From fd69d7f5093d0b3a231d7d79aa3bca3a8a64464c Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 4 Sep 2020 15:33:24 +0200 Subject: [PATCH] refactor: move JWT profile specifics outside of generic JWT BREAKING CHANGE: the `JWT.verify` profile option was removed, use e.g. `JWT.IdToken.verify` instead. BREAKING CHANGE: removed the `maxAuthAge` `JWT.verify` option, this option is now only present at the specific JWT profile APIs where the `auth_time` property applies. BREAKING CHANGE: removed the `nonce` `JWT.verify` option, this option is now only present at the specific JWT profile APIs where the `nonce` property applies. BREAKING CHANGE: the `acr`, `amr`, `nonce` and `azp` claim value types will only be checked when verifying a specific JWT profile using its dedicated API. BREAKING CHANGE: using the draft implementing APIs will emit a one-time warning per process using `process.emitWarning` --- .github/workflows/test.yml | 4 + README.md | 12 +- docs/README.md | 50 +++-- lib/jwt/profiles.js | 167 +++++++++++++++- lib/jwt/shared_validations.js | 41 +++- lib/jwt/verify.js | 154 ++------------- test/jwt/verify.test.js | 362 +++++++++++++++++++--------------- types/index.d.ts | 36 ++-- 8 files changed, 494 insertions(+), 332 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3914d597a9..11d61225c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,8 @@ jobs: - run: npm run lint-ts test: + env: + NODE_NO_WARNINGS: 1 runs-on: ${{ matrix.os }} strategy: matrix: @@ -81,6 +83,8 @@ jobs: run: npx codecov test-electron: + env: + NODE_NO_WARNINGS: 1 runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/README.md b/README.md index 4a067f7c63..898aff039d 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,8 @@ jose.JWT.verify( Verifying OIDC ID Tokens (Click to expand)
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` or the -`JWT.IdToken.verify` shorthand to make sure what you're accepting is really an ID Token meant to +ID Token and it is pretty easy to omit some, use the +`JWT.IdToken.verify` API 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. @@ -175,8 +175,8 @@ attention to changelog and the drafts themselves. 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` or the -`JWT.AccessToken.verify` shorthand to make sure what you're accepting is really a JWT Access Token +and it is pretty easy to omit some. Use the +`JWT.AccessToken.verify` API 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. @@ -202,8 +202,8 @@ since they may have breaking changes use the `~` semver operator when using thes attention to changelog and the drafts themselves. 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` or the -`JWT.LogoutToken.verify` to make sure what you're accepting is really an Logout Token meant to your +Logout Token and it is pretty easy to omit some, use the +`JWT.LogoutToken.verify` API 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. diff --git a/docs/README.md b/docs/README.md index 5d31cff7c3..f72153d3a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -908,12 +908,6 @@ 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', '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. 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 @@ -935,14 +929,9 @@ Verifies the claims and signature of a JSON Web Token. - `issuer`: `` | `string[]` Expected issuer value(s). When string an exact match must be found in the payload, when array at least one must be matched. - `jti`: `` Expected jti value. An exact match must be found in the payload. - - `maxAuthAge`: `` When provided the payload is checked to have the "auth_time" claim and - its value is validated, provided as timespan string e.g. `30m`, `24 hours`. See - [OpenID Connect Core 1.0][connect-core] for details. Do not confuse with maxTokenAge option. - `maxTokenAge`: `` When provided the payload is checked to have the "iat" claim and its value is validated not to be older than the provided timespan string e.g. `30m`, `24 hours`. Do not confuse with maxAuthAge option. - - `nonce`: `` Expected nonce value. An exact match must be found in the payload. See - [OpenID Connect Core 1.0][connect-core] for details. - `now`: `` Date object to be used instead of the current unix epoch timestamp. **Default:** 'new Date()' - `subject`: `` Expected subject value. An exact match must be found in the payload. @@ -1014,7 +1003,22 @@ JWT.decode(token, { complete: true }) #### `JWT.AccessToken.verify(token, keyOrStore, options])` -A shorthand for [`JWT.verify`](#jwtverifytoken-keyorstore-options) with the `profile` option set to `at+JWT`. +A shorthand for [`JWT.verify`](#jwtverifytoken-keyorstore-options) with additional constraints and options +to verify an Access Token according to +[JWT Profile for OAuth 2.0 Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-06). +This is an IETF **draft** implementation. Breaking draft implementations are included as minor versions of +the jose library, therefore, the ~ semver operator should be used and close attention be payed to library +changelog as well as the drafts themselves. + +The function arguments are the same as for [`JWT.verify`](#jwtverifytoken-keyorstore-options), only difference +is that `issuer` and `audience` options are required and the additional option: + +- see [`JWT.verify`](#jwtverifytoken-keyorstore-options) +- `issuer`: `` REQUIRED +- `audience`: `` REQUIRED +- `maxAuthAge`: `` When provided the payload is checked to have the "auth_time" claim and + its value is validated, provided as timespan string e.g. `30m`, `24 hours`. See + [OpenID Connect Core 1.0][connect-core] for details. Do not confuse with maxTokenAge option.
Example (Click to expand) @@ -1038,6 +1042,18 @@ jose.JWT.AccessToken.verify( A shorthand for [`JWT.verify`](#jwtverifytoken-keyorstore-options) with the `profile` option set to `id_token`. +The function arguments are the same as for [`JWT.verify`](#jwtverifytoken-keyorstore-options), only difference +is that `issuer` and `audience` options are required and the additional options: + +- see [`JWT.verify`](#jwtverifytoken-keyorstore-options) +- `issuer`: `` REQUIRED +- `audience`: `` REQUIRED +- `maxAuthAge`: `` When provided the payload is checked to have the "auth_time" claim and + its value is validated, provided as timespan string e.g. `30m`, `24 hours`. See + [OpenID Connect Core 1.0][connect-core] for details. Do not confuse with maxTokenAge option. +- `nonce`: `` Expected nonce value. An exact match must be found in the payload. See + [OpenID Connect Core 1.0][connect-core] for details. +
Example (Click to expand) @@ -1060,6 +1076,16 @@ jose.JWT.IdToken.verify( #### `JWT.LogoutToken.verify(token, keyOrStore, options])` A shorthand for [`JWT.verify`](#jwtverifytoken-keyorstore-options) with the `profile` option set to `logout_token`. +This is an OIDF **draft** implementation. Breaking draft implementations are included as minor versions of +the jose library, therefore, the ~ semver operator should be used and close attention be payed to library +changelog as well as the drafts themselves. + +The function arguments are the same as for [`JWT.verify`](#jwtverifytoken-keyorstore-options), only difference +is that `issuer` and `audience` options are required. + +- see [`JWT.verify`](#jwtverifytoken-keyorstore-options) +- `issuer`: `` REQUIRED +- `audience`: `` REQUIRED
Example (Click to expand) diff --git a/lib/jwt/profiles.js b/lib/jwt/profiles.js index 8ef18c4322..9b3b743b66 100644 --- a/lib/jwt/profiles.js +++ b/lib/jwt/profiles.js @@ -1,7 +1,168 @@ +const { JWTClaimInvalid } = require('../errors') +const secs = require('../help/secs') +const epoch = require('../help/epoch') +const isObject = require('../help/is_object') + const verify = require('./verify') +const { + isString, + isRequired, + isTimestamp, + isStringOrArrayOfStrings +} = require('./shared_validations') + +const isPayloadRequired = isRequired.bind(undefined, JWTClaimInvalid) +const isPayloadString = isString.bind(undefined, JWTClaimInvalid) +const isOptionString = isString.bind(undefined, TypeError) + +const defineLazyExportWithWarning = (obj, property, name, definition) => { + Object.defineProperty(obj, property, { + enumerable: true, + configurable: true, + value (...args) { + process.emitWarning( + `The ${name} API implements an IETF draft. Breaking draft implementations are included as minor versions of the jose library, therefore, the ~ semver operator should be used and close attention be payed to library changelog as well as the drafts themselves.`, + 'DraftWarning' + ) + Object.defineProperty(obj, property, { + enumerable: true, + configurable: true, + value: definition + }) + return obj[property](...args) + } + }) +} + +const validateCommonOptions = (options, profile) => { + if (!isObject(options)) { + throw new TypeError('options must be an object') + } + + if (!options.issuer) { + throw new TypeError(`"issuer" option is required to validate ${profile}`) + } + + if (!options.audience) { + throw new TypeError(`"audience" option is required to validate ${profile}`) + } +} module.exports = { - IdToken: { verify: (token, key, options) => verify(token, key, { ...options, profile: 'id_token' }) }, - LogoutToken: { verify: (token, key, options) => verify(token, key, { ...options, profile: 'logout_token' }) }, - AccessToken: { verify: (token, key, options) => verify(token, key, { ...options, profile: 'at+JWT' }) } + IdToken: { + verify: (token, key, options = {}) => { + validateCommonOptions(options, 'an ID Token') + + if ('maxAuthAge' in options) { + isOptionString(options.maxAuthAge, 'options.maxAuthAge') + } + if ('nonce' in options) { + isOptionString(options.nonce, 'options.nonce') + } + + const unix = epoch(options.now || new Date()) + const result = verify(token, key, { ...options }) + const payload = options.complete ? result.payload : result + + if (Array.isArray(payload.aud) && payload.aud.length > 1) { + isPayloadRequired(payload.azp, '"azp" claim', 'azp') + } + isPayloadRequired(payload.iat, '"iat" claim', 'iat') + isPayloadRequired(payload.sub, '"sub" claim', 'sub') + isPayloadRequired(payload.exp, '"exp" claim', 'exp') + isTimestamp(payload.auth_time, 'auth_time', !!options.maxAuthAge) + isPayloadString(payload.nonce, '"nonce" claim', 'nonce', !!options.nonce) + isPayloadString(payload.acr, '"acr" claim', 'acr') + isStringOrArrayOfStrings(payload.amr, 'amr') + + if (options.nonce && payload.nonce !== options.nonce) { + throw new JWTClaimInvalid('unexpected "nonce" claim value', 'nonce', 'check_failed') + } + + const tolerance = options.clockTolerance ? secs(options.clockTolerance) : 0 + + if (options.maxAuthAge) { + const maxAuthAgeSeconds = secs(options.maxAuthAge) + if (payload.auth_time + maxAuthAgeSeconds < unix - tolerance) { + throw new JWTClaimInvalid('"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)', 'auth_time', 'check_failed') + } + } + + if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== options.audience) { + throw new JWTClaimInvalid('unexpected "azp" claim value', 'azp', 'check_failed') + } + + return result + } + }, + LogoutToken: {}, + AccessToken: {} } + +defineLazyExportWithWarning(module.exports.LogoutToken, 'verify', 'jose.JWT.LogoutToken.verify', (token, key, options = {}) => { + validateCommonOptions(options, 'a Logout Token') + + const result = verify(token, key, { ...options }) + const payload = options.complete ? result.payload : result + + isPayloadRequired(payload.iat, '"iat" claim', 'iat') + isPayloadRequired(payload.jti, '"jti" claim', 'jti') + isPayloadString(payload.sid, '"sid" claim', 'sid') + + 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', 'nonce', 'prohibited') + } + + if (!('events' in payload)) { + throw new JWTClaimInvalid('"events" claim is missing', 'events', 'missing') + } + + if (!isObject(payload.events)) { + throw new JWTClaimInvalid('"events" claim must be an object', 'events', 'invalid') + } + + 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', 'events', 'invalid') + } + + 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', 'events', 'invalid') + } + + return result +}) + +defineLazyExportWithWarning(module.exports.AccessToken, 'verify', 'jose.JWT.AccessToken.verify', (token, key, options = {}) => { + validateCommonOptions(options, 'a JWT Access Token') + + isOptionString(options.maxAuthAge, 'options.maxAuthAge') + + const unix = epoch(options.now || new Date()) + const typ = 'at+JWT' + const result = verify(token, key, { ...options, typ }) + const payload = options.complete ? result.payload : result + + isPayloadRequired(payload.iat, '"iat" claim', 'iat') + isPayloadRequired(payload.exp, '"exp" claim', 'exp') + isPayloadRequired(payload.sub, '"sub" claim', 'sub') + isPayloadRequired(payload.jti, '"jti" claim', 'jti') + isPayloadString(payload.client_id, '"client_id" claim', 'client_id', true) + isTimestamp(payload.auth_time, 'auth_time', !!options.maxAuthAge) + isPayloadString(payload.acr, '"acr" claim', 'acr') + isStringOrArrayOfStrings(payload.amr, 'amr') + + const tolerance = options.clockTolerance ? secs(options.clockTolerance) : 0 + + if (options.maxAuthAge) { + const maxAuthAgeSeconds = secs(options.maxAuthAge) + if (payload.auth_time + maxAuthAgeSeconds < unix - tolerance) { + throw new JWTClaimInvalid('"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)', 'auth_time', 'check_failed') + } + } + + return result +}) diff --git a/lib/jwt/shared_validations.js b/lib/jwt/shared_validations.js index 276d187024..fffc4eee5d 100644 --- a/lib/jwt/shared_validations.js +++ b/lib/jwt/shared_validations.js @@ -1,12 +1,45 @@ -const isNotString = val => typeof val !== 'string' || val.length === 0 +const { JWTClaimInvalid } = require('../errors') -module.exports.isNotString = isNotString -module.exports.isString = function isString (Err, value, label, claim, required = false) { - if (required && value === undefined) { +const isNotString = val => typeof val !== 'string' || val.length === 0 +const isNotArrayOfStrings = val => !Array.isArray(val) || val.length === 0 || val.some(isNotString) +const isRequired = (Err, value, label, claim) => { + if (value === undefined) { throw new Err(`${label} is missing`, claim, 'missing') } +} +const isString = (Err, value, label, claim, required = false) => { + if (required) { + isRequired(Err, value, label, claim) + } if (value !== undefined && isNotString(value)) { throw new Err(`${label} must be a string`, claim, 'invalid') } } +const isTimestamp = (value, label, required = false) => { + if (required && value === undefined) { + throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing') + } + + if (value !== undefined && (typeof value !== 'number')) { + throw new JWTClaimInvalid(`"${label}" claim must be a JSON numeric value`, label, 'invalid') + } +} +const isStringOrArrayOfStrings = (value, label, required = false) => { + if (required && value === undefined) { + throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing') + } + + if (value !== undefined && (isNotString(value) && isNotArrayOfStrings(value))) { + throw new JWTClaimInvalid(`"${label}" claim must be a string or array of strings`, label, 'invalid') + } +} + +module.exports = { + isNotArrayOfStrings, + isRequired, + isNotString, + isString, + isTimestamp, + isStringOrArrayOfStrings +} diff --git a/lib/jwt/verify.js b/lib/jwt/verify.js index 5941efe155..db341603e0 100644 --- a/lib/jwt/verify.js +++ b/lib/jwt/verify.js @@ -5,46 +5,25 @@ const getKey = require('../help/get_key') const { bare: verify } = require('../jws/verify') const { JWTClaimInvalid, JWTExpired } = require('../errors') -const { isString, isNotString } = require('./shared_validations') +const { + isString, + isNotString, + isNotArrayOfStrings, + isTimestamp, + isStringOrArrayOfStrings +} = require('./shared_validations') 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`, label, 'missing') - } - - if (value !== undefined && (typeof value !== 'number')) { - throw new JWTClaimInvalid(`"${label}" claim must be a JSON numeric value`, label, 'invalid') - } -} - -const isStringOrArrayOfStrings = (value, label, required = false) => { - if (required && value === undefined) { - throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing') - } - - if (value !== undefined && (isNotString(value) && isNotArrayOfStrings(value))) { - throw new JWTClaimInvalid(`"${label}" claim must be a string or array of strings`, label, 'invalid') - } -} - -const isNotArrayOfStrings = val => !Array.isArray(val) || val.length === 0 || val.some(isNotString) const normalizeTyp = (value) => value.toLowerCase().replace(/^application\//, '') const validateOptions = ({ algorithms, audience, clockTolerance, complete = false, crit, ignoreExp = false, - ignoreIat = false, ignoreNbf = false, issuer, jti, maxAuthAge, maxTokenAge, nonce, now = new Date(), - profile, subject, typ + ignoreIat = false, ignoreNbf = false, issuer, jti, maxTokenAge, now = new Date(), + subject, typ }) => { - isOptionString(profile, 'options.profile') - if (typeof complete !== 'boolean') { throw new TypeError('options.complete must be a boolean') } @@ -63,7 +42,6 @@ const validateOptions = ({ isOptionString(maxTokenAge, 'options.maxTokenAge') isOptionString(subject, 'options.subject') - isOptionString(maxAuthAge, 'options.maxAuthAge') isOptionString(jti, 'options.jti') isOptionString(clockTolerance, 'options.clockTolerance') isOptionString(typ, 'options.typ') @@ -80,8 +58,6 @@ const validateOptions = ({ throw new TypeError('options.algorithms must be an array of strings') } - isOptionString(nonce, 'options.nonce') - if (!(now instanceof Date) || !now.getTime()) { throw new TypeError('options.now must be a valid Date object') } @@ -94,45 +70,6 @@ const validateOptions = ({ throw new TypeError('options.crit must be an array of strings') } - switch (profile) { - case IDTOKEN: - if (!issuer) { - throw new TypeError('"issuer" option is required to validate an ID Token') - } - - if (!audience) { - throw new TypeError('"audience" option is required to validate an ID Token') - } - - break - case ATJWT: - if (!issuer) { - throw new TypeError('"issuer" option is required to validate a JWT Access Token') - } - - if (!audience) { - throw new TypeError('"audience" option is required to validate a JWT Access Token') - } - - typ = ATJWT - - break - case LOGOUTTOKEN: - if (!issuer) { - throw new TypeError('"issuer" option is required to validate a Logout Token') - } - - if (!audience) { - throw new TypeError('"audience" option is required to validate a Logout Token') - } - - break - case undefined: - break - default: - throw new TypeError(`unsupported options.profile value "${profile}"`) - } - return { algorithms, audience, @@ -144,67 +81,27 @@ const validateOptions = ({ ignoreNbf, issuer, jti, - maxAuthAge, maxTokenAge, - nonce, now, - profile, subject, typ } } -const validateTypes = ({ header, payload }, profile, options) => { +const validateTypes = ({ header, payload }, options) => { isPayloadString(header.alg, '"alg" header parameter', 'alg', true) - 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.iat, 'iat', !!options.maxTokenAge) + isTimestamp(payload.exp, 'exp') isTimestamp(payload.nbf, 'nbf') - 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.jti, '"jti" claim', 'jti', !!options.jti) isStringOrArrayOfStrings(payload.iss, 'iss', !!options.issuer) - isPayloadString(payload.sub, '"sub" claim', 'sub', profile === IDTOKEN || profile === ATJWT || !!options.subject) + isPayloadString(payload.sub, '"sub" claim', 'sub', !!options.subject) isStringOrArrayOfStrings(payload.aud, 'aud', !!options.audience) - isPayloadString(payload.azp, '"azp" claim', 'azp', profile === IDTOKEN && Array.isArray(payload.aud) && payload.aud.length > 1) - isStringOrArrayOfStrings(payload.amr, 'amr') isPayloadString(header.typ, '"typ" header parameter', 'typ', !!options.typ) - - if (profile === ATJWT) { - isPayloadString(payload.client_id, '"client_id" claim', 'client_id', true) - } - - if (profile === LOGOUTTOKEN) { - isPayloadString(payload.sid, '"sid" claim', 'sid') - - 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', 'nonce', 'prohibited') - } - - if (!('events' in payload)) { - throw new JWTClaimInvalid('"events" claim is missing', 'events', 'missing') - } - - if (!isObject(payload.events)) { - throw new JWTClaimInvalid('"events" claim must be an object', 'events', 'invalid') - } - - 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', 'events', 'invalid') - } - - 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', 'events', 'invalid') - } - } } -const checkAudiencePresence = (audPayload, audOption, profile) => { +const checkAudiencePresence = (audPayload, audOption) => { if (typeof audPayload === 'string') { return audOption.includes(audPayload) } @@ -222,7 +119,7 @@ module.exports = (token, key, options = {}) => { const { algorithms, audience, clockTolerance, complete, crit, ignoreExp, ignoreIat, ignoreNbf, issuer, - jti, maxAuthAge, maxTokenAge, nonce, now, profile, subject, typ + jti, maxTokenAge, now, subject, typ } = options = validateOptions(options) const decoded = decode(token, { complete: true }) @@ -236,16 +133,12 @@ module.exports = (token, key, options = {}) => { } const unix = epoch(now) - validateTypes(decoded, profile, options) + validateTypes(decoded, options) if (issuer && (typeof decoded.payload.iss !== 'string' || !(typeof issuer === 'string' ? [issuer] : issuer).includes(decoded.payload.iss))) { throw new JWTClaimInvalid('unexpected "iss" claim value', 'iss', 'check_failed') } - if (nonce && decoded.payload.nonce !== nonce) { - throw new JWTClaimInvalid('unexpected "nonce" claim value', 'nonce', 'check_failed') - } - if (subject && decoded.payload.sub !== subject) { throw new JWTClaimInvalid('unexpected "sub" claim value', 'sub', 'check_failed') } @@ -254,7 +147,7 @@ module.exports = (token, key, options = {}) => { throw new JWTClaimInvalid('unexpected "jti" claim value', 'jti', 'check_failed') } - if (audience && !checkAudiencePresence(decoded.payload.aud, typeof audience === 'string' ? [audience] : audience, profile)) { + if (audience && !checkAudiencePresence(decoded.payload.aud, typeof audience === 'string' ? [audience] : audience)) { throw new JWTClaimInvalid('unexpected "aud" claim value', 'aud', 'check_failed') } @@ -264,13 +157,6 @@ module.exports = (token, key, options = {}) => { const tolerance = clockTolerance ? secs(clockTolerance) : 0 - if (maxAuthAge) { - const maxAuthAgeSeconds = secs(maxAuthAge) - if (decoded.payload.auth_time + maxAuthAgeSeconds < unix - tolerance) { - throw new JWTClaimInvalid('"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)', 'auth_time', 'check_failed') - } - } - if (!ignoreIat && !('exp' in decoded.payload) && 'iat' in decoded.payload && decoded.payload.iat > unix + tolerance) { throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)', 'iat', 'check_failed') } @@ -296,9 +182,5 @@ module.exports = (token, key, options = {}) => { } } - if (profile === IDTOKEN && Array.isArray(decoded.payload.aud) && decoded.payload.aud.length > 1 && decoded.payload.azp !== audience) { - throw new JWTClaimInvalid('unexpected "azp" claim value', 'azp', 'check_failed') - } - return complete ? decoded : decoded.payload } diff --git a/test/jwt/verify.test.js b/test/jwt/verify.test.js index 9bd13aa602..5e030fa813 100644 --- a/test/jwt/verify.test.js +++ b/test/jwt/verify.test.js @@ -5,10 +5,10 @@ const { JWS, JWT, JWK, JWKS, errors } = require('../..') const key = JWK.generateSync('oct') const token = JWT.sign({}, key, { iat: false }) -const string = (t, option) => { +const string = (t, option, method = JWT.verify, opts) => { ;['', false, [], {}, Buffer, Buffer.from('foo'), 0, Infinity].forEach((val) => { t.throws(() => { - JWT.verify(token, key, { [option]: val }) + method(token, key, { ...opts, [option]: val }) }, { instanceOf: TypeError, message: `options.${option} must be a string` }) }) } @@ -36,10 +36,7 @@ test('options must be an object', t => { test('options.clockTolerance must be a string', string, 'clockTolerance') 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') test('options.subject must be a string', string, 'subject') const boolean = (t, option) => { @@ -112,7 +109,7 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { }, { instanceOf: TypeError, message: 'options.ignoreIat and options.maxTokenAge cannot used together' }) }) -;['iat', 'exp', 'auth_time', 'nbf'].forEach((claim) => { +;['iat', 'exp', 'nbf'].forEach((claim) => { test(`"${claim} must be a timestamp when provided"`, t => { ;['', 'foo', true, null, [], {}].forEach((val) => { const err = t.throws(() => { @@ -126,7 +123,7 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { }) }) -;['jti', 'acr', 'nonce', 'sub', 'azp'].forEach((claim) => { +;['jti', 'sub'].forEach((claim) => { test(`"${claim} must be a string when provided"`, t => { ;['', 0, 1, true, null, [], {}].forEach((val) => { const err = t.throws(() => { @@ -140,7 +137,7 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { }) }) -;['aud', 'amr', 'iss'].forEach((claim) => { +;['aud', 'iss'].forEach((claim) => { test(`"${claim} must be a string when provided"`, t => { ;['', 0, 1, true, null, [], {}].forEach((val) => { let err @@ -164,7 +161,6 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { Object.entries({ issuer: 'iss', jti: 'jti', - nonce: 'nonce', subject: 'sub' }).forEach(([option, claim]) => { test(`option.${option} validation fails`, t => { @@ -266,33 +262,9 @@ test('option.audience validation success', t => { t.pass() }) -test('option.maxAuthAge requires iat to be in the payload', t => { - const err = t.throws(() => { - const invalid = JWS.sign({}, key) - JWT.verify(invalid, key, { maxAuthAge: '30s' }) - }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim is missing' }) - t.is(err.claim, 'auth_time') - t.is(err.reason, 'missing') -}) - const epoch = 1265328501 const now = new Date(epoch * 1000) -test('option.maxAuthAge checks auth_time', t => { - const err = t.throws(() => { - const invalid = JWS.sign({ auth_time: epoch - 31 }, key) - JWT.verify(invalid, key, { maxAuthAge: '30s', now }) - }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)' }) - t.is(err.claim, 'auth_time') - t.is(err.reason, 'check_failed') -}) - -test('option.maxAuthAge checks auth_time (with tolerance)', t => { - const token = JWT.sign({ auth_time: epoch - 31 }, key, { now }) - JWT.verify(token, key, { maxAuthAge: '30s', now, clockTolerance: '1s' }) - t.pass() -}) - test('option.maxTokenAge requires iat to be in the payload', t => { const err = t.throws(() => { const invalid = JWS.sign({}, key) @@ -449,133 +421,175 @@ 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"' }) -}) - { const token = JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'client_id' }) - test('profile=id_token', t => { - JWT.verify(token, key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' }) + test('IdToken.verify options must be an object', t => { + t.throws(() => { + JWT.IdToken.verify(token, key, []) + }, { instanceOf: TypeError, message: 'options must be an object' }) + }) + + test('IdToken.verify options.maxAuthAge must be a string', string, 'maxAuthAge', JWT.IdToken.verify, { issuer: 'foo', audience: 'bar' }) + test('IdToken.verify options.nonce must be a string', string, 'nonce', JWT.IdToken.verify, { issuer: 'foo', audience: 'bar' }) + + test('IdToken.verify', t => { JWT.IdToken.verify(token, key, { issuer: 'issuer', audience: 'client_id' }) t.pass() }) - 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('IdToken.verify requires issuer option too', t => { t.throws(() => { JWT.IdToken.verify(token, key) }, { 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('IdToken.verify requires audience option too', t => { t.throws(() => { JWT.IdToken.verify(token, key, { issuer: 'issuer' }) }, { instanceOf: TypeError, message: '"audience" option is required to validate an ID Token' }) }) - test('profile=id_token mandates exp to be present', t => { + test('IdToken.verify mandates exp to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.IdToken.verify( JWT.sign({ }, key, { subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"exp" claim is missing' }) t.is(err.claim, 'exp') t.is(err.reason, 'missing') }) - test('profile=id_token mandates iat to be present', t => { + test('IdToken.verify mandates iat to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.IdToken.verify( JWT.sign({ }, key, { expiresIn: '10m', iat: false, subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim is missing' }) t.is(err.claim, 'iat') t.is(err.reason, 'missing') }) - test('profile=id_token mandates sub to be present', t => { + test('IdToken.verify mandates sub to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.IdToken.verify( JWT.sign({ }, key, { expiresIn: '10m', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"sub" claim is missing' }) t.is(err.claim, 'sub') t.is(err.reason, 'missing') }) - test('profile=id_token mandates iss to be present', t => { + test('IdToken.verify mandates iss to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.IdToken.verify( JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', audience: 'client_id' }), key, - { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iss" claim is missing' }) t.is(err.claim, 'iss') t.is(err.reason, 'missing') }) - test('profile=id_token mandates aud to be present', t => { + test('IdToken.verify mandates aud to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.IdToken.verify( JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer' }), key, - { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"aud" claim is missing' }) t.is(err.claim, 'aud') t.is(err.reason, 'missing') }) - test('profile=id_token mandates azp to be present when multiple audiences are used', t => { + test('IdToken.verify mandates azp to be present when multiple audiences are used', t => { const err = t.throws(() => { - JWT.verify( + JWT.IdToken.verify( JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['client_id', 'another audience'] }), key, - { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"azp" claim is missing' }) t.is(err.claim, 'azp') t.is(err.reason, 'missing') }) - test('profile=id_token mandates azp to match the audience when required', t => { + test('IdToken.verify mandates azp to match the audience when required', t => { const err = t.throws(() => { - JWT.verify( + JWT.IdToken.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' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: 'unexpected "azp" claim value' }) t.is(err.claim, 'azp') t.is(err.reason, 'check_failed') }) - test('profile=id_token validates full id tokens', t => { + test('IdToken.verify validates full id tokens', t => { t.notThrows(() => { - JWT.verify( + JWT.IdToken.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' } + { issuer: 'issuer', audience: 'client_id' } ) }) }) + + test('IdToken.verify option.maxAuthAge requires auth_time to be in the payload', t => { + const err = t.throws(() => { + const invalid = JWT.sign({}, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'client' }) + JWT.IdToken.verify(invalid, key, { maxAuthAge: '30s', issuer: 'issuer', audience: 'client' }) + }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim is missing' }) + t.is(err.claim, 'auth_time') + t.is(err.reason, 'missing') + }) + + test('IdToken.verify option.maxAuthAge checks auth_time', t => { + const err = t.throws(() => { + const invalid = JWT.sign({ auth_time: epoch - 31 }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'client' }) + JWT.IdToken.verify(invalid, key, { maxAuthAge: '30s', now, issuer: 'issuer', audience: 'client' }) + }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)' }) + t.is(err.claim, 'auth_time') + t.is(err.reason, 'check_failed') + }) + + test('IdToken.verify option.maxAuthAge checks auth_time (with tolerance)', t => { + const token = JWT.sign({ auth_time: epoch - 31 }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'client' }) + JWT.IdToken.verify(token, key, { maxAuthAge: '30s', now, clockTolerance: '1s', issuer: 'issuer', audience: 'client' }) + t.pass() + }) + + test('IdToken.verify auth_time must be a timestamp when provided', t => { + ;['', 'foo', true, null, [], {}].forEach((val) => { + const err = t.throws(() => { + const invalid = JWT.sign({ auth_time: val }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'client' }) + JWT.IdToken.verify(invalid, key, { issuer: 'issuer', audience: 'client' }) + }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim must be a JSON numeric value' }) + + t.is(err.claim, 'auth_time') + t.is(err.reason, 'invalid') + }) + }) + + test('IdToken.verify option.nonce checks nonce value', t => { + const token = JWT.sign({ nonce: 'foobar' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'client' }) + JWT.IdToken.verify(token, key, { now, issuer: 'issuer', audience: 'client', nonce: 'foobar' }) + const err = t.throws(() => { + JWT.IdToken.verify(token, key, { now, issuer: 'issuer', audience: 'client', nonce: 'baz' }) + }, { instanceOf: errors.JWTClaimInvalid, message: 'unexpected "nonce" claim value' }) + + t.is(err.claim, 'nonce') + t.is(err.reason, 'check_failed') + }) } { @@ -585,164 +599,163 @@ test('must be a supported value', t => { } }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }) - test('profile=logout_token', t => { - JWT.verify(token, key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' }) + test('LogoutToken.verify options must be an object', t => { + t.throws(() => { + JWT.LogoutToken.verify(token, key, []) + }, { instanceOf: TypeError, message: 'options must be an object' }) + }) + + test('LogoutToken.verify', t => { JWT.LogoutToken.verify(token, key, { issuer: 'issuer', audience: 'client_id' }) t.pass() }) - 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' }) + test('LogoutToken.verify requires issuer option too', t => { t.throws(() => { JWT.LogoutToken.verify(token, key) }, { instanceOf: TypeError, message: '"issuer" option is required to validate a Logout Token' }) }) - 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' }) + test('LogoutToken.verify requires audience option too', t => { t.throws(() => { JWT.LogoutToken.verify(token, key, { issuer: 'issuer' }) }, { instanceOf: TypeError, message: '"audience" option is required to validate a Logout Token' }) }) - test('profile=logout_token mandates jti to be present', t => { + test('LogoutToken.verify mandates jti to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ }, key, { subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"jti" claim is missing' }) t.is(err.claim, 'jti') t.is(err.reason, 'missing') }) - test('profile=logout_token mandates events to be present', t => { + test('LogoutToken.verify mandates events to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"events" claim is missing' }) t.is(err.claim, 'events') t.is(err.reason, 'missing') }) - test('profile=logout_token mandates events to be an object', t => { + test('LogoutToken.verify mandates events to be an object', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ events: [] }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"events" claim must be an object' }) t.is(err.claim, 'events') t.is(err.reason, 'invalid') }) - test('profile=logout_token mandates events to have the backchannel logout member', t => { + test('LogoutToken.verify mandates events to have the backchannel logout member', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ events: {} }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim' }) t.is(err.claim, 'events') t.is(err.reason, 'invalid') }) - test('profile=logout_token mandates events to have the backchannel logout member thats an object', t => { + test('LogoutToken.verify mandates events to have the backchannel logout member thats an object', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.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' } + { 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' }) t.is(err.claim, 'events') t.is(err.reason, 'invalid') }) - test('profile=logout_token mandates iat to be present', t => { + test('LogoutToken.verify mandates iat to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ }, key, { jti: 'foo', iat: false, subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim is missing' }) t.is(err.claim, 'iat') t.is(err.reason, 'missing') }) - test('profile=logout_token mandates sub or sid to be present', t => { + test('LogoutToken.verify mandates sub or sid to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ }, key, { jti: 'foo', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: 'either "sid" or "sub" (or both) claims must be present' }) t.is(err.claim, 'unspecified') t.is(err.reason, 'unspecified') }) - test('profile=logout_token mandates sid to be a string when present', t => { + test('LogoutToken.verify mandates sid to be a string when present', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ sid: true }, key, { jti: 'foo', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"sid" claim must be a string' }) t.is(err.claim, 'sid') t.is(err.reason, 'invalid') }) - test('profile=logout_token prohibits nonce', t => { + test('LogoutToken.verify prohibits nonce', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ nonce: 'foo' }, key, { subject: 'subject', jti: 'foo', issuer: 'issuer', audience: 'client_id' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"nonce" claim is prohibited' }) t.is(err.claim, 'nonce') t.is(err.reason, 'prohibited') }) - test('profile=logout_token mandates iss to be present', t => { + test('LogoutToken.verify mandates iss to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ }, key, { jti: 'foo', subject: 'subject', audience: 'client_id' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iss" claim is missing' }) t.is(err.claim, 'iss') t.is(err.reason, 'missing') }) - test('profile=logout_token mandates aud to be present', t => { + test('LogoutToken.verify mandates aud to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.LogoutToken.verify( JWT.sign({ }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer' }), key, - { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } + { issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"aud" claim is missing' }) t.is(err.claim, 'aud') @@ -753,135 +766,172 @@ test('must be a supported value', t => { { 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' }) + test('AccessToken.verify options must be an object', t => { + t.throws(() => { + JWT.AccessToken.verify(token, key, []) + }, { instanceOf: TypeError, message: 'options must be an object' }) + }) + + test('AccessToken.verify options.maxAuthAge must be a string', string, 'maxAuthAge', JWT.AccessToken.verify, { issuer: 'foo', audience: 'bar' }) + + test('AccessToken.verify', t => { JWT.AccessToken.verify(token, key, { issuer: 'issuer', audience: 'RS' }) t.pass() }) - 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' }) + test('AccessToken.verify requires issuer option too', t => { t.throws(() => { JWT.AccessToken.verify(token, key) }, { instanceOf: TypeError, message: '"issuer" option is required to validate a JWT Access Token' }) }) - 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' }) + test('AccessToken.verify requires audience option too', t => { t.throws(() => { JWT.AccessToken.verify(token, key, { issuer: 'issuer' }) }, { instanceOf: TypeError, message: '"audience" option is required to validate a JWT Access Token' }) }) - test('profile=at+JWT mandates exp to be present', t => { + test('AccessToken.verify mandates exp to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.AccessToken.verify( 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' } + { issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"exp" claim is missing' }) t.is(err.claim, 'exp') t.is(err.reason, 'missing') }) - test('profile=at+JWT mandates client_id to be present', t => { + test('AccessToken.verify mandates client_id to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.AccessToken.verify( 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' } + { issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"client_id" claim is missing' }) t.is(err.claim, 'client_id') t.is(err.reason, 'missing') }) - test('profile=at+JWT mandates jti to be present', t => { + test('AccessToken.verify mandates jti to be present', t => { const err = t.throws(() => { - JWT.verify( - JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS' }), + JWT.AccessToken.verify( + JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), key, - { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + { 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 => { + test('AccessToken.verify 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 }), + JWT.AccessToken.verify( + JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', iat: false, header: { typ: 'at+JWT' } }), key, - { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } + { 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 => { + test('AccessToken.verify mandates sub to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.AccessToken.verify( 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' } + { issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"sub" claim is missing' }) t.is(err.claim, 'sub') t.is(err.reason, 'missing') }) - test('profile=at+JWT mandates iss to be present', t => { + test('AccessToken.verify mandates iss to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.AccessToken.verify( 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' } + { issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iss" claim is missing' }) t.is(err.claim, 'iss') t.is(err.reason, 'missing') }) - test('profile=at+JWT mandates aud to be present', t => { + test('AccessToken.verify mandates aud to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.AccessToken.verify( 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' } + { issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"aud" claim is missing' }) t.is(err.claim, 'aud') t.is(err.reason, 'missing') }) - test('profile=at+JWT mandates header typ to be present', t => { + test('AccessToken.verify mandates header typ to be present', t => { const err = t.throws(() => { - JWT.verify( + JWT.AccessToken.verify( 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' } + { issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"typ" header parameter is missing' }) t.is(err.claim, 'typ') t.is(err.reason, 'missing') }) - test('profile=at+JWT mandates header typ to be present and of the right value', t => { + test('AccessToken.verify mandates header typ to be present and of the right value', t => { const err = t.throws(() => { - JWT.verify( + JWT.AccessToken.verify( 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' } + { issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: 'unexpected "typ" JWT header value' }) t.is(err.claim, 'typ') t.is(err.reason, 'check_failed') }) + + test('AccessToken.verify option.maxAuthAge requires auth_time to be in the payload', t => { + const err = t.throws(() => { + const invalid = JWT.sign({ client_id: 'client' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', jti: 'random', header: { typ: 'at+JWT' } }) + JWT.AccessToken.verify(invalid, key, { maxAuthAge: '30s', issuer: 'issuer', audience: 'RS' }) + }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim is missing' }) + t.is(err.claim, 'auth_time') + t.is(err.reason, 'missing') + }) + + test('AccessToken.verify option.maxAuthAge checks auth_time', t => { + const err = t.throws(() => { + const invalid = JWT.sign({ auth_time: epoch - 31, client_id: 'client' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', jti: 'random', header: { typ: 'at+JWT' } }) + JWT.AccessToken.verify(invalid, key, { maxAuthAge: '30s', now, issuer: 'issuer', audience: 'RS' }) + }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)' }) + t.is(err.claim, 'auth_time') + t.is(err.reason, 'check_failed') + }) + + test('AccessToken.verify option.maxAuthAge checks auth_time (with tolerance)', t => { + const token = JWT.sign({ auth_time: epoch - 31, client_id: 'client' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', jti: 'random', header: { typ: 'at+JWT' } }) + JWT.AccessToken.verify(token, key, { maxAuthAge: '30s', now, clockTolerance: '1s', issuer: 'issuer', audience: 'RS' }) + t.pass() + }) + + test('AccessToken.verify auth_time must be a timestamp when provided', t => { + ;['', 'foo', true, null, [], {}].forEach((val) => { + const err = t.throws(() => { + const invalid = JWT.sign({ auth_time: val, client_id: 'client' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', jti: 'random', header: { typ: 'at+JWT' } }) + JWT.AccessToken.verify(invalid, key, { issuer: 'issuer', audience: 'RS' }) + }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim must be a JSON numeric value' }) + + t.is(err.claim, 'auth_time') + t.is(err.reason, 'invalid') + }) + }) } diff --git a/types/index.d.ts b/types/index.d.ts index 1eaf284183..f7f8929587 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -22,7 +22,6 @@ export type Curves = OKPCurve | ECCurve; export type keyType = 'RSA' | 'EC' | 'OKP' | 'oct'; export type asymmetricKeyObjectTypes = 'private' | 'public'; export type keyObjectTypes = asymmetricKeyObjectTypes | 'secret'; -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; @@ -444,16 +443,13 @@ export namespace JWT { maxTokenAge?: string; subject?: string; issuer?: string | string[]; - maxAuthAge?: string; jti?: string; clockTolerance?: string; audience?: string | string[]; algorithms?: string[]; - nonce?: string; typ?: string; now?: Date; crit?: string[]; - profile?: JWTProfiles; } function verify(jwt: string, key: NoneKey, options: VerifyOptions & { complete: true }): completeResult; @@ -476,28 +472,38 @@ export namespace JWT { function sign(payload: object, key: ProduceKeyInputWithNone, options?: SignOptions): string; - interface VerifyProfileOptions { + interface ProfiledVerifyOptions { issuer: string | string[]; audience: string | string[]; - profile?: profile; } + interface IdTokenVerifyOptions extends ProfiledVerifyOptions { + nonce?: string; + maxAuthAge?: string; + } + + interface AccessTokenVerifyOptions extends ProfiledVerifyOptions { + maxAuthAge?: string; + } + + interface LogoutTokenVerifyOptions extends ProfiledVerifyOptions {} + namespace IdToken { - function verify(jwt: string, key: ConsumeKeyInput | EmbeddedVerifyKeys, options: VerifyOptions & { complete: true } & VerifyProfileOptions<'id_token'>): completeResult; - function verify(jwt: string, key: NoneKey, options: VerifyOptions & { complete: true } & VerifyProfileOptions<'id_token'>): completeResult; - function verify(jwt: string, key: ConsumeKeyInputWithNone | EmbeddedVerifyKeys, options: VerifyOptions & VerifyProfileOptions<'id_token'>): object; + function verify(jwt: string, key: ConsumeKeyInput | EmbeddedVerifyKeys, options: VerifyOptions & { complete: true } & IdTokenVerifyOptions): completeResult; + function verify(jwt: string, key: NoneKey, options: VerifyOptions & { complete: true } & IdTokenVerifyOptions): completeResult; + function verify(jwt: string, key: ConsumeKeyInputWithNone | EmbeddedVerifyKeys, options: VerifyOptions & IdTokenVerifyOptions): object; } namespace LogoutToken { - function verify(jwt: string, key: ConsumeKeyInput | EmbeddedVerifyKeys, options: VerifyOptions & { complete: true } & VerifyProfileOptions<'logout_token'>): completeResult; - function verify(jwt: string, key: NoneKey, options: VerifyOptions & { complete: true } & VerifyProfileOptions<'logout_token'>): completeResult; - function verify(jwt: string, key: ConsumeKeyInputWithNone | EmbeddedVerifyKeys, options: VerifyOptions & VerifyProfileOptions<'logout_token'>): object; + function verify(jwt: string, key: ConsumeKeyInput | EmbeddedVerifyKeys, options: VerifyOptions & { complete: true } & LogoutTokenVerifyOptions): completeResult; + function verify(jwt: string, key: NoneKey, options: VerifyOptions & { complete: true } & LogoutTokenVerifyOptions): completeResult; + function verify(jwt: string, key: ConsumeKeyInputWithNone | EmbeddedVerifyKeys, options: VerifyOptions & LogoutTokenVerifyOptions): object; } namespace AccessToken { - function verify(jwt: string, key: ConsumeKeyInput | EmbeddedVerifyKeys, options: VerifyOptions & { complete: true } & VerifyProfileOptions<'at+JWT'>): completeResult; - function verify(jwt: string, key: NoneKey, options: VerifyOptions & { complete: true } & VerifyProfileOptions<'at+JWT'>): completeResult; - function verify(jwt: string, key: ConsumeKeyInputWithNone | EmbeddedVerifyKeys, options: VerifyOptions & VerifyProfileOptions<'at+JWT'>): object; + function verify(jwt: string, key: ConsumeKeyInput | EmbeddedVerifyKeys, options: VerifyOptions & { complete: true } & AccessTokenVerifyOptions): completeResult; + function verify(jwt: string, key: NoneKey, options: VerifyOptions & { complete: true } & AccessTokenVerifyOptions): completeResult; + function verify(jwt: string, key: ConsumeKeyInputWithNone | EmbeddedVerifyKeys, options: VerifyOptions & AccessTokenVerifyOptions): object; } }