diff --git a/README.md b/README.md index de7b8ee8aa..f94286acf6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ implementation is correct. Available JWT validation profiles - Generic JWT -- ID Token (id_token) - [OpenID Connect Core 1.0][spec-oidc-id_token] +- 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]
Detailed feature matrix (Click to expand)
@@ -77,8 +79,8 @@ Legend: | 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] | ◯ || +| 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] | ◯ || Notes @@ -224,6 +226,9 @@ jose.JWT.verify( ) ``` +
+ Verifying OIDC ID Tokens (Click to expand)
+ #### ID Token Verifying ID Token is a JWT, but profiled, there are additional requirements to a JWT to be accepted as an @@ -249,6 +254,59 @@ Note: Depending on the channel you receive an ID Token from the following claims 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 `jose` +
+ +
+ Verifying OAuth 2.0 JWT Access Tokens (Click to expand)
+ +#### JWT Access Token Verifying + +When accepting a JWT-formatted OAuth 2.0 Access Token there are additional requirements for the JWT +to be accepted as an Access Token according to the [specification][draft-ietf-oauth-access-token-jwt] +and it is pretty easy to omit some. Use the `profile` option of `JWT.verify` to make sure +what you're accepting is really a JWT Access Token meant for your Resource Server. This will then +perform all doable validations given the input. See the [documentation][documentation-jwt] for more. + +```js +jose.JWT.verify( + 'eyJhbGciOiJQUzI1NiIsInR5cCI6ImF0K0pXVCIsImtpZCI6InIxTGtiQm8zOTI1UmIyWkZGckt5VTNNVmV4OVQyODE3S3gwdmJpNmlfS2MifQ.eyJzdWIiOiJmb28iLCJjbGllbnRfaWQiOiJ1cm46ZXhhbXBsZTpjbGllbnRfaWQiLCJhdWQiOiJ1cm46ZXhhbXBsZTpyZXNvdXJjZS1zZXJ2ZXIiLCJleHAiOjE1NjM4ODg4MzAsImlzcyI6Imh0dHBzOi8vb3AuZXhhbXBsZS5jb20iLCJzY29wZSI6ImFwaTpyZWFkIn0.UYy8vEGWS0cS24giCYobMMy9-bqI45p807yV1l-2WXX2J4UO-eohV_R58LE2oM88gl414c6XydO6QSYXul5roNPoOs41jpEvreQIP-HmegjbWGutktWJKfvoOblE5FjYwjrwStjLQGUzkq6KWcnDLPGmpFy7n6gZ4LF8YVz4dLEaO335hMNVNrmSPSXYqr7bAWybnLVpLxjDYwNfCO1g0_TlFx8fHh2OftHoOOmJFltFwb8JypkSB-JXVVSEh43IOEjeeMJIG_ylWIOxfLLi5Q7vPWgub83ZTkuGNe4KmlQJKIsH5k0yZSshsLYUOOH0RiXqQ-SA4Ubh3Fowigdu-g', + keystore, + { + profile: 'at+JWT', + issuer: 'https://op.example.com', + audience: 'urn:example:resource-server', + algorithms: ['PS256'] + } +) +``` + +
+ +
+ Verifying OIDC Logout Token (Click to expand)
+ +#### Logout Token Verifying + +Logout Token is a JWT, but profiled, there are additional requirements to a JWT to be accepted as an +Logout 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 Logout 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( + 'eyJhbGciOiJQUzI1NiJ9.eyJzdWIiOiJmb28iLCJhdWQiOiJ1cm46ZXhhbXBsZTpjbGllbnRfaWQiLCJpYXQiOjE1NjM4ODg4MzAsImp0aSI6ImhqazMyN2RzYSIsImlzcyI6Imh0dHBzOi8vb3AuZXhhbXBsZS5jb20iLCJldmVudHMiOnsiaHR0cDovL3NjaGVtYXMub3BlbmlkLm5ldC9ldmVudC9iYWNrY2hhbm5lbC1sb2dvdXQiOnt9fX0.SBi7uNUvjHL9TFoFzautGgTQ1MjyeGUNYHL7inpgq3XgTv6xc9EAKuPRtpixmhdNhmInGwUvAeqDSJxomwv1KK1cTndrC9zAMZ7h657BGQAwGhu7nTm41fWMpKQdiLa9sqp3yit5_FNBmqUNeOoMPrYT_Vl9ytsoNO89MUQy2aqCd-Z7BrNJZH0QycdW6dmYlrmZL7w3t3TaAXoJDJ4Hgl2Itkkkb6_6gO-VoPIdVD8sDuf1zQzGhIkmcFrk0fXczVYOkeF2hNYBuvsM8LuO-EPA3oyE2In9djai3M7yceTQetRa1vwlqWkg_xmYS59ry-6wT44aN7-Y6p0TdXm-Zg', + keystore, + { + profile: 'logout_token', + issuer: 'https://op.example.com', + audience: 'urn:example:client_id', + algorithms: ['PS256'] + } +) +``` + +
+ #### JWS Signing Sign with a private or symmetric key using compact serialization. See the diff --git a/docs/README.md b/docs/README.md index 84a0b21c1f..96c345769b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -834,9 +834,9 @@ 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' for now. **Default:** 'undefined' (generic JWT). Combine this - option with the other ones like `maxAuthAge` and `nonce` or `subject` depending on the - use-case. + Supported values are 'id_token', 'at+JWT', and 'logout_token'. **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/jwt/verify.js b/lib/jwt/verify.js index d0437c7edf..d30810874b 100644 --- a/lib/jwt/verify.js +++ b/lib/jwt/verify.js @@ -12,6 +12,10 @@ const decode = require('./decode') const isPayloadString = isString.bind(undefined, JWTClaimInvalid) const isOptionString = isString.bind(undefined, TypeError) +const IDTOKEN = 'id_token' +const LOGOUTTOKEN = 'logout_token' +const ATJWT = 'at+JWT' + const isTimestamp = (value, label, required = false) => { if (required && value === undefined) { throw new JWTClaimInvalid(`"${label}" claim is missing`) @@ -83,7 +87,7 @@ const validateOptions = (options) => { } switch (options.profile) { - case 'id_token': + case IDTOKEN: if (!options.issuer) { throw new TypeError('"issuer" option is required to validate an ID Token') } @@ -92,6 +96,26 @@ const validateOptions = (options) => { throw new TypeError('"audience" option is required to validate an ID Token') } + break + case ATJWT: + if (!options.issuer) { + throw new TypeError('"issuer" option is required to validate a JWT Access Token') + } + + if (!options.audience) { + throw new TypeError('"audience" option is required to validate a JWT Access Token') + } + + break + case LOGOUTTOKEN: + if (!options.issuer) { + throw new TypeError('"issuer" option is required to validate a Logout Token') + } + + if (!options.audience) { + throw new TypeError('"audience" option is required to validate a Logout Token') + } + break case undefined: break @@ -100,28 +124,72 @@ const validateOptions = (options) => { } } -const validatePayloadTypes = (payload, profile) => { - isTimestamp(payload.iat, 'iat', profile === 'id_token') - isTimestamp(payload.exp, 'exp', profile === 'id_token') +const validateTypes = ({ header, payload }, profile) => { + isPayloadString(header.alg, '"alg" header parameter', true) + + isTimestamp(payload.iat, 'iat', profile === IDTOKEN || profile === LOGOUTTOKEN) + isTimestamp(payload.exp, 'exp', profile === IDTOKEN || profile === ATJWT) isTimestamp(payload.auth_time, 'auth_time') isTimestamp(payload.nbf, 'nbf') - isPayloadString(payload.jti, '"jti" claim') + isPayloadString(payload.jti, '"jti" claim', profile === LOGOUTTOKEN) 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) + isPayloadString(payload.iss, '"iss" claim', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN) + isPayloadString(payload.sub, '"sub" claim', profile === IDTOKEN || profile === ATJWT) + isStringOrArrayOfStrings(payload.aud, 'aud', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN) + isPayloadString(payload.azp, '"azp" claim', profile === IDTOKEN && Array.isArray(payload.aud) && payload.aud.length > 1) isStringOrArrayOfStrings(payload.amr, 'amr') + + if (profile === ATJWT) { + isPayloadString(payload.client_id, '"client_id" claim', true) + isPayloadString(header.typ, '"typ" header parameter', true) + } + + if (profile === LOGOUTTOKEN) { + isPayloadString(payload.sid, '"sid" claim') + + if (!('sid' in payload) && !('sub' in payload)) { + throw new JWTClaimInvalid('either "sid" or "sub" (or both) claims must be present') + } + + if ('nonce' in payload) { + throw new JWTClaimInvalid('"nonce" claim is prohibited') + } + + if (!('events' in payload)) { + throw new JWTClaimInvalid('"events" claim is missing') + } + + if (!isObject(payload.events)) { + throw new JWTClaimInvalid('"events" claim must be an object') + } + + if (!('http://schemas.openid.net/event/backchannel-logout' in payload.events)) { + throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim') + } + + if (!isObject(payload.events['http://schemas.openid.net/event/backchannel-logout'])) { + throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object') + } + } } -const checkAudiencePresence = (audPayload, audOption) => { +const checkAudiencePresence = (audPayload, audOption, profile) => { if (typeof audPayload === 'string') { return audOption.includes(audPayload) } - audPayload = new Set(audPayload) - return audOption.some(Set.prototype.has.bind(audPayload)) + if (profile === ATJWT) { + // reject if it contains additional audiences that are not known aliases of the resource + // indicator of the current resource server + audOption = new Set(audOption) + return audPayload.every(Set.prototype.has.bind(audOption)) + } else { + // Each principal intended to process the JWT MUST + // identify itself with a value in the audience claim + audPayload = new Set(audPayload) + return audOption.some(Set.prototype.has.bind(audPayload)) + } } module.exports = (token, key, options = {}) => { @@ -157,7 +225,7 @@ module.exports = (token, key, options = {}) => { const unix = epoch(now) const decoded = decode(token, { complete: true }) - validatePayloadTypes(decoded.payload, profile) + validateTypes(decoded, profile) if (issuer && decoded.payload.iss !== issuer) { throw new JWTClaimInvalid('issuer mismatch') @@ -175,7 +243,7 @@ module.exports = (token, key, options = {}) => { throw new JWTClaimInvalid('jti mismatch') } - if (audience && !checkAudiencePresence(decoded.payload.aud, typeof audience === 'string' ? [audience] : audience)) { + if (audience && !checkAudiencePresence(decoded.payload.aud, typeof audience === 'string' ? [audience] : audience, profile)) { throw new JWTClaimInvalid('audience mismatch') } @@ -214,10 +282,14 @@ module.exports = (token, key, options = {}) => { } } - if (profile === 'id_token' && Array.isArray(decoded.payload.aud) && decoded.payload.aud.length > 1 && decoded.payload.azp !== audience) { + if (profile === IDTOKEN && Array.isArray(decoded.payload.aud) && decoded.payload.aud.length > 1 && decoded.payload.azp !== audience) { throw new JWTClaimInvalid('azp mismatch') } + if (profile === ATJWT && decoded.header.typ !== ATJWT) { + throw new JWTClaimInvalid('invalid JWT typ header value for the used validation profile') + } + key = getKey(key, true) if (complete && key instanceof KeyStore) { diff --git a/package.json b/package.json index 07b45ca96d..22eddc1324 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,10 @@ "jwks", "jws", "jwt", + "access_token", + "access token", + "logout_token", + "logout token", "secp256k1", "sign", "validate", @@ -29,6 +33,7 @@ ], "homepage": "https://github.com/panva/jose", "repository": "panva/jose", + "funding": "https://github.com/sponsors/panva", "license": "MIT", "author": "Filip Skokan ", "files": [ @@ -36,7 +41,6 @@ "LICENSE_THIRD_PARTY", "types/index.d.ts" ], - "funding": "https://github.com/sponsors/panva", "main": "lib/index.js", "types": "types/index.d.ts", "scripts": { @@ -56,6 +60,13 @@ "@commitlint/config-conventional" ] }, + "ava": { + "babel": false, + "compileEnhancements": false, + "files": [ + "test/**/*.test.js" + ] + }, "dependencies": { "asn1.js": "^5.2.0" }, @@ -72,13 +83,6 @@ "engines": { "node": ">=10.13.0" }, - "ava": { - "babel": false, - "compileEnhancements": false, - "files": [ - "test/**/*.test.js" - ] - }, "standard": { "parser": "babel-eslint" } diff --git a/test/jwt/verify.test.js b/test/jwt/verify.test.js index 6ed0082ccd..08a1cc1cd1 100644 --- a/test/jwt/verify.test.js +++ b/test/jwt/verify.test.js @@ -107,7 +107,7 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { test(`"${claim} must be a timestamp when provided"`, t => { ;['', 'foo', true, null, [], {}].forEach((val) => { t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({ [claim]: val })}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: val })}.` JWT.verify(invalid, key) }, { instanceOf: errors.JWTClaimInvalid, message: `"${claim}" claim must be a unix timestamp` }) }) @@ -118,7 +118,7 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { test(`"${claim} must be a string when provided"`, t => { ;['', 0, 1, true, null, [], {}].forEach((val) => { t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({ [claim]: val })}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: val })}.` JWT.verify(invalid, key) }, { instanceOf: errors.JWTClaimInvalid, message: `"${claim}" claim must be a string` }) }) @@ -129,11 +129,11 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { test(`"${claim} must be a string when provided"`, t => { ;['', 0, 1, true, null, [], {}].forEach((val) => { t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({ [claim]: val })}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: val })}.` JWT.verify(invalid, key) }, { instanceOf: errors.JWTClaimInvalid, message: `"${claim}" claim must be a string or array of strings` }) t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({ [claim]: [val] })}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: [val] })}.` JWT.verify(invalid, key) }, { instanceOf: errors.JWTClaimInvalid, message: `"${claim}" claim must be a string or array of strings` }) }) @@ -148,7 +148,7 @@ Object.entries({ }).forEach(([option, claim]) => { test(`option.${option} validation fails`, t => { t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({ [claim]: 'foo' })}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: 'foo' })}.` JWT.verify(invalid, key, { [option]: 'bar' }) }, { instanceOf: errors.JWTClaimInvalid, message: `${option} mismatch` }) }) @@ -162,11 +162,11 @@ Object.entries({ test('option.audience validation fails', t => { t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({ aud: 'foo' })}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ aud: 'foo' })}.` JWT.verify(invalid, key, { audience: 'bar' }) }, { instanceOf: errors.JWTClaimInvalid, message: 'audience mismatch' }) t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({ aud: ['foo'] })}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ aud: ['foo'] })}.` JWT.verify(invalid, key, { audience: 'bar' }) }, { instanceOf: errors.JWTClaimInvalid, message: 'audience mismatch' }) }) @@ -190,7 +190,7 @@ test('option.audience validation success', t => { test('option.maxAuthAge requires iat to be in the payload', t => { t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({})}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({})}.` JWT.verify(invalid, key, { maxAuthAge: '30s' }) }, { instanceOf: errors.JWTClaimInvalid, message: 'missing auth_time' }) }) @@ -200,7 +200,7 @@ const now = new Date(epoch * 1000) test('option.maxAuthAge checks auth_time', t => { t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({ auth_time: epoch - 31 })}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ auth_time: epoch - 31 })}.` JWT.verify(invalid, key, { maxAuthAge: '30s', now }) }, { instanceOf: errors.JWTClaimInvalid, message: 'too much time has elapsed since the last End-User authentication' }) }) @@ -213,14 +213,14 @@ test('option.maxAuthAge checks auth_time (with tolerance)', t => { test('option.maxTokenAge requires iat to be in the payload', t => { t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({})}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({})}.` JWT.verify(invalid, key, { maxTokenAge: '30s' }) }, { instanceOf: errors.JWTClaimInvalid, message: 'missing iat claim' }) }) test('option.maxTokenAge checks iat elapsed time', t => { t.throws(() => { - const invalid = `e30.${base64url.JSON.encode({ iat: epoch - 31 })}.` + const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ iat: epoch - 31 })}.` JWT.verify(invalid, key, { maxTokenAge: '30s', now }) }, { instanceOf: errors.JWTClaimInvalid, message: 'maxTokenAge exceeded' }) }) @@ -337,26 +337,28 @@ test('nbf check (passed because of ignoreIat)', t => { 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"' }) - }) +// 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' }) + JWT.verify(token, key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' }) }) 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' }) + JWT.verify(token, key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' }) }) test('profile=id_token mandates exp to be present', t => { @@ -440,6 +442,249 @@ test('nbf check (passed because of ignoreIat)', t => { }) } +{ + const token = JWT.sign({ + events: { + 'http://schemas.openid.net/event/backchannel-logout': {} + } + }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }) + + test('profile=logout_token requires issuer option too', t => { + t.throws(() => { + JWT.verify(token, key, { profile: 'logout_token' }) + }, { instanceOf: TypeError, message: '"issuer" option is required to validate a Logout Token' }) + JWT.verify(token, key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' }) + }) + + test('profile=logout_token requires audience option too', t => { + t.throws(() => { + JWT.verify(token, key, { profile: 'logout_token', issuer: 'issuer' }) + }, { instanceOf: TypeError, message: '"audience" option is required to validate a Logout Token' }) + JWT.verify(token, key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' }) + }) + + test('profile=logout_token mandates jti to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { subject: 'subject', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"jti" claim is missing' }) + }) + + test('profile=logout_token mandates events to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"events" claim is missing' }) + }) + + test('profile=logout_token mandates events to be an object', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ + events: [] + }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"events" claim must be an object' }) + }) + + test('profile=logout_token mandates events to have the backchannel logout member', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ + events: {} + }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim' }) + }) + + test('profile=logout_token mandates events to have the backchannel logout member thats an object', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ + events: { + 'http://schemas.openid.net/event/backchannel-logout': [] + } + }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object' }) + }) + + test('profile=logout_token mandates iat to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { jti: 'foo', iat: false, subject: 'subject', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim is missing' }) + }) + + test('profile=logout_token mandates sub or sid to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { jti: 'foo', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: 'either "sid" or "sub" (or both) claims must be present' }) + }) + + test('profile=logout_token mandates sid to be a string when present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ sid: true }, key, { jti: 'foo', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"sid" claim must be a string' }) + }) + + test('profile=logout_token prohibits nonce', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ nonce: 'foo' }, key, { subject: 'subject', jti: 'foo', issuer: 'issuer', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"nonce" claim is prohibited' }) + }) + + test('profile=logout_token mandates iss to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { jti: 'foo', subject: 'subject', audience: 'client_id' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"iss" claim is missing' }) + }) + + test('profile=logout_token mandates aud to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer' }), + key, + { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"aud" claim is missing' }) + }) +} + +{ + const token = JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }) + + test('profile=at+JWT requires issuer option too', t => { + t.throws(() => { + JWT.verify(token, key, { profile: 'at+JWT' }) + }, { instanceOf: TypeError, message: '"issuer" option is required to validate a JWT Access Token' }) + JWT.verify(token, key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' }) + }) + + test('profile=at+JWT requires audience option too', t => { + t.throws(() => { + JWT.verify(token, key, { profile: 'at+JWT', issuer: 'issuer' }) + }, { instanceOf: TypeError, message: '"audience" option is required to validate a JWT Access Token' }) + JWT.verify(token, key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' }) + }) + + test('profile=at+JWT mandates exp to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ client_id: 'client_id' }, key, { subject: 'subject', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"exp" claim is missing' }) + }) + + test('profile=at+JWT mandates that all known aliases of the current RS are provided as the audience option', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['RS-alias1', 'RS-alias2'], header: { typ: 'at+JWT' } }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: ['RS-alias1'] } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: 'audience mismatch' }) + JWT.verify( + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['RS-alias1', 'RS-alias2'], header: { typ: 'at+JWT' } }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: ['RS-alias1', 'RS-alias2'] } + ) + }) + + test('profile=at+JWT mandates client_id to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"client_id" claim is missing' }) + }) + + test('profile=at+JWT mandates sub to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"sub" claim is missing' }) + }) + + test('profile=at+JWT mandates iss to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', header: { typ: 'at+JWT' } }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"iss" claim is missing' }) + }) + + test('profile=at+JWT mandates aud to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', header: { typ: 'at+JWT' } }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"aud" claim is missing' }) + }) + + test('profile=at+JWT mandates header typ to be present', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', issuer: 'issuer' }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: '"typ" header parameter is missing' }) + }) + + test('profile=at+JWT mandates header typ to be present and of the right value', t => { + t.throws(() => { + JWT.verify( + JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', issuer: 'issuer', header: { typ: 'JWT' } }), + key, + { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + ) + }, { instanceOf: errors.JWTClaimInvalid, message: 'invalid JWT typ header value for the used validation profile' }) + }) +} + test('invalid tokens', t => { t.throws(() => { JWT.verify( diff --git a/types/index.d.ts b/types/index.d.ts index 134809a9fd..f9727f92bb 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -23,7 +23,7 @@ export type OKPCurve = 'Ed25519' | 'Ed448' | 'X25519' | 'X448'; export type keyType = 'RSA' | 'EC' | 'OKP' | 'oct'; export type asymmetricKeyObjectTypes = 'private' | 'public'; export type keyObjectTypes = asymmetricKeyObjectTypes | 'secret'; -export type JWTProfiles = 'id_token'; +export type JWTProfiles = 'id_token' | 'at+JWT' | 'logout_token'; export type KeyInput = PrivateKeyInput | PublicKeyInput | string | Buffer; export type ProduceKeyInput = JWK.Key | KeyObject | KeyInput | JWKOctKey | JWKRSAKey | JWKECKey | JWKOKPKey; export type ConsumeKeyInput = ProduceKeyInput | JWKS.KeyStore;