-
-
Notifications
You must be signed in to change notification settings - Fork 321
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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`
- Loading branch information
Showing
8 changed files
with
494 additions
and
332 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Oops, something went wrong.