You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

522 lines
14 KiB

'use strict'
const { createPublicKey, createSecretKey } = require('crypto')
const Cache = require('mnemonist/lru-cache')
const { useNewCrypto, hsAlgorithms, verifySignature, detectPublicKeyAlgorithms } = require('./crypto')
const createDecoder = require('./decoder')
const { TokenError } = require('./error')
const { getAsyncKey, ensurePromiseCallback, hashToken } = require('./utils')
const defaultCacheSize = 1000
function exactStringClaimMatcher(allowed, actual) {
return allowed === actual
}
function checkAreCompatibleAlgorithms(expected, actual) {
let valid = false
for (const expectedAlg of expected) {
valid = actual.indexOf(expectedAlg) !== -1
// if at least one of the expected algorithms is compatible we're done
if (valid) {
break
}
}
if (!valid) {
throw new TokenError(
TokenError.codes.invalidKey,
`Invalid public key provided for algorithms ${expected.join(', ')}.`
)
}
}
function prepareKeyOrSecret(key, isSecret) {
if (typeof key === 'string') {
key = Buffer.from(key, 'utf-8')
}
// Only on Node 12 - Create a key object
/* istanbul ignore next */
if (useNewCrypto) {
key = isSecret ? createSecretKey(key) : createPublicKey(key)
}
return key
}
function ensureStringClaimMatcher(raw) {
if (!Array.isArray(raw)) {
raw = [raw]
}
return raw
.filter(r => r)
.map(r => {
if (r && typeof r.test === 'function') {
return r
}
return { test: exactStringClaimMatcher.bind(null, r) }
})
}
function createCache(rawSize) {
const size = parseInt(rawSize === true ? defaultCacheSize : rawSize, 10)
return size > 0 ? new Cache(size) : null
}
function cacheSet(
{
cache,
token,
cacheTTL,
payload,
ignoreExpiration,
ignoreNotBefore,
maxAge,
clockTimestamp,
clockTolerance,
errorCacheTTL
},
value
) {
if (!cache) {
return value
}
const cacheValue = [value, 0, 0]
if (value instanceof TokenError) {
const ttl = typeof errorCacheTTL === 'function' ? errorCacheTTL(value) : errorCacheTTL
cacheValue[2] = (clockTimestamp || Date.now()) + clockTolerance + ttl
cache.set(hashToken(token), cacheValue)
return value
}
const hasIat = payload && typeof payload.iat === 'number'
// Add time range of the token
if (hasIat) {
cacheValue[1] = !ignoreNotBefore && typeof payload.nbf === 'number' ? payload.nbf * 1000 - clockTolerance : 0
if (!ignoreExpiration) {
if (typeof payload.exp === 'number') {
cacheValue[2] = payload.exp * 1000 + clockTolerance
} else if (maxAge) {
cacheValue[2] = payload.iat * 1000 + maxAge + clockTolerance
}
}
}
// The maximum TTL for the token cannot exceed the configured cacheTTL
const maxTTL = (clockTimestamp || Date.now()) + clockTolerance + cacheTTL
cacheValue[2] = cacheValue[2] === 0 ? maxTTL : Math.min(cacheValue[2], maxTTL)
cache.set(hashToken(token), cacheValue)
return value
}
function handleCachedResult(cached, callback, promise) {
if (cached instanceof TokenError) {
if (!callback) {
throw cached
}
callback(cached)
} else {
if (!callback) {
return cached
}
callback(null, cached)
}
return promise
}
function validateAlgorithmAndSignature(input, header, signature, key, allowedAlgorithms) {
// According to the signature and key, check with algorithms are supported
const algorithms = allowedAlgorithms
// Verify the token is allowed
if (!algorithms.includes(header.alg)) {
throw new TokenError(TokenError.codes.invalidAlgorithm, 'The token algorithm is invalid.')
}
// Verify the signature, if present
if (signature && !verifySignature(header.alg, key, input, signature)) {
throw new TokenError(TokenError.codes.invalidSignature, 'The token signature is invalid.')
}
}
function validateClaimType(values, claim, array, type) {
const typeFailureMessage = array
? `The ${claim} claim must be a ${type} or an array of ${type}s.`
: `The ${claim} claim must be a ${type}.`
if (values.map(v => typeof v).some(t => t !== type)) {
throw new TokenError(TokenError.codes.invalidClaimType, typeFailureMessage)
}
}
function validateClaimValues(values, claim, allowed, arrayValue) {
const failureMessage = arrayValue
? `None of ${claim} claim values are allowed.`
: `The ${claim} claim value is not allowed.`
if (!values.some(v => allowed.some(a => a.test(v)))) {
throw new TokenError(TokenError.codes.invalidClaimValue, failureMessage)
}
}
function validateClaimDateValue(value, modifier, now, greater, errorCode, errorVerb) {
const adjusted = value * 1000 + (modifier || 0)
const valid = greater ? now >= adjusted : now <= adjusted
if (!valid) {
throw new TokenError(TokenError.codes[errorCode], `The token ${errorVerb} at ${new Date(adjusted).toISOString()}.`)
}
}
function verifyToken(
key,
{ input, header, payload, signature },
{ validators, allowedAlgorithms, checkTyp, clockTimestamp, clockTolerance, requiredClaims }
) {
// Verify the key
/* istanbul ignore next */
const hasKey = key instanceof Buffer ? key.length : !!key
if (hasKey && !signature) {
throw new TokenError(TokenError.codes.missingSignature, 'The token signature is missing.')
} else if (!hasKey && signature) {
throw new TokenError(TokenError.codes.missingKey, 'The key option is missing.')
}
validateAlgorithmAndSignature(input, header, signature, key, allowedAlgorithms)
// Verify typ
if (checkTyp) {
if (typeof header.typ !== 'string' || checkTyp !== header.typ.toLowerCase().replace(/^application\//, '')) {
throw new TokenError(TokenError.codes.invalidType, 'Invalid typ.')
}
}
// Verify the payload
const now = clockTimestamp || Date.now()
for (const validator of validators) {
const { type, claim, allowed, array, modifier, greater, errorCode, errorVerb } = validator
const value = payload[claim]
const arrayValue = Array.isArray(value)
const values = arrayValue ? value : [value]
// Check if the claim is marked as required before skipping it
if (!(claim in payload)) {
if (requiredClaims && requiredClaims.includes(claim)) {
throw new TokenError(TokenError.codes.missingRequiredClaim, `The ${claim} claim is required.`)
}
continue
}
// Validate type
validateClaimType(values, claim, array, type === 'date' ? 'number' : 'string')
if (type === 'date') {
validateClaimDateValue(value, modifier, now, greater, errorCode, errorVerb)
} else {
validateClaimValues(values, claim, allowed, arrayValue)
}
}
}
function verify(
{
key,
allowedAlgorithms,
complete,
cacheTTL,
checkTyp,
clockTimestamp,
clockTolerance,
ignoreExpiration,
ignoreNotBefore,
maxAge,
isAsync,
validators,
decode,
cache,
requiredClaims,
errorCacheTTL
},
token,
cb
) {
const [callback, promise] = isAsync ? ensurePromiseCallback(cb) : []
const cacheContext = {
cache,
token,
cacheTTL,
errorCacheTTL,
payload: undefined,
ignoreExpiration,
ignoreNotBefore,
maxAge,
clockTimestamp,
clockTolerance
}
// Check the cache
if (cache) {
const [value, min, max] = cache.get(hashToken(token)) || [undefined, 0, 0]
const now = clockTimestamp || Date.now()
// Validate time range
if (
/* istanbul ignore next */
typeof value !== 'undefined' &&
(min === 0 ||
(now < min && value.code === 'FAST_JWT_INACTIVE') ||
(now >= min && value.code !== 'FAST_JWT_INACTIVE')) &&
(max === 0 || now <= max)
) {
// Cache hit
return handleCachedResult(value, callback, promise)
}
}
/*
As very first thing, decode the token - If invalid, everything else is useless.
We don't involve cache here since it's much slower.
*/
let decoded
try {
decoded = decode(token)
} catch (e) {
if (callback) {
callback(e)
return promise
}
throw e
}
const { header, payload, signature } = decoded
cacheContext.payload = payload
const validationContext = { validators, allowedAlgorithms, checkTyp, clockTimestamp, clockTolerance, requiredClaims }
// We have the key
if (!callback) {
try {
verifyToken(key, decoded, validationContext)
return cacheSet(cacheContext, complete ? { header, payload, signature } : payload)
} catch (e) {
throw cacheSet(cacheContext, e)
}
}
// Get the key asynchronously
getAsyncKey(key, header, (err, currentKey) => {
if (err) {
return callback(
cacheSet(cacheContext, TokenError.wrap(err, TokenError.codes.keyFetchingError, 'Cannot fetch key.'))
)
}
if (typeof currentKey === 'string') {
currentKey = Buffer.from(currentKey, 'utf-8')
} else if (!(currentKey instanceof Buffer)) {
return callback(
cacheSet(
cacheContext,
new TokenError(
TokenError.codes.keyFetchingError,
'The key returned from the callback must be a string or a buffer containing a secret or a public key.'
)
)
)
}
try {
// Detect the private key - If the algorithms were known, just verify they match, otherwise assign them
const availableAlgorithms = detectPublicKeyAlgorithms(currentKey)
if (validationContext.allowedAlgorithms.length) {
checkAreCompatibleAlgorithms(allowedAlgorithms, availableAlgorithms)
} else {
validationContext.allowedAlgorithms = availableAlgorithms
}
currentKey = prepareKeyOrSecret(currentKey, availableAlgorithms[0] === hsAlgorithms[0])
verifyToken(currentKey, decoded, validationContext)
} catch (e) {
return callback(cacheSet(cacheContext, e))
}
callback(null, cacheSet(cacheContext, complete ? { header, payload, signature } : payload))
})
return promise
}
module.exports = function createVerifier(options) {
let {
key,
algorithms: allowedAlgorithms,
complete,
cache: cacheSize,
cacheTTL,
errorCacheTTL,
checkTyp,
clockTimestamp,
clockTolerance,
ignoreExpiration,
ignoreNotBefore,
maxAge,
allowedJti,
allowedAud,
allowedIss,
allowedSub,
allowedNonce,
requiredClaims
} = { cacheTTL: 600000, clockTolerance: 0, errorCacheTTL: -1, ...options }
// Validate options
if (!Array.isArray(allowedAlgorithms)) {
allowedAlgorithms = []
}
const keyType = typeof key
if (keyType !== 'string' && keyType !== 'object' && keyType !== 'function') {
throw new TokenError(
TokenError.codes.INVALID_OPTION,
'The key option must be a string, a buffer or a function returning the algorithm secret or public key.'
)
}
if (key && keyType !== 'function') {
// Detect the private key - If the algorithms were known, just verify they match, otherwise assign them
const availableAlgorithms = detectPublicKeyAlgorithms(key)
if (allowedAlgorithms.length) {
checkAreCompatibleAlgorithms(allowedAlgorithms, availableAlgorithms)
} else {
allowedAlgorithms = availableAlgorithms
}
key = prepareKeyOrSecret(key, availableAlgorithms[0] === hsAlgorithms[0])
}
if (clockTimestamp && (typeof clockTimestamp !== 'number' || clockTimestamp < 0)) {
throw new TokenError(TokenError.codes.invalidOption, 'The clockTimestamp option must be a positive number.')
}
if (clockTolerance && (typeof clockTolerance !== 'number' || clockTolerance < 0)) {
throw new TokenError(TokenError.codes.invalidOption, 'The clockTolerance option must be a positive number.')
}
if (cacheTTL && (typeof cacheTTL !== 'number' || cacheTTL < 0)) {
throw new TokenError(TokenError.codes.invalidOption, 'The cacheTTL option must be a positive number.')
}
if (
(errorCacheTTL && typeof errorCacheTTL !== 'function' && typeof errorCacheTTL !== 'number') ||
errorCacheTTL < -1
) {
throw new TokenError(
TokenError.codes.invalidOption,
'The errorCacheTTL option must be a number greater than -1 or a function.'
)
}
if (requiredClaims && !Array.isArray(requiredClaims)) {
throw new TokenError(TokenError.codes.invalidOption, 'The requiredClaims option must be an array.')
}
// Add validators
const validators = []
if (!ignoreNotBefore) {
validators.push({
type: 'date',
claim: 'nbf',
errorCode: 'inactive',
errorVerb: 'will be active',
greater: true,
modifier: -clockTolerance
})
}
if (!ignoreExpiration) {
validators.push({
type: 'date',
claim: 'exp',
errorCode: 'expired',
errorVerb: 'has expired',
modifier: +clockTolerance
})
}
if (typeof maxAge === 'number') {
validators.push({ type: 'date', claim: 'iat', errorCode: 'expired', errorVerb: 'has expired', modifier: maxAge })
}
if (allowedJti) {
validators.push({ type: 'string', claim: 'jti', allowed: ensureStringClaimMatcher(allowedJti) })
}
if (allowedAud) {
validators.push({ type: 'string', claim: 'aud', allowed: ensureStringClaimMatcher(allowedAud), array: true })
}
if (allowedIss) {
validators.push({ type: 'string', claim: 'iss', allowed: ensureStringClaimMatcher(allowedIss) })
}
if (allowedSub) {
validators.push({ type: 'string', claim: 'sub', allowed: ensureStringClaimMatcher(allowedSub) })
}
if (allowedNonce) {
validators.push({ type: 'string', claim: 'nonce', allowed: ensureStringClaimMatcher(allowedNonce) })
}
let normalizedTyp = null
if (checkTyp) {
normalizedTyp = checkTyp.toLowerCase().replace(/^application\//, '')
}
const context = {
key,
allowedAlgorithms,
complete,
cacheTTL,
errorCacheTTL,
checkTyp: normalizedTyp,
clockTimestamp,
clockTolerance,
ignoreExpiration,
ignoreNotBefore,
maxAge,
isAsync: keyType === 'function',
validators,
decode: createDecoder({ complete: true }),
cache: createCache(cacheSize),
requiredClaims
}
// Return the verifier
const verifier = verify.bind(null, context)
verifier.cache = context.cache
return verifier
}