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.
310 lines
8.9 KiB
310 lines
8.9 KiB
'use strict'
|
|
|
|
const {
|
|
base64UrlMatcher,
|
|
base64UrlReplacer,
|
|
useNewCrypto,
|
|
hsAlgorithms,
|
|
esAlgorithms,
|
|
rsaAlgorithms,
|
|
edAlgorithms,
|
|
detectPrivateKeyAlgorithm,
|
|
createSignature
|
|
} = require('./crypto')
|
|
const { TokenError } = require('./error')
|
|
const { getAsyncKey, ensurePromiseCallback } = require('./utils')
|
|
const { createPrivateKey, createSecretKey } = require('crypto')
|
|
|
|
const supportedAlgorithms = Array.from(
|
|
new Set([...hsAlgorithms, ...esAlgorithms, ...rsaAlgorithms, ...edAlgorithms, 'none'])
|
|
).join(', ')
|
|
|
|
function checkIsCompatibleAlgorithm(expected, actual) {
|
|
const expectedType = expected.slice(0, 2)
|
|
const actualType = actual.slice(0, 2)
|
|
|
|
let valid = true // We accept everything for HS
|
|
|
|
// If the key is passphrase encrypted (actual === "ENCRYPTED") only RS and ES algos are supported
|
|
if (expectedType === 'RS' || expectedType === 'PS') {
|
|
// RS and PS use same keys
|
|
valid = actualType === 'RS' || (expectedType === 'RS' && actual === 'ENCRYPTED')
|
|
} else if (expectedType === 'ES' || expectedType === 'Ed') {
|
|
// ES and Ed must match
|
|
valid = expectedType === actualType || (expectedType === 'ES' && actual === 'ENCRYPTED')
|
|
}
|
|
|
|
if (!valid) {
|
|
throw new TokenError(TokenError.codes.invalidKey, `Invalid private key provided for algorithm ${expected}.`)
|
|
}
|
|
}
|
|
|
|
function prepareKeyOrSecret(key, algorithm) {
|
|
if (typeof key === 'string') {
|
|
key = Buffer.from(key, 'utf-8')
|
|
}
|
|
|
|
// Only on Node 12 - Create a key object
|
|
/* istanbul ignore next */
|
|
if (useNewCrypto) {
|
|
key = algorithm[0] === 'H' ? createSecretKey(key) : createPrivateKey(key)
|
|
}
|
|
|
|
return key
|
|
}
|
|
|
|
function sign(
|
|
{
|
|
key,
|
|
algorithm,
|
|
noTimestamp,
|
|
mutatePayload,
|
|
clockTimestamp,
|
|
expiresIn,
|
|
notBefore,
|
|
kid,
|
|
typ,
|
|
isAsync,
|
|
additionalHeader,
|
|
fixedPayload
|
|
},
|
|
payload,
|
|
cb
|
|
) {
|
|
const [callback, promise] = isAsync ? ensurePromiseCallback(cb) : []
|
|
|
|
// Validate payload
|
|
if (typeof payload !== 'object') {
|
|
throw new TokenError(TokenError.codes.invalidType, 'The payload must be an object.')
|
|
}
|
|
|
|
if (payload.exp && (!Number.isInteger(payload.exp) || payload.exp < 0)) {
|
|
throw new TokenError(TokenError.codes.invalidClaimValue, 'The exp claim must be a positive integer.')
|
|
}
|
|
|
|
// Prepare the header
|
|
const header = {
|
|
alg: algorithm,
|
|
typ: typ || 'JWT',
|
|
kid,
|
|
...additionalHeader
|
|
}
|
|
|
|
// Prepare the payload
|
|
let encodedPayload = ''
|
|
|
|
// Add claims
|
|
const iat = payload.iat * 1000 || clockTimestamp || Date.now()
|
|
|
|
const finalPayload = {
|
|
...payload,
|
|
...fixedPayload,
|
|
iat: noTimestamp ? undefined : Math.floor(iat / 1000),
|
|
exp: payload.exp ? payload.exp : expiresIn ? Math.floor((iat + expiresIn) / 1000) : undefined,
|
|
nbf: notBefore ? Math.floor((iat + notBefore) / 1000) : undefined
|
|
}
|
|
|
|
if (mutatePayload) {
|
|
Object.assign(payload, finalPayload)
|
|
}
|
|
|
|
encodedPayload = Buffer.from(JSON.stringify(finalPayload), 'utf-8')
|
|
.toString('base64')
|
|
.replace(base64UrlMatcher, base64UrlReplacer)
|
|
|
|
// We have the key
|
|
if (!callback) {
|
|
const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8')
|
|
.toString('base64')
|
|
.replace(base64UrlMatcher, base64UrlReplacer)
|
|
|
|
const input = encodedHeader + '.' + encodedPayload
|
|
const signature = algorithm === 'none' ? '' : createSignature(algorithm, key, input)
|
|
|
|
return input + '.' + signature
|
|
}
|
|
|
|
// Get the key asynchronously
|
|
getAsyncKey(key, header, (err, currentKey) => {
|
|
if (err) {
|
|
const error = TokenError.wrap(err, TokenError.codes.keyFetchingError, 'Cannot fetch key.')
|
|
return callback(error)
|
|
}
|
|
|
|
if (typeof currentKey === 'string') {
|
|
currentKey = Buffer.from(currentKey, 'utf-8')
|
|
} else if (!(currentKey instanceof Buffer)) {
|
|
return callback(
|
|
new TokenError(
|
|
TokenError.codes.keyFetchingError,
|
|
'The key returned from the callback must be a string or a buffer containing a secret or a private key.'
|
|
)
|
|
)
|
|
}
|
|
|
|
let token
|
|
try {
|
|
// Detect the private key - If the algorithm was known, just verify they match, otherwise assign it
|
|
const availableAlgorithm = detectPrivateKeyAlgorithm(currentKey, algorithm)
|
|
|
|
if (algorithm) {
|
|
checkIsCompatibleAlgorithm(algorithm, availableAlgorithm)
|
|
} else {
|
|
header.alg = algorithm = availableAlgorithm
|
|
}
|
|
|
|
currentKey = prepareKeyOrSecret(currentKey, algorithm)
|
|
|
|
const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8')
|
|
.toString('base64')
|
|
.replace(base64UrlMatcher, base64UrlReplacer)
|
|
|
|
const input = encodedHeader + '.' + encodedPayload
|
|
token = input + '.' + createSignature(algorithm, currentKey, input)
|
|
} catch (e) {
|
|
return callback(e)
|
|
}
|
|
|
|
callback(null, token)
|
|
})
|
|
|
|
return promise
|
|
}
|
|
|
|
module.exports = function createSigner(options) {
|
|
let {
|
|
key,
|
|
algorithm,
|
|
noTimestamp,
|
|
mutatePayload,
|
|
clockTimestamp,
|
|
expiresIn,
|
|
notBefore,
|
|
jti,
|
|
aud,
|
|
iss,
|
|
sub,
|
|
nonce,
|
|
kid,
|
|
typ,
|
|
header: additionalHeader
|
|
} = { clockTimestamp: 0, ...options }
|
|
|
|
// Validate options
|
|
if (
|
|
algorithm &&
|
|
algorithm !== 'none' &&
|
|
!hsAlgorithms.includes(algorithm) &&
|
|
!esAlgorithms.includes(algorithm) &&
|
|
!rsaAlgorithms.includes(algorithm) &&
|
|
!edAlgorithms.includes(algorithm)
|
|
) {
|
|
throw new TokenError(
|
|
TokenError.codes.invalidOption,
|
|
`The algorithm option must be one of the following values: ${supportedAlgorithms}.`
|
|
)
|
|
}
|
|
|
|
const keyType = typeof key
|
|
const isKeyPasswordProtected = keyType === 'object' && key && key.key && key.passphrase
|
|
|
|
if (algorithm === 'none') {
|
|
if (key) {
|
|
throw new TokenError(
|
|
TokenError.codes.invalidOption,
|
|
'The key option must not be provided when the algorithm option is "none".'
|
|
)
|
|
}
|
|
} else if (
|
|
!key ||
|
|
(keyType !== 'string' && !(key instanceof Buffer) && keyType !== 'function' && !isKeyPasswordProtected)
|
|
) {
|
|
throw new TokenError(
|
|
TokenError.codes.invalidOption,
|
|
'The key option must be a string, a buffer, an object containing key/passphrase properties or a function returning the algorithm secret or private key.'
|
|
)
|
|
} else if (isKeyPasswordProtected && !algorithm) {
|
|
throw new TokenError(
|
|
TokenError.codes.invalidAlgorithm,
|
|
'When using password protected key you must provide the algorithm option.'
|
|
)
|
|
}
|
|
|
|
// Convert the key to a string when not a function, in order to be able to detect
|
|
if (key && keyType !== 'function') {
|
|
// Detect the private key - If the algorithm was known, just verify they match, otherwise assign it
|
|
const availableAlgorithm = detectPrivateKeyAlgorithm(isKeyPasswordProtected ? key.key : key, algorithm)
|
|
|
|
if (algorithm) {
|
|
checkIsCompatibleAlgorithm(algorithm, availableAlgorithm)
|
|
} else {
|
|
algorithm = availableAlgorithm
|
|
}
|
|
|
|
key = prepareKeyOrSecret(key, algorithm)
|
|
}
|
|
|
|
if (expiresIn && (typeof expiresIn !== 'number' || expiresIn < 0)) {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The expiresIn option must be a positive number.')
|
|
}
|
|
|
|
if (notBefore && (typeof notBefore !== 'number' || notBefore < 0)) {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The notBefore option must be a positive number.')
|
|
}
|
|
|
|
if (clockTimestamp && (typeof clockTimestamp !== 'number' || clockTimestamp < 0)) {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The clockTimestamp option must be a positive number.')
|
|
}
|
|
|
|
if (jti && typeof jti !== 'string') {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The jti option must be a string.')
|
|
}
|
|
|
|
if (aud && typeof aud !== 'string' && !Array.isArray(aud)) {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The aud option must be a string or an array of strings.')
|
|
}
|
|
|
|
if (iss && typeof iss !== 'string') {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The iss option must be a string.')
|
|
}
|
|
|
|
if (sub && typeof sub !== 'string') {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The sub option must be a string.')
|
|
}
|
|
|
|
if (nonce && typeof nonce !== 'string') {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The nonce option must be a string.')
|
|
}
|
|
|
|
if (kid && typeof kid !== 'string') {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The kid option must be a string.')
|
|
}
|
|
|
|
if (additionalHeader && typeof additionalHeader !== 'object') {
|
|
throw new TokenError(TokenError.codes.invalidOption, 'The header option must be a object.')
|
|
}
|
|
|
|
const fpo = { jti, aud, iss, sub, nonce }
|
|
const fixedPayload = Object.keys(fpo).reduce((obj, key) => {
|
|
return fpo[key] !== undefined ? Object.assign(obj, { [key]: fpo[key] }) : obj
|
|
}, {})
|
|
|
|
// Return the signer
|
|
const context = {
|
|
key,
|
|
algorithm,
|
|
noTimestamp,
|
|
mutatePayload,
|
|
clockTimestamp,
|
|
expiresIn,
|
|
notBefore,
|
|
kid,
|
|
typ,
|
|
isAsync: keyType === 'function',
|
|
additionalHeader,
|
|
fixedPayload
|
|
}
|
|
|
|
return sign.bind(null, context)
|
|
}
|