From 8c0a8a950e4503cb7a756589e307286fe1116b05 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 16 Apr 2020 12:03:41 +0200 Subject: [PATCH] feat: update JWT Profile for OAuth 2.0 Access Tokens to latest draft BREAKING CHANGE: `at+JWT` JWT draft profile - in the draft's Section 2.2 the claims `iat` and `jti` are now REQUIRED (was RECOMMENDED). --- README.md | 31 ++++++++++++++++++++----------- docs/README.md | 6 ++++-- lib/jwt/verify.js | 4 ++-- test/jwt/verify.test.js | 40 ++++++++++++++++++++++++++++++++-------- 4 files changed, 58 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 57fc3e1063..efe86caede 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,12 @@ Available JWT validation profiles - Generic JWT - OIDC ID Token (`id_token`) - [OpenID Connect Core 1.0][spec-oidc-id_token] -- OAuth 2.0 JWT Access Tokens (`at+JWT`) - [JWT Profile for OAuth 2.0 Access Tokens][draft-ietf-oauth-access-token-jwt] -- OIDC Logout Token (`logout_token`) - [OpenID Connect Back-Channel Logout 1.0][spec-oidc-logout_token] +- (draft 04) OIDC Logout Token (`logout_token`) - [OpenID Connect Back-Channel Logout 1.0][spec-oidc-logout_token] +- (draft 06) OAuth 2.0 JWT Access Tokens (`at+JWT`) - [JWT Profile for OAuth 2.0 Access Tokens][draft-ietf-oauth-access-token-jwt] + +Draft profiles are updated as minor versions of the library, therefore, since they may have breaking +changes use the `~` semver operator when using these and pay close attention to changelog and the +drafts themselves. ## Sponsor @@ -305,12 +309,13 @@ jose.JWE.decrypt( | AES_CBC_HMAC_SHA2 | ✓ | A128CBC-HS256, A192CBC-HS384, A256CBC-HS512 | | (X)ChaCha | ✓ via [plugin][plugin-chacha] | C20P, XC20P | -| 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] | ✓ | `at+JWT` | -| Logout Token - [OpenID Connect Back-Channel Logout 1.0][spec-oidc-logout_token] | ✓ | `logout_token` | -| JARM - [JWT Secured Authorization Response Mode for OAuth 2.0][draft-jarm] | ◯ || +| JWT profile validation | Supported | Stable profile | 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] | ✓ | ✕5 | `at+JWT` | +| Logout Token - [OpenID Connect Back-Channel Logout 1.0][spec-oidc-logout_token] | ✓ | ✕5 | `logout_token` | +| JARM - [JWT Secured Authorization Response Mode for OAuth 2.0][draft-jarm] | ◯ ||| +| [JWT Response for OAuth Token Introspection][draft-jwtintrospection] | ◯ ||| Legend: - **✓** Implemented @@ -322,7 +327,10 @@ Legend: operations but it is an entirely opt-in behaviour, downgrade attacks are prevented by the required use of a special `JWK.Key`-like object that cannot be instantiated through the key import API 3 RSAES OAEP using SHA-2 and MGF1 with SHA-2 is only supported when Node.js >= 12.9.0 runtime is detected -4 ECDH-ES with X25519 and X448 keys is only supported when Node.js >= 13.9.0 runtime is detected +4 ECDH-ES with X25519 and X448 keys is only supported when Node.js >= 13.9.0 runtime is detected +5 Draft specification profiles are updated as minor versions of the library, therefore, +since they may have breaking changes use the `~` semver operator when using these and pay close +attention to changelog and the drafts themselves. ## FAQ @@ -389,11 +397,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-04 -[draft-ietf-oauth-access-token-jwt]: https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt +[draft-ietf-oauth-access-token-jwt]: https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-06 [draft-jarm]: https://openid.net/specs/openid-financial-api-jarm.html +[draft-jwtintrospection]: https://tools.ietf.org/html/draft-ietf-oauth-jwt-introspection-response [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 +[spec-oidc-logout_token]: https://openid.net/specs/openid-connect-backchannel-1_0-04.html#LogoutToken [oidc-token-hash]: https://www.npmjs.com/package/oidc-token-hash [support-sponsor]: https://github.com/sponsors/panva [sponsor-auth0]: https://auth0.com/overview?utm_source=GHsponsor&utm_medium=GHsponsor&utm_campaign=panva-jose&utm_content=auth diff --git a/docs/README.md b/docs/README.md index 9f8e92319e..caed72e846 100644 --- a/docs/README.md +++ b/docs/README.md @@ -888,9 +888,11 @@ Verifies the claims and signature of a JSON Web Token. 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', 'at+JWT', and 'logout_token'. **Default:** 'undefined' + Supported values are 'id_token', 'at+JWT' (draft), and 'logout_token' (draft). **Default:** 'undefined' (generic JWT). Combine this option with the other ones like `maxAuthAge` and `nonce` or - `subject` depending on the use-case. + `subject` depending on the use-case. Draft profiles are updated as minor versions of the library, + therefore, since they may have breaking changes use the `~` semver operator when using these and + pay close attention to changelog and the drafts themselves. - `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. - `typ`: `` Expected JWT "typ" Header Parameter value. An exact match must be found in the diff --git a/lib/jwt/verify.js b/lib/jwt/verify.js index b16b2f81b5..04cd5f7f11 100644 --- a/lib/jwt/verify.js +++ b/lib/jwt/verify.js @@ -153,11 +153,11 @@ const validateOptions = ({ const validateTypes = ({ header, payload }, profile, options) => { isPayloadString(header.alg, '"alg" header parameter', 'alg', true) - isTimestamp(payload.iat, 'iat', profile === IDTOKEN || profile === LOGOUTTOKEN || !!options.maxTokenAge) + isTimestamp(payload.iat, 'iat', profile === IDTOKEN || profile === LOGOUTTOKEN || profile === ATJWT || !!options.maxTokenAge) isTimestamp(payload.exp, 'exp', profile === IDTOKEN || profile === ATJWT) isTimestamp(payload.auth_time, 'auth_time', !!options.maxAuthAge) isTimestamp(payload.nbf, 'nbf') - isPayloadString(payload.jti, '"jti" claim', 'jti', profile === LOGOUTTOKEN || !!options.jti) + isPayloadString(payload.jti, '"jti" claim', 'jti', profile === LOGOUTTOKEN || profile === ATJWT || !!options.jti) isPayloadString(payload.acr, '"acr" claim', 'acr') isPayloadString(payload.nonce, '"nonce" claim', 'nonce', !!options.nonce) isPayloadString(payload.iss, '"iss" claim', 'iss', !!options.issuer) diff --git a/test/jwt/verify.test.js b/test/jwt/verify.test.js index 99d5828f90..3d53742f75 100644 --- a/test/jwt/verify.test.js +++ b/test/jwt/verify.test.js @@ -723,7 +723,7 @@ test('must be a supported value', t => { } { - const token = JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }) + const token = JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', jti: 'random', header: { typ: 'at+JWT' } }) test('profile=at+JWT', t => { JWT.verify(token, key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' }) @@ -752,7 +752,7 @@ test('must be a supported value', t => { test('profile=at+JWT mandates exp to be present', t => { const err = t.throws(() => { JWT.verify( - JWT.sign({ client_id: 'client_id' }, key, { subject: 'subject', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), + JWT.sign({ client_id: 'client_id' }, key, { subject: 'subject', issuer: 'issuer', audience: 'RS', jti: 'random', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) @@ -764,7 +764,7 @@ test('must be a supported value', t => { test('profile=at+JWT mandates client_id to be present', t => { const err = t.throws(() => { JWT.verify( - JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), + JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', jti: 'random', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) @@ -773,10 +773,34 @@ test('must be a supported value', t => { t.is(err.reason, 'missing') }) + test('profile=at+JWT mandates jti to be present', t => { + const err = t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS' }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"jti" claim is missing' }) + t.is(err.claim, 'jti') + t.is(err.reason, 'missing') + }) + + test('profile=at+JWT mandates iat to be present', t => { + const err = t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', iat: false }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim is missing' }) + t.is(err.claim, 'iat') + t.is(err.reason, 'missing') + }) + test('profile=at+JWT mandates sub to be present', t => { const err = t.throws(() => { JWT.verify( - JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', issuer: 'issuer', audience: 'RS', jti: 'random', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) @@ -788,7 +812,7 @@ test('must be a supported value', t => { test('profile=at+JWT mandates iss to be present', t => { const err = t.throws(() => { JWT.verify( - JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', header: { typ: 'at+JWT' } }), + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', jti: 'random', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) @@ -800,7 +824,7 @@ test('must be a supported value', t => { test('profile=at+JWT mandates aud to be present', t => { const err = t.throws(() => { JWT.verify( - JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', header: { typ: 'at+JWT' } }), + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', jti: 'random', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) @@ -812,7 +836,7 @@ test('must be a supported value', t => { test('profile=at+JWT mandates header typ to be present', t => { const err = t.throws(() => { JWT.verify( - JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', issuer: 'issuer' }), + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', jti: 'random', issuer: 'issuer' }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) @@ -824,7 +848,7 @@ test('must be a supported value', t => { test('profile=at+JWT mandates header typ to be present and of the right value', t => { const err = t.throws(() => { JWT.verify( - JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', issuer: 'issuer', header: { typ: 'JWT' } }), + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', jti: 'random', issuer: 'issuer', header: { typ: 'JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } )