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.
503 lines
15 KiB
503 lines
15 KiB
3 years ago
|
'use strict'
|
||
|
|
||
|
const fp = require('fastify-plugin')
|
||
|
const { createSigner, createDecoder, createVerifier, TokenError } = require('fast-jwt')
|
||
|
const assert = require('assert')
|
||
|
const steed = require('steed')
|
||
|
const { parse } = require('@lukeed/ms')
|
||
|
const {
|
||
|
BadRequest,
|
||
|
Unauthorized
|
||
|
} = require('http-errors')
|
||
|
|
||
|
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'
|
||
|
}
|
||
|
|
||
|
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') {
|
||
|
if (isVerifyOptions && options.maxAge) {
|
||
|
options.maxAge = convertToMs(options.maxAge)
|
||
|
} else if (options.expiresIn || options.notBefore) {
|
||
|
if (options.expiresIn) {
|
||
|
options.expiresIn = convertToMs(options.expiresIn)
|
||
|
}
|
||
|
|
||
|
if (options.notBefore) {
|
||
|
options.notBefore = convertToMs(options.notBefore)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return options
|
||
|
}
|
||
|
|
||
|
function fastifyJwt (fastify, options, next) {
|
||
|
if (!options.secret) {
|
||
|
return next(new Error('missing secret'))
|
||
|
}
|
||
|
|
||
|
if (options.options) {
|
||
|
return next(new Error('options prefix is deprecated'))
|
||
|
}
|
||
|
|
||
|
const secret = options.secret
|
||
|
const trusted = options.trusted
|
||
|
let secretOrPrivateKey
|
||
|
let secretOrPublicKey
|
||
|
|
||
|
if (typeof secret === 'object' && !Buffer.isBuffer(secret)) {
|
||
|
if (!secret.private || !secret.public) {
|
||
|
return next(new Error('missing private key and/or public key'))
|
||
|
}
|
||
|
secretOrPrivateKey = secret.private
|
||
|
secretOrPublicKey = secret.public
|
||
|
} else {
|
||
|
secretOrPrivateKey = secretOrPublicKey = secret
|
||
|
}
|
||
|
|
||
|
let secretCallbackSign = secretOrPrivateKey
|
||
|
let secretCallbackVerify = secretOrPublicKey
|
||
|
if (typeof secretCallbackSign !== 'function') {
|
||
|
secretCallbackSign = wrapStaticSecretInCallback(secretCallbackSign)
|
||
|
}
|
||
|
if (typeof secretCallbackVerify !== 'function') {
|
||
|
secretCallbackVerify = wrapStaticSecretInCallback(secretCallbackVerify)
|
||
|
}
|
||
|
|
||
|
const cookie = options.cookie
|
||
|
const formatUser = options.formatUser
|
||
|
|
||
|
const decodeOptions = options.decode || {}
|
||
|
const signOptions = convertTemporalProps(options.sign) || {}
|
||
|
const verifyOptions = convertTemporalProps(options.verify, true) || {}
|
||
|
const messagesOptions = Object.assign({}, messages, options.messages)
|
||
|
const namespace = typeof options.namespace === 'string' ? options.namespace : undefined
|
||
|
|
||
|
if (
|
||
|
signOptions &&
|
||
|
signOptions.algorithm &&
|
||
|
signOptions.algorithm.includes('RS') &&
|
||
|
(typeof secret === 'string' ||
|
||
|
secret instanceof Buffer)
|
||
|
) {
|
||
|
return next(new Error('RSA Signatures set as Algorithm in the options require a private and public key to be set as the secret'))
|
||
|
}
|
||
|
if (
|
||
|
signOptions &&
|
||
|
signOptions.algorithm &&
|
||
|
signOptions.algorithm.includes('ES') &&
|
||
|
(typeof secret === 'string' ||
|
||
|
secret instanceof Buffer)
|
||
|
) {
|
||
|
return next(new Error('ECDSA Signatures set as Algorithm in the options require a private and public key to be set as the secret'))
|
||
|
}
|
||
|
|
||
|
const jwtConfig = {
|
||
|
decode: decode,
|
||
|
options: {
|
||
|
decode: decodeOptions,
|
||
|
sign: signOptions,
|
||
|
verify: verifyOptions,
|
||
|
messages: messagesOptions
|
||
|
},
|
||
|
cookie: cookie,
|
||
|
sign: sign,
|
||
|
verify: verify,
|
||
|
lookupToken: lookupToken
|
||
|
}
|
||
|
|
||
|
let jwtDecodeName = 'jwtDecode'
|
||
|
let jwtVerifyName = 'jwtVerify'
|
||
|
let jwtSignName = 'jwtSign'
|
||
|
if (namespace) {
|
||
|
if (!fastify.jwt) {
|
||
|
fastify.decorateRequest('user', null)
|
||
|
fastify.decorate('jwt', Object.create(null))
|
||
|
}
|
||
|
|
||
|
if (fastify.jwt[namespace]) {
|
||
|
return next(new Error(`JWT namespace already used "${namespace}"`))
|
||
|
}
|
||
|
fastify.jwt[namespace] = jwtConfig
|
||
|
|
||
|
jwtDecodeName = options.jwtDecode ? (typeof options.jwtDecode === 'string' ? options.jwtDecode : 'jwtDecode') : `${namespace}JwtDecode`
|
||
|
jwtVerifyName = options.jwtVerify || `${namespace}JwtVerify`
|
||
|
jwtSignName = options.jwtSign || `${namespace}JwtSign`
|
||
|
} else {
|
||
|
fastify.decorateRequest('user', null)
|
||
|
fastify.decorate('jwt', jwtConfig)
|
||
|
}
|
||
|
|
||
|
// Temporary conditional to prevent breaking changes by exposing `jwtDecode`,
|
||
|
// which already exists in fastify-auth0-verify.
|
||
|
// If jwtDecode has been requested, or plugin is configured to use a namespace.
|
||
|
// TODO Remove conditional when fastify-jwt >=4.x.x
|
||
|
if (options.jwtDecode || namespace) {
|
||
|
fastify.decorateRequest(jwtDecodeName, requestDecode)
|
||
|
}
|
||
|
fastify.decorateRequest(jwtVerifyName, requestVerify)
|
||
|
fastify.decorateReply(jwtSignName, replySign)
|
||
|
|
||
|
const signerConfig = checkAndMergeSignOptions()
|
||
|
const signer = createSigner(signerConfig.options)
|
||
|
const decoder = createDecoder(decodeOptions)
|
||
|
const verifierConfig = checkAndMergeVerifyOptions()
|
||
|
const verifier = createVerifier(verifierConfig.options)
|
||
|
|
||
|
next()
|
||
|
|
||
|
function decode (token, options) {
|
||
|
assert(token, 'missing token')
|
||
|
|
||
|
if (options && typeof options !== 'function') {
|
||
|
const localDecoder = createDecoder(options)
|
||
|
return localDecoder(token)
|
||
|
}
|
||
|
|
||
|
return decoder(token)
|
||
|
}
|
||
|
|
||
|
function lookupToken (request, options) {
|
||
|
assert(request, 'missing request')
|
||
|
|
||
|
options = Object.assign({}, verifyOptions, options)
|
||
|
|
||
|
let token
|
||
|
const extractToken = options.extractToken
|
||
|
if (extractToken) {
|
||
|
token = extractToken(request)
|
||
|
if (!token) {
|
||
|
throw new BadRequest(messagesOptions.badRequestErrorMessage)
|
||
|
}
|
||
|
} else if (request.headers && request.headers.authorization) {
|
||
|
const parts = request.headers.authorization.split(' ')
|
||
|
if (parts.length === 2) {
|
||
|
const scheme = parts[0]
|
||
|
token = parts[1]
|
||
|
|
||
|
if (!/^Bearer$/i.test(scheme)) {
|
||
|
throw new BadRequest(messagesOptions.badRequestErrorMessage)
|
||
|
}
|
||
|
} else {
|
||
|
throw new BadRequest(messagesOptions.badRequestErrorMessage)
|
||
|
}
|
||
|
} 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 Unauthorized(messagesOptions.noAuthorizationInCookieMessage)
|
||
|
}
|
||
|
} else {
|
||
|
throw new BadRequest(messagesOptions.badCookieRequestErrorMessage)
|
||
|
}
|
||
|
} else {
|
||
|
throw new Unauthorized(messagesOptions.noAuthorizationInHeaderMessage)
|
||
|
}
|
||
|
|
||
|
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) {
|
||
|
let mergedOptions
|
||
|
|
||
|
if (typeof options === 'function') {
|
||
|
callback = options
|
||
|
mergedOptions = mergeOptionsWithKey(defaultOptions, usePrivateKey)
|
||
|
} else {
|
||
|
if (!options) {
|
||
|
mergedOptions = mergeOptionsWithKey(defaultOptions, usePrivateKey)
|
||
|
} else {
|
||
|
mergedOptions = mergeOptionsWithKey(options, usePrivateKey)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return { options: mergedOptions, 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')
|
||
|
let localSigner = signer
|
||
|
|
||
|
convertTemporalProps(options)
|
||
|
const signerConfig = checkAndMergeSignOptions(options, 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
|
||
|
|
||
|
convertTemporalProps(options, true)
|
||
|
const veriferConfig = checkAndMergeVerifyOptions(options, callback)
|
||
|
|
||
|
if (options && typeof options !== 'function') {
|
||
|
localVerifier = createVerifier(veriferConfig.options)
|
||
|
}
|
||
|
|
||
|
if (typeof veriferConfig.callback === 'function') {
|
||
|
const result = localVerifier(token)
|
||
|
veriferConfig.callback(null, result)
|
||
|
} else {
|
||
|
return localVerifier(token)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function replySign (payload, options, next) {
|
||
|
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) {
|
||
|
convertTemporalProps(options.sign)
|
||
|
// New supported contract, options supports sign and can expand
|
||
|
options = {
|
||
|
sign: mergeOptionsWithKey({ ...signOptions, ...options.sign }, true)
|
||
|
}
|
||
|
} else {
|
||
|
convertTemporalProps(options)
|
||
|
// Original contract, options supports only sign
|
||
|
options = mergeOptionsWithKey({ ...signOptions, ...options }, 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) {
|
||
|
let useLocalVerifier = true
|
||
|
if (typeof options === 'function' && !next) {
|
||
|
next = options
|
||
|
options = {}
|
||
|
useLocalVerifier = false
|
||
|
} // support no options
|
||
|
|
||
|
if (!options) {
|
||
|
options = {}
|
||
|
useLocalVerifier = false
|
||
|
}
|
||
|
|
||
|
if (options.decode || options.verify) {
|
||
|
convertTemporalProps(options.verify, true)
|
||
|
// New supported contract, options supports both decode and verify
|
||
|
options = {
|
||
|
decode: Object.assign({}, decodeOptions, options.decode),
|
||
|
verify: Object.assign({}, verifyOptions, options.verify)
|
||
|
}
|
||
|
} else {
|
||
|
convertTemporalProps(options, true)
|
||
|
// Original contract, options supports only verify
|
||
|
options = Object.assign({}, verifyOptions, options)
|
||
|
}
|
||
|
|
||
|
const request = this
|
||
|
|
||
|
if (next === undefined) {
|
||
|
return new Promise(function (resolve, reject) {
|
||
|
request[jwtVerifyName](options, function (err, val) {
|
||
|
err ? reject(err) : resolve(val)
|
||
|
})
|
||
|
})
|
||
|
}
|
||
|
|
||
|
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 {
|
||
|
if (useLocalVerifier) {
|
||
|
const verifierOptions = mergeOptionsWithKey(options.verify || options, secretOrPublicKey)
|
||
|
const localVerifier = createVerifier(verifierOptions)
|
||
|
const verifyResult = localVerifier(token)
|
||
|
callback(null, verifyResult)
|
||
|
} else {
|
||
|
const verifyResult = verifier(token)
|
||
|
callback(null, verifyResult)
|
||
|
}
|
||
|
} catch (error) {
|
||
|
if (error.code === TokenError.codes.expired) {
|
||
|
return callback(new Unauthorized(messagesOptions.authorizationTokenExpiredMessage))
|
||
|
}
|
||
|
|
||
|
if (error.code === TokenError.codes.invalidKey ||
|
||
|
error.code === TokenError.codes.invalidSignature ||
|
||
|
error.code === TokenError.codes.invalidClaimValue
|
||
|
) {
|
||
|
return callback(new Unauthorized(typeof messagesOptions.authorizationTokenInvalid === 'function' ? messagesOptions.authorizationTokenInvalid(error) : messagesOptions.authorizationTokenInvalid))
|
||
|
}
|
||
|
|
||
|
return callback(error)
|
||
|
}
|
||
|
},
|
||
|
function checkIfIsTrusted (result, callback) {
|
||
|
if (!trusted) {
|
||
|
callback(null, result)
|
||
|
} else {
|
||
|
const maybePromise = trusted(request, result)
|
||
|
|
||
|
if (maybePromise && maybePromise.then) {
|
||
|
maybePromise
|
||
|
.then(trusted => trusted ? callback(null, result) : callback(new Unauthorized(messagesOptions.authorizationTokenUntrusted)))
|
||
|
} else if (maybePromise) {
|
||
|
callback(null, maybePromise)
|
||
|
} else {
|
||
|
callback(new Unauthorized(messagesOptions.authorizationTokenUntrusted))
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
], function (err, result) {
|
||
|
if (err) {
|
||
|
next(err)
|
||
|
} else {
|
||
|
const user = formatUser ? formatUser(result) : result
|
||
|
request.user = user
|
||
|
next(null, user)
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
module.exports = fp(fastifyJwt, {
|
||
|
fastify: '>=3.0.0',
|
||
|
name: 'fastify-jwt'
|
||
|
})
|