mirror of
https://github.com/danbulant/jose
synced 2026-05-24 20:41:46 +00:00
304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
const isObject = require('../help/is_object')
|
|
const epoch = require('../help/epoch')
|
|
const secs = require('../help/secs')
|
|
const getKey = require('../help/get_key')
|
|
const JWS = require('../jws')
|
|
const { KeyStore } = require('../jwks')
|
|
const { JWTClaimInvalid, JWTExpired } = require('../errors')
|
|
|
|
const { isString, isNotString } = 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' || !Number.isSafeInteger(value))) {
|
|
throw new JWTClaimInvalid(`"${label}" claim must be a unix timestamp`, 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 validateOptions = ({
|
|
algorithms, audience, clockTolerance, complete = false, crit, ignoreExp = false,
|
|
ignoreIat = false, ignoreNbf = false, issuer, jti, maxAuthAge, maxTokenAge, nonce, now = new Date(),
|
|
profile, subject
|
|
}) => {
|
|
isOptionString(profile, 'options.profile')
|
|
|
|
if (typeof complete !== 'boolean') {
|
|
throw new TypeError('options.complete must be a boolean')
|
|
}
|
|
|
|
if (typeof ignoreExp !== 'boolean') {
|
|
throw new TypeError('options.ignoreExp must be a boolean')
|
|
}
|
|
|
|
if (typeof ignoreNbf !== 'boolean') {
|
|
throw new TypeError('options.ignoreNbf must be a boolean')
|
|
}
|
|
|
|
if (typeof ignoreIat !== 'boolean') {
|
|
throw new TypeError('options.ignoreIat must be a boolean')
|
|
}
|
|
|
|
isOptionString(maxTokenAge, 'options.maxTokenAge')
|
|
isOptionString(subject, 'options.subject')
|
|
isOptionString(issuer, 'options.issuer')
|
|
isOptionString(maxAuthAge, 'options.maxAuthAge')
|
|
isOptionString(jti, 'options.jti')
|
|
isOptionString(clockTolerance, 'options.clockTolerance')
|
|
|
|
if (audience !== undefined && (isNotString(audience) && isNotArrayOfStrings(audience))) {
|
|
throw new TypeError('options.audience must be a string or an array of strings')
|
|
}
|
|
|
|
if (algorithms !== undefined && isNotArrayOfStrings(algorithms)) {
|
|
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')
|
|
}
|
|
|
|
if (ignoreIat && maxTokenAge !== undefined) {
|
|
throw new TypeError('options.ignoreIat and options.maxTokenAge cannot used together')
|
|
}
|
|
|
|
if (crit !== undefined && isNotArrayOfStrings(crit)) {
|
|
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')
|
|
}
|
|
|
|
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,
|
|
clockTolerance,
|
|
complete,
|
|
crit,
|
|
ignoreExp,
|
|
ignoreIat,
|
|
ignoreNbf,
|
|
issuer,
|
|
jti,
|
|
maxAuthAge,
|
|
maxTokenAge,
|
|
nonce,
|
|
now,
|
|
profile,
|
|
subject
|
|
}
|
|
}
|
|
|
|
const validateTypes = ({ header, payload }, profile, options) => {
|
|
isPayloadString(header.alg, '"alg" header parameter', 'alg', true)
|
|
|
|
isTimestamp(payload.iat, 'iat', profile === IDTOKEN || profile === LOGOUTTOKEN || !!options.maxTokenAge)
|
|
isTimestamp(payload.exp, 'exp', profile === IDTOKEN || profile === ATJWT)
|
|
isTimestamp(payload.auth_time, 'auth_time', !!options.maxAuthAge)
|
|
isTimestamp(payload.nbf, 'nbf')
|
|
isPayloadString(payload.jti, '"jti" claim', 'jti', profile === LOGOUTTOKEN || !!options.jti)
|
|
isPayloadString(payload.acr, '"acr" claim', 'acr')
|
|
isPayloadString(payload.nonce, '"nonce" claim', 'nonce', !!options.nonce)
|
|
isPayloadString(payload.iss, '"iss" claim', 'iss', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN || !!options.issuer)
|
|
isPayloadString(payload.sub, '"sub" claim', 'sub', profile === IDTOKEN || profile === ATJWT || !!options.subject)
|
|
isStringOrArrayOfStrings(payload.aud, 'aud', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN || !!options.audience)
|
|
isPayloadString(payload.azp, '"azp" claim', 'azp', profile === IDTOKEN && Array.isArray(payload.aud) && payload.aud.length > 1)
|
|
isStringOrArrayOfStrings(payload.amr, 'amr')
|
|
|
|
if (profile === ATJWT) {
|
|
isPayloadString(payload.client_id, '"client_id" claim', 'client_id', true)
|
|
isPayloadString(header.typ, '"typ" header parameter', 'typ', 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) => {
|
|
if (typeof audPayload === 'string') {
|
|
return audOption.includes(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 = {}) => {
|
|
if (!isObject(options)) {
|
|
throw new TypeError('options must be an object')
|
|
}
|
|
|
|
const {
|
|
algorithms, audience, clockTolerance, complete, crit, ignoreExp, ignoreIat, ignoreNbf, issuer,
|
|
jti, maxAuthAge, maxTokenAge, nonce, now, profile, subject
|
|
} = options = validateOptions(options)
|
|
|
|
const unix = epoch(now)
|
|
|
|
const decoded = decode(token, { complete: true })
|
|
validateTypes(decoded, profile, options)
|
|
|
|
if (issuer && decoded.payload.iss !== issuer) {
|
|
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')
|
|
}
|
|
|
|
if (jti && decoded.payload.jti !== jti) {
|
|
throw new JWTClaimInvalid('unexpected "jti" claim value', 'jti', 'check_failed')
|
|
}
|
|
|
|
if (audience && !checkAudiencePresence(decoded.payload.aud, typeof audience === 'string' ? [audience] : audience, profile)) {
|
|
throw new JWTClaimInvalid('unexpected "aud" claim value', 'aud', 'check_failed')
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
if (!ignoreNbf && 'nbf' in decoded.payload && decoded.payload.nbf > unix + tolerance) {
|
|
throw new JWTClaimInvalid('"nbf" claim timestamp check failed', 'nbf', 'check_failed')
|
|
}
|
|
|
|
if (!ignoreExp && 'exp' in decoded.payload && decoded.payload.exp <= unix - tolerance) {
|
|
throw new JWTExpired('"exp" claim timestamp check failed', 'exp', 'check_failed')
|
|
}
|
|
|
|
if (maxTokenAge) {
|
|
const age = unix - decoded.payload.iat
|
|
const max = secs(maxTokenAge)
|
|
|
|
if (age - tolerance > max) {
|
|
throw new JWTExpired('"iat" claim timestamp check failed (too far in the past)', 'iat', 'check_failed')
|
|
}
|
|
|
|
if (age < 0 - tolerance) {
|
|
throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)', 'iat', 'check_failed')
|
|
}
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
if (profile === ATJWT && decoded.header.typ !== ATJWT) {
|
|
throw new JWTClaimInvalid('invalid JWT typ header value for the used validation profile', 'typ', 'check_failed')
|
|
}
|
|
|
|
key = getKey(key, true)
|
|
|
|
if (complete && key instanceof KeyStore) {
|
|
({ key } = JWS.verify(token, key, { crit, algorithms, complete: true }))
|
|
} else {
|
|
JWS.verify(token, key, { crit, algorithms })
|
|
}
|
|
|
|
return complete ? { ...decoded, key } : decoded.payload
|
|
}
|