@fastify/jwt
Version:
JWT utils for Fastify
570 lines (485 loc) • 18.8 kB
JavaScript
const fp = require('fastify-plugin')
const { createSigner, createDecoder, createVerifier, TokenError } = require('fast-jwt')
const assert = require('node:assert')
const steed = require('steed')
const { parse } = require('@lukeed/ms')
const createError = require('@fastify/error')
const messages = {
badRequestErrorMessage: 'Format is Authorization: Bearer [token]',
badCookieRequestErrorMessage: 'Cookie could not be parsed in request',
noAuthorizationInHeaderMessage: 'No Authorization was found in request.headers',
noAuthorizationInCookieMessage: 'No Authorization was found in request.cookies',
authorizationTokenExpiredMessage: 'Authorization token expired',
authorizationTokenInvalid: (err) => `Authorization token is invalid: ${err.message}`,
authorizationTokenUntrusted: 'Untrusted authorization token',
authorizationTokenUnsigned: 'Unsigned authorization token'
}
function isString (x) {
return Object.prototype.toString.call(x) === '[object String]'
}
function wrapStaticSecretInCallback (secret) {
return function (_request, _payload, cb) {
return cb(null, secret)
}
}
function convertToMs (time) {
// by default if time is number we assume that they are seconds - see README.md
if (typeof time === 'number') {
return time * 1000
}
return parse(time)
}
function convertTemporalProps (options, isVerifyOptions) {
if (!options || typeof options === 'function') {
return options
}
const formatedOptions = Object.assign({}, options)
if (isVerifyOptions && formatedOptions.maxAge) {
formatedOptions.maxAge = convertToMs(formatedOptions.maxAge)
} else if (formatedOptions.expiresIn || formatedOptions.notBefore) {
if (formatedOptions.expiresIn) {
formatedOptions.expiresIn = convertToMs(formatedOptions.expiresIn)
}
if (formatedOptions.notBefore) {
formatedOptions.notBefore = convertToMs(formatedOptions.notBefore)
}
}
return formatedOptions
}
function validateOptions (options) {
assert(options.secret, 'missing secret')
assert(!options.options, 'options prefix is deprecated')
assert(!options.jwtVerify || isString(options.jwtVerify), 'Invalid options.jwtVerify')
assert(!options.jwtDecode || isString(options.jwtDecode), 'Invalid options.jwtDecode')
assert(!options.jwtSign || isString(options.jwtSign), 'Invalid options.jwtSign')
if (
options.sign?.algorithm?.includes('RS') &&
(typeof options.secret === 'string' ||
options.secret instanceof Buffer)
) {
throw new Error('RSA Signatures set as Algorithm in the options require a private and public key to be set as the secret')
}
if (
options.sign?.algorithm?.includes('ES') &&
(typeof options.secret === 'string' ||
options.secret instanceof Buffer)
) {
throw new Error('ECDSA Signatures set as Algorithm in the options require a private and public key to be set as the secret')
}
}
function fastifyJwt (fastify, options, next) {
try {
validateOptions(options)
} catch (e) {
return next(e)
}
const {
cookie,
decode: decodeOptions = {},
formatUser,
jwtDecode,
jwtSign,
jwtVerify,
secret,
sign: initialSignOptions = {},
trusted,
decoratorName = 'user',
// TODO: disable on next major
// enable errorCacheTTL to prevent breaking change
verify: initialVerifyOptions = { errorCacheTTL: 600000 },
...pluginOptions
} = options
const validatorCache = new Map()
let secretOrPrivateKey
let secretOrPublicKey
if (typeof secret === 'object' && !Buffer.isBuffer(secret)) {
if (!secret.public) {
return next(new Error('missing public key'))
}
secretOrPrivateKey = secret.private
secretOrPublicKey = secret.public
} else {
secretOrPrivateKey = secretOrPublicKey = secret
}
let hasStaticPublicKey = false
let secretCallbackSign = secretOrPrivateKey
let secretCallbackVerify = secretOrPublicKey
if (typeof secretCallbackSign !== 'function') {
secretCallbackSign = wrapStaticSecretInCallback(secretCallbackSign)
}
if (typeof secretCallbackVerify !== 'function') {
secretCallbackVerify = wrapStaticSecretInCallback(secretCallbackVerify)
hasStaticPublicKey = true
}
const signOptions = convertTemporalProps(initialSignOptions)
const verifyOptions = convertTemporalProps(initialVerifyOptions, true)
const messagesOptions = Object.assign({}, messages, pluginOptions.messages)
const namespace = typeof pluginOptions.namespace === 'string' ? pluginOptions.namespace : undefined
const NoAuthorizationInCookieError = createError('FST_JWT_NO_AUTHORIZATION_IN_COOKIE', messagesOptions.noAuthorizationInCookieMessage, 401)
const AuthorizationTokenExpiredError = createError('FST_JWT_AUTHORIZATION_TOKEN_EXPIRED', messagesOptions.authorizationTokenExpiredMessage, 401)
const AuthorizationTokenUntrustedError = createError('FST_JWT_AUTHORIZATION_TOKEN_UNTRUSTED', messagesOptions.authorizationTokenUntrusted, 401)
const AuthorizationTokenUnsignedError = createError('FAST_JWT_MISSING_SIGNATURE', messagesOptions.authorizationTokenUnsigned, 401)
const NoAuthorizationInHeaderError = createError('FST_JWT_NO_AUTHORIZATION_IN_HEADER', messagesOptions.noAuthorizationInHeaderMessage, 401)
const AuthorizationTokenInvalidError = createError('FST_JWT_AUTHORIZATION_TOKEN_INVALID', typeof messagesOptions.authorizationTokenInvalid === 'function'
? messagesOptions.authorizationTokenInvalid({ message: '%s' })
: messagesOptions.authorizationTokenInvalid
, 401)
const BadRequestError = createError('FST_JWT_BAD_REQUEST', messagesOptions.badRequestErrorMessage, 400)
const BadCookieRequestError = createError('FST_JWT_BAD_COOKIE_REQUEST', messagesOptions.badCookieRequestErrorMessage, 400)
const jwtDecorator = {
decode,
options: {
decode: decodeOptions,
sign: initialSignOptions,
verify: initialVerifyOptions,
messages: messagesOptions,
decoratorName
},
cookie,
sign,
verify,
lookupToken
}
let jwtDecodeName = 'jwtDecode'
let jwtVerifyName = 'jwtVerify'
let jwtSignName = 'jwtSign'
if (namespace) {
if (!fastify.jwt) {
fastify.decorateRequest(decoratorName, null)
fastify.decorate('jwt', Object.create(null))
}
if (fastify.jwt[namespace]) {
return next(new Error(`JWT namespace already used "${namespace}"`))
}
fastify.jwt[namespace] = jwtDecorator
jwtDecodeName = jwtDecode || `${namespace}JwtDecode`
jwtVerifyName = jwtVerify || `${namespace}JwtVerify`
jwtSignName = jwtSign || `${namespace}JwtSign`
} else {
fastify.decorateRequest(decoratorName, null)
fastify.decorate('jwt', jwtDecorator)
}
fastify.decorateRequest(jwtDecodeName, requestDecode)
fastify.decorateRequest(jwtVerifyName, requestVerify)
fastify.decorateReply(jwtSignName, replySign)
const signerConfig = checkAndMergeSignOptions()
// no signer when configured in verify-mode
const signer = signerConfig.options.key
? createSigner(signerConfig.options)
: null
const decoder = createDecoder(decodeOptions)
const verifierConfig = checkAndMergeVerifyOptions()
const verifier = createVerifier(verifierConfig.options)
next()
function getVerifier (options, globalOptions) {
const useGlobalOptions = globalOptions ?? options === verifierConfig.options
// Use global verifier if using global options with static key
if (useGlobalOptions && hasStaticPublicKey) return verifier
// Only cache verifier when using default options (except for key)
if (useGlobalOptions && options.key && typeof options.key === 'string') {
let verifier = validatorCache.get(options.key)
if (!verifier) {
verifier = createVerifier(options)
validatorCache.set(options.key, verifier)
}
return verifier
}
return createVerifier(options)
}
function decode (token, options) {
assert(token, 'missing token')
let selectedDecoder = decoder
if (options && options !== decodeOptions && typeof options !== 'function') {
selectedDecoder = createDecoder(options)
}
try {
return selectedDecoder(token)
} catch (error) {
// Ignoring the else branch because it's not possible to test it,
// it's just a safeguard for future changes in the fast-jwt library
if (error.code === TokenError.codes.malformed) {
throw new AuthorizationTokenInvalidError(error.message)
} else if (error.code === TokenError.codes.invalidType) {
throw new AuthorizationTokenInvalidError(error.message)
} /* c8 ignore start */ else {
throw error
} /* c8 ignore stop */
}
}
function lookupToken (request, options) {
assert(request, 'missing request')
options = Object.assign({}, verifyOptions, options)
let token
const extractToken = options.extractToken
const onlyCookie = options.onlyCookie
if (extractToken) {
token = extractToken(request)
if (!token) {
throw new BadRequestError()
}
} else if (request.headers.authorization && !onlyCookie && /^Bearer\s/i.test(request.headers.authorization)) {
const parts = request.headers.authorization.split(' ')
if (parts.length === 2) {
token = parts[1]
} else {
throw new BadRequestError()
}
} else if (cookie) {
if (request.cookies) {
if (request.cookies[cookie.cookieName]) {
const tokenValue = request.cookies[cookie.cookieName]
token = cookie.signed ? request.unsignCookie(tokenValue).value : tokenValue
} else {
throw new NoAuthorizationInCookieError()
}
} else {
throw new BadCookieRequestError()
}
} else {
throw new NoAuthorizationInHeaderError()
}
return token
}
function mergeOptionsWithKey (options, useProvidedPrivateKey) {
if (useProvidedPrivateKey && (typeof useProvidedPrivateKey !== 'boolean')) {
return Object.assign({}, options, { key: useProvidedPrivateKey })
} else {
const key = useProvidedPrivateKey ? secretOrPrivateKey : secretOrPublicKey
return Object.assign(!options.key ? { key } : {}, options)
}
}
function checkAndMergeOptions (options, defaultOptions, usePrivateKey, callback) {
if (typeof options === 'function') {
return { options: mergeOptionsWithKey(defaultOptions, usePrivateKey), callback: options }
}
return { options: mergeOptionsWithKey(options || defaultOptions, usePrivateKey), callback }
}
function checkAndMergeSignOptions (options, callback) {
return checkAndMergeOptions(options, signOptions, true, callback)
}
function checkAndMergeVerifyOptions (options, callback) {
return checkAndMergeOptions(options, verifyOptions, false, callback)
}
function sign (payload, options, callback) {
assert(payload, 'missing payload')
// if a global signer was not created, sign mode is not supported
assert(signer, 'unable to sign: secret is configured in verify mode')
let localSigner = signer
const localOptions = convertTemporalProps(options)
const signerConfig = checkAndMergeSignOptions(localOptions, callback)
if (options && typeof options !== 'function') {
localSigner = createSigner(signerConfig.options)
}
if (typeof signerConfig.callback === 'function') {
const token = localSigner(payload)
signerConfig.callback(null, token)
} else {
return localSigner(payload)
}
}
function verify (token, options, callback) {
assert(token, 'missing token')
assert(secretOrPublicKey, 'missing secret')
let localVerifier = verifier
const localOptions = convertTemporalProps(options, true)
const verifierConfig = checkAndMergeVerifyOptions(localOptions, callback)
if (options && typeof options !== 'function') {
localVerifier = getVerifier(verifierConfig.options)
}
if (typeof verifierConfig.callback === 'function') {
const result = localVerifier(token)
verifierConfig.callback(null, result)
} else {
return localVerifier(token)
}
}
function replySign (payload, options, next) {
// if a global signer was not created, sign mode is not supported
assert(signer, 'unable to sign: secret is configured in verify mode')
let useLocalSigner = true
if (typeof options === 'function') {
next = options
options = {}
useLocalSigner = false
} // support no options
if (!options) {
options = {}
useLocalSigner = false
}
const reply = this
if (next === undefined) {
return new Promise(function (resolve, reject) {
reply[jwtSignName](payload, options, function (err, val) {
err ? reject(err) : resolve(val)
})
})
}
if (options.sign) {
const localSignOptions = convertTemporalProps(options.sign)
// New supported contract, options supports sign and can expand
options = {
sign: mergeOptionsWithKey(Object.assign({}, signOptions, localSignOptions), true)
}
} else {
const localOptions = convertTemporalProps(options)
// Original contract, options supports only sign
options = mergeOptionsWithKey(Object.assign({}, signOptions, localOptions), true)
}
if (!payload) {
return next(new Error('jwtSign requires a payload'))
}
steed.waterfall([
function getSecret (callback) {
const signResult = secretCallbackSign(reply.request, payload, callback)
if (signResult && typeof signResult.then === 'function') {
signResult.then(result => callback(null, result), callback)
}
},
function sign (secretOrPrivateKey, callback) {
if (useLocalSigner) {
const signerOptions = mergeOptionsWithKey(options.sign || options, secretOrPrivateKey)
const localSigner = createSigner(signerOptions)
const token = localSigner(payload)
callback(null, token)
} else {
const token = signer(payload)
callback(null, token)
}
}
], next)
}
function requestDecode (options, next) {
if (typeof options === 'function' && !next) {
next = options
options = {}
} // support no options
if (!options) {
options = {}
}
options = {
decode: Object.assign({}, decodeOptions, options.decode),
verify: Object.assign({}, verifyOptions, options.verify)
}
const request = this
if (next === undefined) {
return new Promise(function (resolve, reject) {
request[jwtDecodeName](options, function (err, val) {
err ? reject(err) : resolve(val)
})
})
}
try {
const token = lookupToken(request, options.verify)
const decodedToken = decode(token, options.decode)
return next(null, decodedToken)
} catch (err) {
return next(err)
}
}
function requestVerify (options, next) {
const request = this
if (next === undefined) {
return new Promise(function (resolve, reject) {
request[jwtVerifyName](options, function (err, val) {
err ? reject(err) : resolve(val)
})
})
}
const useGlobalOptions = !options
if (typeof options === 'function') {
next = options
options = {}
} // support no options
if (!options) {
options = {}
}
if (options.decode || options.verify) {
const localVerifyOptions = convertTemporalProps(options.verify, true)
// New supported contract, options supports both decode and verify
options = {
decode: Object.assign({}, decodeOptions, options.decode),
verify: Object.assign({}, verifyOptions, localVerifyOptions)
}
} else {
const localOptions = convertTemporalProps(options, true)
// Original contract, options supports only verify
options = Object.assign({}, verifyOptions, localOptions)
}
let token
try {
token = lookupToken(request, options.verify || options)
} catch (err) {
return next(err)
}
const decodedToken = decode(token, options.decode || decodeOptions)
steed.waterfall([
function getSecret (callback) {
const verifyResult = secretCallbackVerify(request, decodedToken, callback)
if (verifyResult && typeof verifyResult.then === 'function') {
verifyResult.then(result => callback(null, result), callback)
}
},
function verify (secretOrPublicKey, callback) {
try {
const verifierOptions = mergeOptionsWithKey(options.verify || options, secretOrPublicKey)
const localVerifier = getVerifier(verifierOptions, useGlobalOptions)
const verifyResult = localVerifier(token)
if (verifyResult && typeof verifyResult.then === 'function') {
verifyResult.then(result => callback(null, result), error => wrapError(error, callback))
} else {
callback(null, verifyResult)
}
} catch (error) {
return wrapError(error, callback)
}
},
function checkIfIsTrusted (result, callback) {
if (!trusted) {
callback(null, result)
} else {
const maybePromise = trusted(request, result)
if (maybePromise?.then) {
maybePromise
.then(trusted => trusted ? callback(null, result) : callback(new AuthorizationTokenUntrustedError()))
} else if (maybePromise) {
callback(null, result)
} else {
callback(new AuthorizationTokenUntrustedError())
}
}
}
], function (err, result) {
if (err) {
next(err)
} else {
const user = formatUser ? formatUser(result) : result
request[decoratorName] = user
next(null, user)
}
})
}
function wrapError (error, callback) {
if (error.code === TokenError.codes.expired) {
return callback(new AuthorizationTokenExpiredError())
}
if (error.code === TokenError.codes.invalidKey ||
error.code === TokenError.codes.invalidSignature ||
error.code === TokenError.codes.invalidClaimValue
) {
return callback(typeof messagesOptions.authorizationTokenInvalid === 'function'
? new AuthorizationTokenInvalidError(error.message)
: new AuthorizationTokenInvalidError())
}
if (error.code === TokenError.codes.missingSignature) {
return callback(new AuthorizationTokenUnsignedError())
}
return callback(error)
}
}
module.exports = fp(fastifyJwt, {
fastify: '5.x',
name: '@fastify/jwt'
})
module.exports.default = fastifyJwt
module.exports.fastifyJwt = fastifyJwt