diff --git a/README.md b/README.md index 5d474c2412..82aee28fa3 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,18 @@ The following specifications are implemented by @panva/jose The test suite utilizes examples defined in [RFC7520][spec-cookbook] to confirm its JOSE implementation is correct. +Available JWT validation profiles + +- Generic JWT +- ID Token (id_token) - [OpenID Connect Core 1.0][spec-oidc-id_token] +
Detailed feature matrix (Click to expand)
Legend: - **✓** Implemented - **✕** Missing node crypto support / won't implement -- **◯** not planned (yet?) / PR / Use-Case first welcome +- **◯** TBD | JWK Key Types | Supported || | -- | -- | -- | @@ -67,6 +72,13 @@ Legend: | AES GCM | ✓ | A128GCM, A192GCM, A256GCM | | AES_CBC_HMAC_SHA2 | ✓ | A128CBC-HS256, A192CBC-HS384, A256CBC-HS512 | +| JWT profile validation | Supported | profile option value | +| -- | -- | -- | +| ID Token - [OpenID Connect Core 1.0][spec-oidc-id_token] | ✓ | `id_token` | +| JWT Access Tokens [JWT Profile for OAuth 2.0 Access Tokens][draft-ietf-oauth-access-token-jwt] | ◯ || +| Logout Token - [OpenID Connect Back-Channel Logout 1.0][spec-oidc-logout_token] | ◯ || +| JARM - [JWT Secured Authorization Response Mode for OAuth 2.0][draft-jarm] | ◯ || + --- Pending Node.js Support 🤞: @@ -182,7 +194,7 @@ jose.JWT.sign( ) ``` -#### JWT Verification +#### JWT Verifying Verify with a public or symmetric key with plethora of convenience options. See the [documentation][documentation-jwt] for more. @@ -199,6 +211,31 @@ jose.JWT.verify( ) ``` +#### ID Token Verifying + +ID Token is a JWT, but profiled, there are additional requirements to a JWT to be accepted as an +ID Token and it is pretty easy to omit some, use the `profile` option of `JWT.verify` to make sure +what you're accepting is really an ID Token meant to your Client. This will then perform all +doable validations given the input. See the [documentation][documentation-jwt] for more. + +```js +jose.JWT.verify( + 'eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InIxTGtiQm8zOTI1UmIyWkZGckt5VTNNVmV4OVQyODE3S3gwdmJpNmlfS2MifQ.eyJzdWIiOiJmb28iLCJub25jZSI6ImE1MWNjZjA4ZjRiYmIwNmU4ODcxNWRkYzRiYmI0MWQ4IiwiYXVkIjoidXJuOmV4YW1wbGU6Y2xpZW50X2lkIiwiZXhwIjoxNTYzODg4ODMwLCJpYXQiOjE1NjM4ODUyMzAsImlzcyI6Imh0dHBzOi8vb3AuZXhhbXBsZS5jb20ifQ.RKCZczgICF5G9XdNDSwe4dolGauQHptpFKPzahA2wYGG2HKrKhyC8ZzqpeVc8cbntuqFBgABJVv6_9YICRx_dgwPYydTpZfZYjHnxrdWF9QsIPEGs672mrnhqIXUnXoseZ0TF6GOq6P7Qbf6gk1ru7TAbr_ieyJnNWcJhh5iHpz1k3mFz0TyTh7UNXshtQXftPUipqz4OBni5r9UaZXHw8B3QYOnms8__GJ3owOxaqkr1jgRs_EWqMlBNjPaj7ElVaeBWljDKuoK673tH0heSpgzUmUX_W8IDUVqs33uglpZwAQC7cAA5mGEg2odcRpvpP5M-WaP4RE9dl9jzcYmrw', + keystore, + { + profile: 'id_token', + issuer: 'https://op.example.com', + audience: 'urn:example:client_id', + nonce: 'a51ccf08f4bbb06e88715ddc4bbb41d8', + algorithms: ['PS256'] + } +) +``` + +Note: Depending on the channel you receive an ID Token from the following claims may be required +and must also be checked: `at_hash`, `c_hash` or `s_hash`. Use e.g. [`oidc-token-hash`][oidc-token-hash] +to validate those hashes after getting the ID Token payload and signature validated by @panva/jose. + #### JWS Signing Sign with a private or symmetric key using compact serialization. See the @@ -343,7 +380,12 @@ in terms of performance and API (not having well defined errors). [spec-jwt]: https://tools.ietf.org/html/rfc7519 [spec-okp]: https://tools.ietf.org/html/rfc8037 [draft-secp256k1]: https://tools.ietf.org/html/draft-ietf-cose-webauthn-algorithms-01 +[draft-ietf-oauth-access-token-jwt]: https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt +[draft-jarm]: https://openid.net/specs/openid-financial-api-jarm.html [spec-thumbprint]: https://tools.ietf.org/html/rfc7638 +[spec-oidc-id_token]: https://openid.net/specs/openid-connect-core-1_0.html#IDToken +[spec-oidc-logout_token]: https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken +[oidc-token-hash]: https://www.npmjs.com/package/oidc-token-hash [suggest-feature]: https://github.com/panva/jose/issues/new?labels=enhancement&template=feature-request.md&title=proposal%3A+ [support-patreon]: https://www.patreon.com/panva [support-paypal]: https://www.paypal.me/panva diff --git a/docs/README.md b/docs/README.md index 352a2e1e2a..3a86900066 100644 --- a/docs/README.md +++ b/docs/README.md @@ -809,6 +809,10 @@ Verifies the claims and signature of a JSON Web Token. - `algorithms`: `string[]` Array of expected signing algorithms. JWT signed with an algorithm not found in this option will be rejected. **Default:** accepts all algorithms available on the passed key (or keys in the keystore) + - `profile`: `` To validate a JWT according to a specific profile, e.g. as an ID Token. + Supported values are 'id_token' for now. **Default:** 'undefined' (generic JWT). Combine this + option with the other ones like `maxAuthAge` and `nonce` or `subject` depending on the + use-case. - `audience`: `` | `string[]` Expected audience value(s). When string an exact match must be found in the payload, when array at least one must be matched. - `clockTolerance`: `` Clock Tolerance for comparing timestamps, provided as timespan diff --git a/lib/index.d.ts b/lib/index.d.ts index 6e87b90f12..b19d754042 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -20,6 +20,7 @@ type OKPCurve = 'Ed25519' | 'Ed448' | 'X25519' | 'X448' type keyType = 'RSA' | 'EC' | 'OKP' | 'oct' type asymmetricKeyObjectTypes = 'private' | 'public' type keyObjectTypes = asymmetricKeyObjectTypes | 'secret' +type JWTProfiles = 'id_token' interface JWKOctKey extends BasicParameters { // no x5c kty: 'oct', @@ -348,7 +349,8 @@ export namespace JWT { algorithms?: string[], nonce?: string, now?: Date, - crit?: string[] + crit?: string[], + profile?: JWTProfiles } export function verify(jwt: string, key: JWK.Key | JWKS.KeyStore, options?: VerifyOptions): object export function verify(jwt: string, key: JWK.Key | JWKS.KeyStore, options?: VerifyOptions): completeResult diff --git a/lib/jwt/shared_validations.js b/lib/jwt/shared_validations.js index b732fd77fd..099a64b19a 100644 --- a/lib/jwt/shared_validations.js +++ b/lib/jwt/shared_validations.js @@ -1,7 +1,11 @@ const isNotString = val => typeof val !== 'string' || val.length === 0 module.exports.isNotString = isNotString -module.exports.isStringOptional = function isStringOptional (Err, value, label) { +module.exports.isString = function isString (Err, value, label, required = false) { + if (required && value === undefined) { + throw new Err(`${label} is missing`) + } + if (value !== undefined && isNotString(value)) { throw new Err(`${label} must be a string`) } diff --git a/lib/jwt/sign.js b/lib/jwt/sign.js index 587c1c639b..d1104b5618 100644 --- a/lib/jwt/sign.js +++ b/lib/jwt/sign.js @@ -3,7 +3,7 @@ const secs = require('../help/secs') const epoch = require('../help/epoch') const JWS = require('../jws') -const isStringOptional = require('./shared_validations').isStringOptional.bind(undefined, TypeError) +const isString = require('./shared_validations').isString.bind(undefined, TypeError) const validateOptions = (options) => { if (typeof options.iat !== 'boolean') { @@ -14,8 +14,8 @@ const validateOptions = (options) => { throw new TypeError('options.kid must be a boolean') } - isStringOptional(options.subject, 'options.subject') - isStringOptional(options.issuer, 'options.issuer') + isString(options.subject, 'options.subject') + isString(options.issuer, 'options.issuer') if ( options.audience !== undefined && @@ -31,11 +31,11 @@ const validateOptions = (options) => { throw new TypeError('options.header must be an object') } - isStringOptional(options.algorithm, 'options.algorithm') - isStringOptional(options.expiresIn, 'options.expiresIn') - isStringOptional(options.notBefore, 'options.notBefore') - isStringOptional(options.jti, 'options.jti') - isStringOptional(options.nonce, 'options.nonce') + isString(options.algorithm, 'options.algorithm') + isString(options.expiresIn, 'options.expiresIn') + isString(options.notBefore, 'options.notBefore') + isString(options.jti, 'options.jti') + isString(options.nonce, 'options.nonce') if (!(options.now instanceof Date) || !options.now.getTime()) { throw new TypeError('options.now must be a valid Date object') diff --git a/lib/jwt/verify.js b/lib/jwt/verify.js index 977c05315d..0cdb5fc8bb 100644 --- a/lib/jwt/verify.js +++ b/lib/jwt/verify.js @@ -5,19 +5,27 @@ const JWS = require('../jws') const { KeyStore } = require('../jwks') const { JWTClaimInvalid } = require('../errors') -const { isStringOptional, isNotString } = require('./shared_validations') +const { isString, isNotString } = require('./shared_validations') const decode = require('./decode') -const isPayloadStringOptional = isStringOptional.bind(undefined, JWTClaimInvalid) -const isOptionStringOptional = isStringOptional.bind(undefined, TypeError) +const isPayloadString = isString.bind(undefined, JWTClaimInvalid) +const isOptionString = isString.bind(undefined, TypeError) + +const isTimestamp = (value, label, required = false) => { + if (required && value === undefined) { + throw new JWTClaimInvalid(`"${label}" claim is missing`) + } -const isTimestampOptional = (value, label) => { if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value))) { throw new JWTClaimInvalid(`"${label}" claim must be a unix timestamp`) } } -const isStringOrArrayOfStringsOptional = (value, label) => { +const isStringOrArrayOfStrings = (value, label, required = false) => { + if (required && value === undefined) { + throw new JWTClaimInvalid(`"${label}" claim is missing`) + } + if (value !== undefined && (isNotString(value) && isNotArrayOfStrings(value))) { throw new JWTClaimInvalid(`"${label}" claim must be a string or array of strings`) } @@ -26,6 +34,8 @@ const isStringOrArrayOfStringsOptional = (value, label) => { const isNotArrayOfStrings = val => !Array.isArray(val) || val.length === 0 || val.some(isNotString) const validateOptions = (options) => { + isOptionString(options.profile, 'options.profile') + if (typeof options.complete !== 'boolean') { throw new TypeError('options.complete must be a boolean') } @@ -33,19 +43,21 @@ const validateOptions = (options) => { if (typeof options.ignoreExp !== 'boolean') { throw new TypeError('options.ignoreExp must be a boolean') } + if (typeof options.ignoreNbf !== 'boolean') { throw new TypeError('options.ignoreNbf must be a boolean') } + if (typeof options.ignoreIat !== 'boolean') { throw new TypeError('options.ignoreIat must be a boolean') } - isOptionStringOptional(options.maxTokenAge, 'options.maxTokenAge') - isOptionStringOptional(options.subject, 'options.subject') - isOptionStringOptional(options.issuer, 'options.issuer') - isOptionStringOptional(options.maxAuthAge, 'options.maxAuthAge') - isOptionStringOptional(options.jti, 'options.jti') - isOptionStringOptional(options.clockTolerance, 'options.clockTolerance') + isOptionString(options.maxTokenAge, 'options.maxTokenAge') + isOptionString(options.subject, 'options.subject') + isOptionString(options.issuer, 'options.issuer') + isOptionString(options.maxAuthAge, 'options.maxAuthAge') + isOptionString(options.jti, 'options.jti') + isOptionString(options.clockTolerance, 'options.clockTolerance') if (options.audience !== undefined && (isNotString(options.audience) && isNotArrayOfStrings(options.audience))) { throw new TypeError('options.audience must be a string or an array of strings') @@ -55,7 +67,7 @@ const validateOptions = (options) => { throw new TypeError('options.algorithms must be an array of strings') } - isOptionStringOptional(options.nonce, 'options.nonce') + isOptionString(options.nonce, 'options.nonce') if (!(options.now instanceof Date) || !options.now.getTime()) { throw new TypeError('options.now must be a valid Date object') @@ -68,21 +80,38 @@ const validateOptions = (options) => { if (options.crit !== undefined && isNotArrayOfStrings(options.crit)) { throw new TypeError('options.crit must be an array of strings') } + + switch (options.profile) { + case 'id_token': + if (!options.issuer) { + throw new TypeError('"issuer" option is required to validate an ID Token') + } + + if (!options.audience) { + throw new TypeError('"audience" option is required to validate an ID Token') + } + + break + case undefined: + break + default: + throw new TypeError(`unsupported options.profile value "${options.profile}"`) + } } -const validatePayloadTypes = (payload) => { - isTimestampOptional(payload.iat, 'iat') - isTimestampOptional(payload.exp, 'exp') - isTimestampOptional(payload.auth_time, 'auth_time') - isTimestampOptional(payload.nbf, 'nbf') - isPayloadStringOptional(payload.jti, '"jti" claim') - isPayloadStringOptional(payload.acr, '"acr" claim') - isPayloadStringOptional(payload.nonce, '"nonce" claim') - isPayloadStringOptional(payload.iss, '"iss" claim') - isPayloadStringOptional(payload.sub, '"sub" claim') - isPayloadStringOptional(payload.azp, '"azp" claim') - isStringOrArrayOfStringsOptional(payload.aud, 'aud') - isStringOrArrayOfStringsOptional(payload.amr, 'amr') +const validatePayloadTypes = (payload, profile) => { + isTimestamp(payload.iat, 'iat', profile === 'id_token') + isTimestamp(payload.exp, 'exp', profile === 'id_token') + isTimestamp(payload.auth_time, 'auth_time') + isTimestamp(payload.nbf, 'nbf') + isPayloadString(payload.jti, '"jti" claim') + isPayloadString(payload.acr, '"acr" claim') + isPayloadString(payload.nonce, '"nonce" claim') + isPayloadString(payload.iss, '"iss" claim', profile === 'id_token') + isPayloadString(payload.sub, '"sub" claim', profile === 'id_token') + isStringOrArrayOfStrings(payload.aud, 'aud', profile === 'id_token') + isPayloadString(payload.azp, '"azp" claim', profile === 'id_token' && Array.isArray(payload.aud) && payload.aud.length > 1) + isStringOrArrayOfStrings(payload.amr, 'amr') } const checkAudiencePresence = (audPayload, audOption) => { @@ -101,17 +130,33 @@ module.exports = (token, key, options = {}) => { const { algorithms, audience, clockTolerance, complete = false, crit, ignoreExp = false, - ignoreIat = false, ignoreNbf = false, issuer, jti, maxAuthAge, maxTokenAge, nonce, now = new Date(), subject + ignoreIat = false, ignoreNbf = false, issuer, jti, maxAuthAge, maxTokenAge, nonce, now = new Date(), + subject, profile } = options validateOptions({ - algorithms, audience, clockTolerance, complete, crit, ignoreExp, ignoreIat, ignoreNbf, issuer, jti, maxAuthAge, maxTokenAge, nonce, now, subject + algorithms, + audience, + clockTolerance, + complete, + crit, + ignoreExp, + ignoreIat, + ignoreNbf, + issuer, + jti, + maxAuthAge, + maxTokenAge, + nonce, + now, + profile, + subject }) const unix = epoch(now) const decoded = decode(token, { complete: true }) - validatePayloadTypes(decoded.payload) + validatePayloadTypes(decoded.payload, profile) if (issuer && decoded.payload.iss !== issuer) { throw new JWTClaimInvalid('issuer mismatch') @@ -168,6 +213,10 @@ module.exports = (token, key, options = {}) => { } } + if (profile === 'id_token' && Array.isArray(decoded.payload.aud) && decoded.payload.aud.length > 1 && decoded.payload.azp !== audience) { + throw new JWTClaimInvalid('azp mismatch') + } + if (complete && key instanceof KeyStore) { ({ key } = JWS.verify(token, key, { crit, algorithms, complete: true })) } else { diff --git a/package.json b/package.json index 305fef517a..a370c04922 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "encrypt", "flattened", "general", + "id token", + "id_token", "jose", "json web token", "jsonwebtoken", diff --git a/test/jwt/verify.test.js b/test/jwt/verify.test.js index e4dec32413..4ce669bf64 100644 --- a/test/jwt/verify.test.js +++ b/test/jwt/verify.test.js @@ -38,6 +38,7 @@ test('options must be an object', t => { test('options.clockTolerance must be a string', string, 'clockTolerance') test('options.issuer must be a string', string, 'issuer') test('options.jti must be a string', string, 'jti') +test('options.profile must be a string', string, 'profile') test('options.maxAuthAge must be a string', string, 'maxAuthAge') test('options.maxTokenAge must be a string', string, 'maxTokenAge') test('options.nonce must be a string', string, 'nonce') @@ -329,3 +330,106 @@ test('nbf check (passed because of ignoreIat)', t => { JWT.verify(token, key, { now, ignoreNbf: true }) t.pass() }) + +{ + // JWT options.profile + test('must be a supported value', t => { + t.throws(() => { + JWT.verify('foo', key, { profile: 'foo' }) + }, { instanceOf: TypeError, message: 'unsupported options.profile value "foo"' }) + }) + + const token = JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'client_id' }) + + test('profile=id_token requires issuer option too', t => { + t.throws(() => { + JWT.verify(token, key, { profile: 'id_token' }) + }, { instanceOf: TypeError, message: '"issuer" option is required to validate an ID Token' }) + }) + + test('profile=id_token requires audience option too', t => { + t.throws(() => { + JWT.verify(token, key, { profile: 'id_token', issuer: 'issuer' }) + }, { instanceOf: TypeError, message: '"audience" option is required to validate an ID Token' }) + }) + + test('profile=id_token mandates exp to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { subject: 'subject', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"exp" claim is missing' }) + }) + + test('profile=id_token mandates iat to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { expiresIn: '10m', iat: false, subject: 'subject', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim is missing' }) + }) + + test('profile=id_token mandates sub to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { expiresIn: '10m', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"sub" claim is missing' }) + }) + + test('profile=id_token mandates iss to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', audience: 'client_id' }), + key, + { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"iss" claim is missing' }) + }) + + test('profile=id_token mandates aud to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer' }), + key, + { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"aud" claim is missing' }) + }) + + test('profile=id_token mandates azp to be present when multiple audiences are used', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['client_id', 'another audience'] }), + key, + { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"azp" claim is missing' }) + }) + + test('profile=id_token mandates azp to match the audience when required', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ azp: 'mismatched' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['client_id', 'another audience'] }), + key, + { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: 'azp mismatch' }) + }) + + test('profile=id_token validates full id tokens', t => { + t.notThrows(() => { + JWT.verify( + JWT.sign({ azp: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['client_id', 'another audience'] }), + key, + { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + ) + }) + }) +}