From 6c98b6159793993631f5959a606317ed03883e04 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 23 Jul 2019 14:48:13 +0200 Subject: [PATCH] feat: validate JWTs according to a JWT profile - ID Token It is now possible to pass a profile to `JWT.verify` and have the JWT validated according to it. This makes sure you pass all the right options and that required claims are present, prohibited claims are missing and that the right JWT typ is used. More profiles will be added in the future. --- README.md | 46 ++++++++++++++- docs/README.md | 4 ++ lib/index.d.ts | 4 +- lib/jwt/shared_validations.js | 6 +- lib/jwt/sign.js | 16 +++--- lib/jwt/verify.js | 105 +++++++++++++++++++++++++--------- package.json | 2 + test/jwt/verify.test.js | 104 +++++++++++++++++++++++++++++++++ 8 files changed, 247 insertions(+), 40 deletions(-) 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' } + ) + }) + }) +}