diff --git a/README.md b/README.md index b2c4101..6710511 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,26 @@ If you need to verify Auth0 issued HS256 or RS256 JWT tokens, you can use [fasti ## Options ### `secret` (required) -You must pass a `secret` to the `options` parameter. The `secret` can be a primitive type String, a function that returns a String or an object `{ private, public }`. +You must pass a `secret` to the `options` parameter. The `secret` can be a string, a buffer, a function that provides a string or buffer via a callback or Promise, or an object `{ private, public }`. In this object `{ private, public }` the `private` key is a string, buffer, or object containing either the secret for HMAC algorithms or the PEM encoded private key for RSA and ECDSA. In case of a private key with passphrase an object `{ private: { key, passphrase }, public }` can be used (based on [crypto documentation](https://nodejs.org/api/crypto.html)), in this case be sure you pass the `algorithm` inside the signing options prefixed by the `sign` key of the plugin registering options). In this object `{ private, public }` the `public` key is a string or buffer containing either the secret for HMAC algorithms, or the PEM encoded public key for RSA and ECDSA. -Function based `secret` is supported by the `request.jwtVerify()` and `reply.jwtSign()` methods and is called with `request`, `token`, and `callback` parameters. +Function based `secret` is supported by all methods (`request.jwtVerify()`, `reply.jwtSign()`, `fastify.jwt.sign()`, and `fastify.jwt.verify()`) and is called with a `context` object and a `callback`. + +Providers can call `callback(null, key)` or return a Promise resolving to the key. A provider that produces no usable key fails with `FAST_JWT_KEY_FETCHING_ERROR`. Function-valued `sign.key` and `verify.key` options, including per-call overrides, use this same contract. + +The `context` object has the following shape: +- `operation`: `'sign'` or `'verify'` +- `payload`: the JWT payload +- `header`: the JWT header (only for `'verify'`) +- `signature`: the JWT signature (only for `'verify'`) +- `request`: the Fastify request object (only available in `request.jwtVerify()` and `reply.jwtSign()`) + +During verification, the context contains decoded but unverified token data. Do not treat it as authenticated until verification succeeds. + +When the effective key is a function, `fastify.jwt.sign()` and `fastify.jwt.verify()` require a callback argument, even if the provider returns a Promise. A static `key` override allows synchronous calls even when the plugin's `secret` is a function. Request/reply methods continue to support both callbacks and Promises. #### Verify-only mode @@ -140,20 +153,21 @@ const jwt = require('@fastify/jwt') fastify.register(jwt, { secret: 'supersecret' }) // secret as a function with callback fastify.register(jwt, { - secret: function (request, token, callback) { - // do something + secret: function (context, callback) { + // context.operation is 'sign' or 'verify' + // context.payload, context.header, context.signature, context.request callback(null, 'supersecret') } }) // secret as a function returning a promise fastify.register(jwt, { - secret: function (request, token) { + secret: function (context) { return Promise.resolve('supersecret') } }) // secret as an async function fastify.register(jwt, { - secret: async function (request, token) { + secret: async function (context) { return 'supersecret' } }) @@ -797,8 +811,8 @@ const jwt = require('@fastify/jwt') const request = require('request') fastify.register(jwt, { - secret: function (request, reply, callback) { - // do something + secret: function (context, callback) { + // context.operation is 'sign' or 'verify' callback(null, 'supersecret') } }) @@ -867,9 +881,8 @@ const fastify = Fastify() const getJwks = buildGetJwks() fastify.register(fjwt, { - decode: { complete: true }, - secret: (request, token) => { - const { header: { kid, alg }, payload: { iss } } = token + secret: (context) => { + const { header: { kid, alg }, payload: { iss } } = context return getJwks.getPublicKey({ kid, domain: iss, alg }) } }) diff --git a/UPGRADING.md b/UPGRADING.md index 260c906..0106922 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,6 +1,20 @@ ## Upgrading Notes This document captures breaking changes between versions of `@fastify/jwt`. +### Upcoming major release + +Function-based secrets now use the same `(context, callback)` signature in `fastify.jwt.sign()`, `fastify.jwt.verify()`, `reply.jwtSign()`, and `request.jwtVerify()`. Promise-returning providers receive the same context. Static secrets are unchanged. + +- Replace the old `request` argument with `context.request`. It is only available in request/reply methods; instance methods have no request. +- Use `context.payload` for the payload and `context.operation` to distinguish `'sign'` from `'verify'`. +- During verification, `context.header` and `context.signature` are always available, regardless of `decode.complete`. They are absent during signing. Replace any conditional handling of the old token/header argument with these fields. +- Function-valued `sign.key` and `verify.key` overrides use the same context contract, not the native `fast-jwt` provider signature. +- Instance methods require a callback when the effective key is a function, including Promise-returning providers. A static per-call `key` override still permits synchronous calls. Request/reply methods still accept callbacks or return Promises. +- A provider that produces no usable key is no longer re-invoked under the native `fast-jwt` key fetcher contract. It now fails immediately with `FAST_JWT_KEY_FETCHING_ERROR` and runs exactly once, so an empty string or buffer that previously surfaced as a `401` from `request.jwtVerify()` is now reported as a `500`. +- In TypeScript, replace `TokenOrHeader` and native `fast-jwt.KeyFetcher` provider annotations with `SecretContext` and `SecretProvider`, respectively. Callback providers return `void`; Promise providers resolve to a string or buffer. + +See the [secret option](README.md#secret-required) for examples. Verification contexts contain unverified input and must not be treated as authenticated data. + ### Upgrading from 3.x to 4.0 In `v4` we migrated away from using `jsonwebtoken` to `fast-jwt`. This introduced the following breaking changes: diff --git a/index.js b/index.js index 486c3bb..9bcd626 100644 --- a/index.js +++ b/index.js @@ -22,9 +22,31 @@ function isString (x) { return Object.prototype.toString.call(x) === '[object String]' } -function wrapStaticSecretInCallback (secret) { - return function (_request, _payload, cb) { - return cb(null, secret) +function resolveSecret (secretValue, context, callback) { + if (typeof secretValue !== 'function') { + return callback(null, secretValue) + } + + let called = false + function once (err, val) { + if (called) return + called = true + // A function would otherwise reach fast-jwt as a native key fetcher, which uses a different contract + if (!err && (!val || typeof val === 'function' || val.length === 0)) { + err = new TokenError(TokenError.codes.keyFetchingError, 'The secret provider did not return a usable key.') + } + callback(err, val) + } + + try { + const result = secretValue(context, once) + + if (result && typeof result.then === 'function') { + result.then(secret => once(null, secret), once) + } + } catch (error) { + if (called) throw error + once(error) } } @@ -121,17 +143,6 @@ function fastifyJwt (fastify, options, next) { 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) @@ -192,20 +203,27 @@ function fastifyJwt (fastify, options, next) { fastify.decorateReply(jwtSignName, replySign) const signerConfig = checkAndMergeSignOptions() - // no signer when configured in verify-mode - const signer = signerConfig.options.key + // no signer when configured in verify-mode or when secret is a function (resolved per-call) + const signer = (signerConfig.options.key && typeof signerConfig.options.key !== 'function') ? createSigner(signerConfig.options) : null const decoder = createDecoder(decodeOptions) + const completeDecodeOptions = Object.assign({}, decodeOptions, { complete: true }) + const completeDecoder = createDecoder(completeDecodeOptions) + // instance verify only decodes to build the secret context, so decode options must not add checks here + const verifyDecoder = createDecoder({ complete: true }) const verifierConfig = checkAndMergeVerifyOptions() - const verifier = createVerifier(verifierConfig.options) + // no global verifier when secret is a function (resolved per-call) + const verifier = (verifierConfig.options.key && typeof verifierConfig.options.key !== 'function') + ? createVerifier(verifierConfig.options) + : null 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 + if (useGlobalOptions && verifier) 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) @@ -221,9 +239,12 @@ function fastifyJwt (fastify, options, next) { function decode (token, options) { assert(token, 'missing token') - let selectedDecoder = decoder - - if (options && options !== decodeOptions && typeof options !== 'function') { + let selectedDecoder + if (!options || options === decodeOptions || typeof options === 'function') { + selectedDecoder = decoder + } else if (options === completeDecodeOptions) { + selectedDecoder = completeDecoder + } else { selectedDecoder = createDecoder(options) } @@ -289,21 +310,23 @@ function fastifyJwt (fastify, options, next) { return token } - function mergeOptionsWithKey (options, useProvidedPrivateKey) { - if (useProvidedPrivateKey && (typeof useProvidedPrivateKey !== 'boolean')) { - return Object.assign({}, options, { key: options.key ?? useProvidedPrivateKey }) - } else { - const key = useProvidedPrivateKey ? secretOrPrivateKey : secretOrPublicKey - return Object.assign(!options.key ? { key } : {}, options) - } + function withStaticKey (options, usePrivateKey) { + if (options.key) return Object.assign({}, options) + const key = usePrivateKey ? secretOrPrivateKey : secretOrPublicKey + if (!key) return Object.assign({}, options) + return Object.assign({}, options, { key }) + } + + function withResolvedKey (options, key) { + return Object.assign({}, options, { key }) } function checkAndMergeOptions (options, defaultOptions, usePrivateKey, callback) { if (typeof options === 'function') { - return { options: mergeOptionsWithKey(defaultOptions, usePrivateKey), callback: options } + return { options: withStaticKey(defaultOptions, usePrivateKey), callback: options } } - return { options: mergeOptionsWithKey(options || defaultOptions, usePrivateKey), callback } + return { options: withStaticKey(options || defaultOptions, usePrivateKey), callback } } function checkAndMergeSignOptions (options, callback) { @@ -316,50 +339,103 @@ function fastifyJwt (fastify, options, next) { 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 + assert(secretOrPrivateKey, 'unable to sign: secret is configured in verify mode') const localOptions = convertTemporalProps(options) const signerConfig = checkAndMergeSignOptions(localOptions, callback) - if (options && typeof options !== 'function') { - localSigner = createSigner(signerConfig.options) + if (typeof signerConfig.callback !== 'function') { + assert(typeof signerConfig.options.key !== 'function', 'callback is required when secret is a function') + let localSigner = signer + if (options && typeof options !== 'function') { + localSigner = createSigner(signerConfig.options) + } + return localSigner(payload) } - if (typeof signerConfig.callback === 'function') { - const token = localSigner(payload) - signerConfig.callback(null, token) - } else { - return localSigner(payload) + const cb = signerConfig.callback + + // Fast-path: reuse global signer when no custom options were passed + if (signer && (!options || typeof options === 'function')) { + let token + try { + token = signer(payload) + } catch (error) { + return cb(error) + } + return cb(null, token) } + + const context = { operation: 'sign', payload } + resolveSecret(signerConfig.options.key, context, function (err, secret) { + if (err) return cb(err) + let token + try { + const resolvedOptions = withResolvedKey(signerConfig.options, secret) + const localSigner = createSigner(resolvedOptions) + token = localSigner(payload) + } catch (error) { + return cb(error) + } + cb(null, token) + }) } 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') { + assert(typeof verifierConfig.options.key !== 'function', 'callback is required when secret is a function') + let localVerifier = verifier + if (options && typeof options !== 'function') { + localVerifier = getVerifier(verifierConfig.options) + } + return localVerifier(token) } - if (typeof verifierConfig.callback === 'function') { - const result = localVerifier(token) - verifierConfig.callback(null, result) - } else { - return localVerifier(token) + const cb = verifierConfig.callback + + // Fast-path: reuse global verifier when no custom options were passed + const useGlobalVerifier = verifier && (!options || typeof options === 'function') + if (useGlobalVerifier || typeof verifierConfig.options.key !== 'function') { + let result + try { + const localVerifier = useGlobalVerifier ? verifier : getVerifier(verifierConfig.options) + result = localVerifier(token) + } catch (error) { + return cb(error) + } + return cb(null, result) } + + let decoded + try { + decoded = verifyDecoder(token) + } catch (error) { + return cb(error) + } + const context = { operation: 'verify', header: decoded.header, payload: decoded.payload, signature: decoded.signature } + resolveSecret(verifierConfig.options.key, context, function (err, secret) { + if (err) return cb(err) + let result + try { + const resolvedOptions = withResolvedKey(verifierConfig.options, secret) + const localVerifier = getVerifier(resolvedOptions) + result = localVerifier(token) + } catch (error) { + return cb(error) + } + cb(null, result) + }) } 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') + // sign mode is not supported when only a public key is provided + assert(secretOrPrivateKey, 'unable to sign: secret is configured in verify mode') let useLocalSigner = true if (typeof options === 'function') { @@ -387,34 +463,34 @@ function fastifyJwt (fastify, options, next) { const localSignOptions = convertTemporalProps(options.sign) // New supported contract, options supports sign and can expand options = { - sign: Object.assign({}, signOptions, localSignOptions) + sign: withStaticKey(Object.assign({}, signOptions, localSignOptions), true) } } else { const localOptions = convertTemporalProps(options) // Original contract, options supports only sign - options = Object.assign({}, signOptions, localOptions) + options = withStaticKey(Object.assign({}, signOptions, localOptions), true) } if (!payload) { return next(new Error('jwtSign requires a payload')) } + const replySignOptions = options.sign || options + 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) - } + const context = { operation: 'sign', payload, request: reply.request } + resolveSecret(replySignOptions.key, context, callback) }, function sign (secretOrPrivateKey, callback) { if (useLocalSigner) { - const signerOptions = mergeOptionsWithKey(options.sign || options, secretOrPrivateKey) + const signerOptions = withResolvedKey(replySignOptions, secretOrPrivateKey) const localSigner = createSigner(signerOptions) const token = localSigner(payload) callback(null, token) } else { - const token = signer(payload) + const localSigner = signer || createSigner(withResolvedKey(signerConfig.options, secretOrPrivateKey)) + const token = localSigner(payload) callback(null, token) } } @@ -481,44 +557,47 @@ function fastifyJwt (fastify, options, next) { 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) + decode: options.decode ? Object.assign({}, decodeOptions, options.decode, { complete: true }) : completeDecodeOptions, + verify: withStaticKey(Object.assign({}, verifyOptions, localVerifyOptions), false) } } else { const localOptions = convertTemporalProps(options, true) // Original contract, options supports only verify - options = Object.assign({}, verifyOptions, localOptions) + options = withStaticKey(Object.assign({}, verifyOptions, localOptions), false) } let token - let decodedToken + let completeDecode try { token = lookupToken(request, options.verify || options) - decodedToken = decode(token, options.decode || decodeOptions) + completeDecode = decode(token, options.decode || completeDecodeOptions) } catch (err) { return next(err) } + const requestVerifyOptions = options.verify || options + steed.waterfall([ function getSecret (callback) { - const verifyResult = secretCallbackVerify(request, decodedToken, callback) - if (verifyResult && typeof verifyResult.then === 'function') { - verifyResult.then(result => callback(null, result), callback) + const context = { + operation: 'verify', + header: completeDecode.header, + payload: completeDecode.payload, + signature: completeDecode.signature, + request } + resolveSecret(requestVerifyOptions.key, context, callback) }, function verify (secretOrPublicKey, callback) { + let verifyResult try { - const verifierOptions = mergeOptionsWithKey(options.verify || options, secretOrPublicKey) + const verifierOptions = withResolvedKey(requestVerifyOptions, 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) - } + verifyResult = localVerifier(token) } catch (error) { return wrapError(error, callback) } + callback(null, verifyResult) }, function checkIfIsTrusted (result, callback) { if (!trusted) { diff --git a/test/jwt.test.js b/test/jwt.test.js index fa79532..e370985 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -2,7 +2,7 @@ const { test } = require('node:test') const Fastify = require('fastify') -const { createSigner } = require('fast-jwt') +const { createSigner, TokenError } = require('fast-jwt') const jwt = require('..') const defaultExport = require('..').default const { fastifyJwt: namedExport } = require('..') @@ -90,7 +90,7 @@ test('register', async function (t) { await t.test('secret as a function with a callback returning a Buffer', async function (t) { const fastify = Fastify() await fastify.register(jwt, { - secret: (_request, _token, callback) => { callback(null, Buffer.from('some secret', 'base64')) } + secret: (_context, callback) => { callback(null, Buffer.from('some secret', 'base64')) } }).ready() }) @@ -260,7 +260,7 @@ test('register', async function (t) { } await t.test('secret as a function with callback', t => { - return runWithSecret(t, function (_request, _token, callback) { + return runWithSecret(t, function (_context, callback) { callback(null, 'some-secret') }) }) @@ -278,7 +278,7 @@ test('register', async function (t) { }) await t.test('secret as a function with callback returning a Buffer', t => { - return runWithSecret(t, function (_request, _token, callback) { + return runWithSecret(t, function (_context, callback) { callback(null, Buffer.from('some-secret', 'base64')) }) }) @@ -308,116 +308,1102 @@ test('sign and verify with HS-secret', async function (t) { t.plan(2) await t.test('server methods', async function (t) { + t.plan(3) + + const fastify = Fastify() + fastify.register(jwt, { secret: 'test' }) + + await fastify.ready() + + await t.test('synchronous', function (t) { + t.plan(1) + + const token = fastify.jwt.sign({ foo: 'bar' }) + const decoded = fastify.jwt.verify(token) + + t.assert.strictEqual(decoded.foo, 'bar') + }) + + await t.test('with callbacks', function (t) { + t.plan(3) + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, function (error, token) { + t.assert.ifError(error) + + fastify.jwt.verify(token, function (error, decoded) { + t.assert.ifError(error) + t.assert.strictEqual(decoded.foo, 'bar') + resolve() + }) + }) + return promise + }) + + await t.test('with callbacks and invalid token', function (t) { + t.plan(1) + + const { promise, resolve } = helper.withResolvers() + + const { createSigner: createLocalSigner } = require('fast-jwt') + const wrongSigner = createLocalSigner({ key: 'wrong-secret' }) + const invalidToken = wrongSigner({ foo: 'bar' }) + + fastify.jwt.verify(invalidToken, function (error) { + t.assert.ok(error) + resolve() + }) + return promise + }) + }) + + await t.test('route methods', async function (t) { t.plan(2) const fastify = Fastify() - fastify.register(jwt, { secret: 'test' }) + fastify.register(jwt, { secret: 'test' }) + + fastify.post('/signSync', function (request, reply) { + return reply.jwtSign(request.body).then(function (token) { + return { token } + }) + }) + + fastify.get('/verifySync', function (request) { + return request.jwtVerify() + }) + + fastify.post('/signAsync', function (request, reply) { + reply.jwtSign(request.body, function (error, token) { + return reply.send(error || { token }) + }) + }) + + fastify.get('/verifyAsync', function (request, reply) { + request.jwtVerify(function (error, decodedToken) { + return reply.send(error || decodedToken) + }) + }) + + await fastify.ready() + + await t.test('synchronous', async function (t) { + t.plan(2) + + const signResponse = await fastify.inject({ + method: 'post', + url: '/signSync', + payload: { foo: 'bar' } + }) + + const token = JSON.parse(signResponse.payload).token + t.assert.ok(token) + + const verifyResponse = await fastify.inject({ + method: 'get', + url: '/verifySync', + headers: { + authorization: `Bearer ${token}` + } + }) + + const decodedToken = JSON.parse(verifyResponse.payload) + t.assert.strictEqual(decodedToken.foo, 'bar') + }) + + await t.test('with callbacks', async function (t) { + t.plan(2) + + const signResponse = await fastify.inject({ + method: 'post', + url: '/signAsync', + payload: { foo: 'bar' } + }) + + const token = JSON.parse(signResponse.payload).token + t.assert.ok(token) + + const verifyResponse = await fastify.inject({ + method: 'get', + url: '/verifyAsync', + headers: { + authorization: `Bearer ${token}` + } + }) + const decodedToken = JSON.parse(verifyResponse.payload) + t.assert.strictEqual(decodedToken.foo, 'bar') + }) + }) +}) + +test('instance verify delivers decode errors through callbacks', async function (t) { + const invalidTokens = [ + { token: 'not-a-jwt-token', code: TokenError.codes.malformed }, + { token: createSigner({ key: 'test', header: { typ: 'OTHER' } })({ foo: 'bar' }), code: TokenError.codes.invalidType } + ] + + for (const mode of ['static', 'static with options', 'function secret', 'function key']) { + for (const { token, code } of invalidTokens) { + await t.test(`${mode}: ${code}`, async function (t) { + let secretCalls = 0 + const secret = function (_context, callback) { + secretCalls++ + callback(null, 'test') + } + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { + secret: mode === 'function secret' ? secret : 'test', + decode: { checkTyp: 'JWT' }, + verify: { checkTyp: 'JWT' } + }) + await fastify.ready() + + let callbackCalls = 0 + let receivedError + const callback = function (error) { + callbackCalls++ + receivedError = error + } + + t.assert.doesNotThrow(function () { + if (mode === 'function key') { + fastify.jwt.verify(token, { key: secret, checkTyp: 'JWT' }, callback) + } else if (mode === 'static with options') { + fastify.jwt.verify(token, { checkTyp: 'JWT' }, callback) + } else { + fastify.jwt.verify(token, callback) + } + }) + t.assert.strictEqual(callbackCalls, 1) + t.assert.ok(receivedError instanceof TokenError) + t.assert.strictEqual(receivedError.code, code) + t.assert.strictEqual(receivedError.statusCode, undefined) + t.assert.strictEqual(secretCalls, code === TokenError.codes.invalidType && mode.startsWith('function') ? 1 : 0) + }) + } + } +}) + +test('instance verify with static key overrides decodes only once', async function (testContext) { + const token = createSigner({ key: 'test' })({ foo: 'bar' }) + + for (const dynamicSecret of [false, true]) { + for (const key of ['test', Buffer.from('test')]) { + await testContext.test(`${dynamicSecret ? 'function' : 'static'} secret, ${typeof key} key`, async function (testContext) { + const fastify = Fastify() + testContext.after(() => fastify.close()) + fastify.register(jwt, { + secret: dynamicSecret ? function () { throw new Error('overridden provider must not run') } : 'test' + }) + await fastify.ready() + + const options = { key } + const parse = testContext.mock.method(JSON, 'parse') + const expected = fastify.jwt.verify(token, options) + const syncParseCalls = parse.mock.callCount() + parse.mock.resetCalls() + + const callback = testContext.mock.fn() + fastify.jwt.verify(token, options, callback) + + testContext.assert.strictEqual(parse.mock.callCount(), syncParseCalls) + testContext.assert.strictEqual(callback.mock.callCount(), 1) + testContext.assert.deepStrictEqual(callback.mock.calls[0].arguments, [null, expected]) + }) + } + } +}) + +test('instance verify uses verify type checks independently of decode options', async function (testContext) { + const token = createSigner({ key: 'test', header: { typ: 'OTHER' } })({ foo: 'bar' }) + const provider = function (_context, callback) { callback(null, 'test') } + + for (const secret of ['test', provider]) { + for (const checkTyp of [undefined, 'OTHER', 'JWT']) { + await testContext.test(`${typeof secret} secret, verify.checkTyp: ${checkTyp}`, async function (testContext) { + const fastify = Fastify() + testContext.after(() => fastify.close()) + fastify.register(jwt, { secret, decode: { checkTyp: 'JWT' }, verify: { checkTyp } }) + await fastify.ready() + + for (const options of [undefined, { checkTyp }, { key: 'test', checkTyp }, { key: provider, checkTyp }]) { + const callback = testContext.mock.fn() + fastify.jwt.verify(token, options, callback) + + testContext.assert.strictEqual(callback.mock.callCount(), 1) + const [error, result] = callback.mock.calls[0].arguments + if (checkTyp === 'JWT') { + testContext.assert.ok(error instanceof TokenError) + testContext.assert.strictEqual(error.code, TokenError.codes.invalidType) + testContext.assert.strictEqual(result, undefined) + } else { + testContext.assert.ifError(error) + testContext.assert.strictEqual(result.foo, 'bar') + } + + if (typeof (options?.key || secret) !== 'function') { + if (checkTyp === 'JWT') { + testContext.assert.throws(() => fastify.jwt.verify(token, options), { code: TokenError.codes.invalidType }) + } else { + testContext.assert.deepStrictEqual(fastify.jwt.verify(token, options), result) + } + } + } + }) + } + } +}) + +test('instance methods handle secret provider completion', async function (t) { + const token = createSigner({ key: 'test' })({ foo: 'bar' }) + + for (const operation of ['sign', 'verify']) { + const input = operation === 'sign' ? { foo: 'bar' } : token + + for (const source of ['secret', 'key']) { + await t.test(`${operation}: throwing ${source}`, async function (t) { + const expectedError = new Error('secret fetch failed') + const secret = function () { throw expectedError } + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { secret: source === 'secret' ? secret : 'test' }) + await fastify.ready() + + const callback = t.mock.fn() + t.assert.doesNotThrow(function () { + if (source === 'key') { + fastify.jwt[operation](input, { key: secret }, callback) + } else { + fastify.jwt[operation](input, callback) + } + }) + t.assert.strictEqual(callback.mock.callCount(), 1) + t.assert.strictEqual(callback.mock.calls[0].arguments[0], expectedError) + }) + } + + await t.test(`${operation}: provider throws after completion`, async function (t) { + const expectedError = new Error('provider threw after callback') + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { + secret: function (_context, callback) { + callback(null, 'test') + throw expectedError + } + }) + await fastify.ready() + + const callback = t.mock.fn() + t.assert.throws(() => fastify.jwt[operation](input, callback), error => error === expectedError) + t.assert.strictEqual(callback.mock.callCount(), 1) + t.assert.ifError(callback.mock.calls[0].arguments[0]) + t.assert.ok(callback.mock.calls[0].arguments[1]) + }) + + for (const reject of [false, true]) { + await t.test(`${operation}: callback followed by Promise ${reject ? 'rejection' : 'resolution'}`, async function (t) { + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { + secret: async function (_context, callback) { + callback(null, 'test') + if (reject) throw new Error('late rejection') + return 'other-secret' + } + }) + await fastify.ready() + + const callback = t.mock.fn() + fastify.jwt[operation](input, callback) + await new Promise(resolve => setImmediate(resolve)) + t.assert.strictEqual(callback.mock.callCount(), 1) + t.assert.ifError(callback.mock.calls[0].arguments[0]) + t.assert.ok(callback.mock.calls[0].arguments[1]) + }) + } + } +}) + +test('secret providers reject invalid keys without being invoked again', async function (t) { + const token = createSigner({ key: 'test' })({ foo: 'bar' }) + const cases = [] + for (const value of [undefined, null, '', Buffer.alloc(0), function () {}]) { + cases.push({ name: `callback: ${String(value)}`, provider: (_context, callback) => callback(null, value) }) + cases.push({ name: `Promise: ${String(value)}`, provider: async () => value }) + } + // eslint-disable-next-line prefer-promise-reject-errors + cases.push({ name: 'rejection without a reason', provider: () => Promise.reject() }) + + for (const operation of ['sign', 'verify']) { + for (const source of ['secret', 'key']) { + for (const route of [false, true]) { + for (const scenario of cases) { + await t.test(`${route ? 'route' : 'instance'} ${operation}, ${source}, ${scenario.name}`, async function (t) { + const provider = t.mock.fn(scenario.provider) + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { secret: source === 'secret' ? provider : 'test' }) + const options = source === 'key' ? { key: provider } : undefined + const input = operation === 'sign' ? { foo: 'bar' } : token + let receivedError + let result + let callbackCalls = 0 + + if (route) { + fastify.get('/', async function (request, reply) { + try { + result = operation === 'sign' ? await reply.jwtSign(input, options) : await request.jwtVerify(options) + } catch (error) { + receivedError = error + throw error + } + return { handled: true } + }) + const response = await fastify.inject({ url: '/', headers: { authorization: `Bearer ${token}` } }) + t.assert.strictEqual(response.statusCode, 500) + t.assert.strictEqual(response.json().code, TokenError.codes.keyFetchingError) + } else { + await fastify.ready() + await new Promise(function (resolve) { + fastify.jwt[operation](input, options, function (error, value) { + callbackCalls++ + receivedError = error + result = value + resolve() + }) + }) + t.assert.strictEqual(callbackCalls, 1) + } + + t.assert.ok(receivedError instanceof TokenError) + t.assert.strictEqual(receivedError.code, TokenError.codes.keyFetchingError) + t.assert.strictEqual(result, undefined) + t.assert.strictEqual(provider.mock.callCount(), 1) + }) + } + } + } + } +}) + +test('secret providers may return passphrase protected keys', async function (t) { + for (const route of [false, true]) { + await t.test(route ? 'route methods' : 'instance methods', async function (t) { + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { + secret: { + private: (_context, callback) => callback(null, { key: privateKeyProtected, passphrase }), + public: publicKeyProtected + }, + sign: { algorithm: 'RS256' } + }) + fastify.post('/sign', (request, reply) => reply.jwtSign(request.body)) + await fastify.ready() + + let token + if (route) { + const response = await fastify.inject({ method: 'POST', url: '/sign', payload: { foo: 'bar' } }) + t.assert.strictEqual(response.statusCode, 200) + token = response.body + } else { + token = await new Promise(function (resolve, reject) { + fastify.jwt.sign({ foo: 'bar' }, (error, value) => error ? reject(error) : resolve(value)) + }) + } + + t.assert.strictEqual(fastify.jwt.verify(token).foo, 'bar') + }) + } +}) + +test('instance methods do not catch consumer callback exceptions', async function (t) { + const token = createSigner({ key: 'test' })({ foo: 'bar' }) + const invalidToken = createSigner({ key: 'wrong-secret' })({ foo: 'bar' }) + + for (const operation of ['sign', 'verify']) { + for (const mode of ['static', 'static with options', 'function secret', 'function key']) { + for (const fail of [false, true]) { + await t.test(`${operation}: ${mode}, ${fail ? 'error' : 'success'} callback`, async function (t) { + const expectedError = new Error('consumer callback failed') + const secret = function (_context, callback) { callback(null, 'test') } + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { secret: mode === 'function secret' ? secret : 'test' }) + await fastify.ready() + + const input = operation === 'sign' + ? (fail ? { exp: 'invalid' } : { foo: 'bar' }) + : (fail ? invalidToken : token) + const callback = t.mock.fn(function () { throw expectedError }) + + t.assert.throws(function () { + if (mode === 'function key') { + fastify.jwt[operation](input, { key: secret }, callback) + } else if (mode === 'static with options') { + fastify.jwt[operation](input, {}, callback) + } else { + fastify.jwt[operation](input, callback) + } + }, error => error === expectedError) + t.assert.strictEqual(callback.mock.callCount(), 1) + const [error, result] = callback.mock.calls[0].arguments + if (fail) { + t.assert.ok(error instanceof TokenError) + t.assert.strictEqual(result, undefined) + } else { + t.assert.ifError(error) + t.assert.ok(result) + } + }) + } + } + } +}) + +test('route methods deliver synchronous secret provider errors', async function (t) { + const token = createSigner({ key: 'test' })({ foo: 'bar' }) + + for (const operation of ['sign', 'verify']) { + for (const source of ['secret', 'key']) { + for (const useCallback of [false, true]) { + await t.test(`${operation}: ${source}, ${useCallback ? 'callback' : 'Promise'}`, async function (t) { + const expectedError = new Error('secret fetch failed') + const secret = function () { throw expectedError } + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { secret: source === 'secret' ? secret : 'test' }) + const options = source === 'key' ? { [operation]: { key: secret } } : undefined + const receivedErrors = [] + + fastify.get('/', async function (request, reply) { + const invoke = operation === 'sign' + ? callback => reply.jwtSign({ foo: 'bar' }, options, callback) + : callback => request.jwtVerify(options, callback) + if (useCallback) { + return new Promise(function (resolve) { + invoke(function (error) { + receivedErrors.push(error) + resolve({ handled: true }) + }) + }) + } + try { + await invoke() + } catch (error) { + receivedErrors.push(error) + } + return { handled: true } + }) + + const response = await fastify.inject({ + url: '/', + headers: { authorization: `Bearer ${token}` } + }) + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(receivedErrors.length, 1) + t.assert.strictEqual(receivedErrors[0], expectedError) + }) + } + } + } +}) + +test('route methods surface provider throws after callback completion', async function (t) { + const token = createSigner({ key: 'test' })({ foo: 'bar' }) + for (const operation of ['sign', 'verify']) { + await t.test(operation, async function (t) { + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { + secret: function (_context, callback) { + callback(null, 'test') + throw new Error('provider threw after callback') + } + }) + const callback = t.mock.fn() + fastify.get('/', function (request, reply) { + if (operation === 'sign') { + reply.jwtSign({ foo: 'bar' }, callback) + } else { + request.jwtVerify({}, callback) + } + return { handled: true } + }) + + const response = await fastify.inject({ url: '/', headers: { authorization: `Bearer ${token}` } }) + t.assert.strictEqual(response.statusCode, 500) + t.assert.strictEqual(response.json().message, 'provider threw after callback') + t.assert.strictEqual(callback.mock.callCount(), 1) + t.assert.ifError(callback.mock.calls[0].arguments[0]) + t.assert.ok(callback.mock.calls[0].arguments[1]) + }) + } +}) + +test('sign and verify with function secret (server methods)', async function (t) { + await t.test('with callback secret', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: function (context, cb) { + cb(null, 'test-secret') + } + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, function (error, token) { + t.assert.ifError(error) + t.assert.ok(token) + + fastify.jwt.verify(token, function (error, decoded) { + t.assert.ifError(error) + t.assert.strictEqual(decoded.foo, 'bar') + resolve() + }) + }) + return promise + }) + + await t.test('with async secret', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: async function () { + return 'test-secret' + } + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, function (error, token) { + t.assert.ifError(error) + t.assert.ok(token) + + fastify.jwt.verify(token, function (error, decoded) { + t.assert.ifError(error) + t.assert.strictEqual(decoded.foo, 'bar') + resolve() + }) + }) + return promise + }) + + await t.test('sign context has operation and payload', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: function (context, cb) { + t.assert.strictEqual(context.operation, 'sign') + t.assert.deepStrictEqual(context.payload, { foo: 'bar' }) + t.assert.strictEqual(context.request, undefined) + t.assert.strictEqual(context.header, undefined) + t.assert.strictEqual(context.signature, undefined) + cb(null, 'test-secret') + } + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, function (error, token) { + t.assert.ifError(error) + t.assert.ok(token) + resolve() + }) + return promise + }) + + await t.test('verify context has operation and full decoded token', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: function (context, cb) { + if (context.operation === 'sign') { + return cb(null, 'test-secret') + } + t.assert.strictEqual(context.operation, 'verify') + t.assert.ok(context.header) + t.assert.strictEqual(context.payload.foo, 'bar') + t.assert.strictEqual(typeof context.payload.iat, 'number') + t.assert.ok(context.signature) + t.assert.strictEqual(context.request, undefined) + cb(null, 'test-secret') + } + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, function (error, token) { + t.assert.ifError(error) + + fastify.jwt.verify(token, function (error, decoded) { + t.assert.ifError(error) + t.assert.strictEqual(decoded.foo, 'bar') + resolve() + }) + }) + return promise + }) + + await t.test('replySign context shape', async function (t) { + const fastify = Fastify() + t.after(() => fastify.close()) + let signContext = null + let routeRequest + fastify.register(jwt, { + secret: function (context, cb) { + signContext = context + cb(null, 'test-secret') + } + }) + + fastify.post('/sign', async function (request, reply) { + routeRequest = request + const token = await reply.jwtSign(request.body) + return { token } + }) + + await fastify.ready() + + const response = await fastify.inject({ + method: 'post', + url: '/sign', + payload: { foo: 'bar' } + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.ok(response.json().token) + t.assert.ok(signContext) + t.assert.strictEqual(signContext.operation, 'sign') + t.assert.deepStrictEqual(signContext.payload, { foo: 'bar' }) + t.assert.strictEqual(signContext.request, routeRequest) + t.assert.strictEqual(signContext.request.method, 'POST') + t.assert.strictEqual(signContext.header, undefined) + t.assert.strictEqual(signContext.signature, undefined) + }) + + await t.test('requestVerify context shape', async function (t) { + const fastify = Fastify() + t.after(() => fastify.close()) + let verifyContext = null + let routeRequest + fastify.register(jwt, { + secret: function (context, cb) { + verifyContext = context + cb(null, 'test-secret') + } + }) + + fastify.get('/verify', function (request) { + routeRequest = request + return request.jwtVerify() + }) + + await fastify.ready() + + const token = createSigner({ key: 'test-secret' })({ foo: 'bar' }) + const response = await fastify.inject({ + method: 'get', + url: '/verify', + headers: { authorization: `Bearer ${token}` } + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(response.json().foo, 'bar') + t.assert.ok(verifyContext) + t.assert.strictEqual(verifyContext.operation, 'verify') + t.assert.strictEqual(verifyContext.payload.foo, 'bar') + t.assert.strictEqual(typeof verifyContext.payload.iat, 'number') + t.assert.strictEqual(verifyContext.header.alg, 'HS256') + t.assert.strictEqual(verifyContext.header.typ, 'JWT') + t.assert.strictEqual(verifyContext.signature, token.split('.')[2]) + t.assert.strictEqual(verifyContext.request, routeRequest) + t.assert.strictEqual(verifyContext.request.method, 'GET') + }) + + await t.test('replySign with callback and function secret', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: function (context, cb) { + cb(null, 'test-secret') + } + }) + + fastify.post('/sign', function (request, reply) { + reply.jwtSign(request.body, function (error, token) { + return reply.send(error || { token }) + }) + }) + + await fastify.ready() + + const response = await fastify.inject({ + method: 'post', + url: '/sign', + payload: { foo: 'bar' } + }) + + const result = JSON.parse(response.payload) + t.assert.ok(result.token) + + const decoded = fastify.jwt.decode(result.token) + t.assert.strictEqual(decoded.foo, 'bar') + }) + + await t.test('sign requires callback when secret is a function', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: async function () { return 'test-secret' } + }) + + await fastify.ready() + + t.assert.throws(function () { + fastify.jwt.sign({ foo: 'bar' }) + }, { message: 'callback is required when secret is a function' }) + }) + + await t.test('verify requires callback when secret is a function', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: async function () { return 'test-secret' } + }) + + await fastify.ready() + + t.assert.throws(function () { + fastify.jwt.verify('some-token') + }, { message: 'callback is required when secret is a function' }) + }) + + await t.test('sign propagates errors from secret function', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: function (_context, cb) { + cb(new Error('secret fetch failed')) + } + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, function (error) { + t.assert.ok(error) + t.assert.strictEqual(error.message, 'secret fetch failed') + resolve() + }) + return promise + }) + + await t.test('verify propagates errors from secret function', async function (t) { + const { createSigner: createLocalSigner } = require('fast-jwt') + const fastify = Fastify() + fastify.register(jwt, { + secret: function (context, cb) { + if (context.operation === 'sign') { + return cb(null, 'test-secret') + } + cb(new Error('secret fetch failed')) + } + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + const signer = createLocalSigner({ key: 'test-secret' }) + const token = signer({ foo: 'bar' }) + + fastify.jwt.verify(token, function (error) { + t.assert.ok(error) + t.assert.strictEqual(error.message, 'secret fetch failed') + resolve() + }) + return promise + }) + + await t.test('sign with per-call static key override when global secret is a function', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: async function () { return 'global-secret' } + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, { key: 'override-secret' }, function (error, token) { + t.assert.ifError(error) + t.assert.ok(token) + + const { createVerifier } = require('fast-jwt') + const localVerifier = createVerifier({ key: 'override-secret' }) + const result = localVerifier(token) + t.assert.strictEqual(result.foo, 'bar') + resolve() + }) + return promise + }) + + await t.test('verify with per-call static key override when global secret is a function', async function (t) { + const { createSigner: createLocalSigner } = require('fast-jwt') + const fastify = Fastify() + fastify.register(jwt, { + secret: async function () { return 'global-secret' } + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + const signer = createLocalSigner({ key: 'override-secret' }) + const token = signer({ foo: 'bar' }) + + fastify.jwt.verify(token, { key: 'override-secret' }, function (error, result) { + t.assert.ifError(error) + t.assert.ok(result) + t.assert.strictEqual(result.foo, 'bar') + resolve() + }) + return promise + }) + + await t.test('sign with per-call function key override', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'global-secret' + }) await fastify.ready() - await t.test('synchronous', function (t) { - t.plan(1) + const { promise, resolve } = helper.withResolvers() - const token = fastify.jwt.sign({ foo: 'bar' }) - const decoded = fastify.jwt.verify(token) + const keyFn = function (_context, cb) { cb(null, 'function-secret') } - t.assert.strictEqual(decoded.foo, 'bar') + fastify.jwt.sign({ foo: 'bar' }, { key: keyFn }, function (error, token) { + t.assert.ifError(error) + t.assert.ok(token) + + const { createVerifier } = require('fast-jwt') + const localVerifier = createVerifier({ key: 'function-secret' }) + const result = localVerifier(token) + t.assert.strictEqual(result.foo, 'bar') + resolve() }) + return promise + }) - await t.test('with callbacks', function (t) { - t.plan(3) + await t.test('verify with per-call function key override', async function (t) { + const { createSigner: createLocalSigner } = require('fast-jwt') + const fastify = Fastify() + fastify.register(jwt, { + secret: 'global-secret' + }) - const { promise, resolve } = helper.withResolvers() + await fastify.ready() - fastify.jwt.sign({ foo: 'bar' }, function (error, token) { - t.assert.ifError(error) + const { promise, resolve } = helper.withResolvers() - fastify.jwt.verify(token, function (error, decoded) { - t.assert.ifError(error) - t.assert.strictEqual(decoded.foo, 'bar') - resolve() - }) - }) - return promise + const signer = createLocalSigner({ key: 'function-secret' }) + const token = signer({ foo: 'bar' }) + + const keyFn = function (_context, cb) { cb(null, 'function-secret') } + + fastify.jwt.verify(token, { key: keyFn }, function (error, result) { + t.assert.ifError(error) + t.assert.ok(result) + t.assert.strictEqual(result.foo, 'bar') + resolve() }) + return promise }) - await t.test('route methods', async function (t) { - t.plan(2) + await t.test('sign sync with per-call static key when global secret is a function', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: async function () { return 'global-secret' } + }) + + await fastify.ready() + + const token = fastify.jwt.sign({ foo: 'bar' }, { key: 'override-secret' }) + t.assert.ok(token) + + const { createVerifier } = require('fast-jwt') + const localVerifier = createVerifier({ key: 'override-secret' }) + const result = localVerifier(token) + t.assert.strictEqual(result.foo, 'bar') + }) + await t.test('verify sync with per-call static key when global secret is a function', async function (t) { + const { createSigner: createLocalSigner } = require('fast-jwt') const fastify = Fastify() - fastify.register(jwt, { secret: 'test' }) + fastify.register(jwt, { + secret: async function () { return 'global-secret' } + }) - fastify.post('/signSync', function (request, reply) { - return reply.jwtSign(request.body).then(function (token) { - return { token } - }) + await fastify.ready() + + const signer = createLocalSigner({ key: 'override-secret' }) + const token = signer({ foo: 'bar' }) + + const result = fastify.jwt.verify(token, { key: 'override-secret' }) + t.assert.ok(result) + t.assert.strictEqual(result.foo, 'bar') + }) + + await t.test('sign with async function key that also calls callback does not double-invoke', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'global-secret' }) - fastify.get('/verifySync', function (request) { - return request.jwtVerify() + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + let callbackCalls = 0 + + // An async function that also calls the callback — only one should win + const keyFn = async function (_context, cb) { + cb(null, 'function-secret') + return 'function-secret' + } + + fastify.jwt.sign({ foo: 'bar' }, { key: keyFn }, function (error, token) { + callbackCalls++ + t.assert.ifError(error) + t.assert.ok(token) + + const { createVerifier } = require('fast-jwt') + const localVerifier = createVerifier({ key: 'function-secret' }) + const result = localVerifier(token) + t.assert.strictEqual(result.foo, 'bar') + resolve() }) + await promise + await new Promise(resolve => setImmediate(resolve)) + t.assert.strictEqual(callbackCalls, 1) + }) - fastify.post('/signAsync', function (request, reply) { - reply.jwtSign(request.body, function (error, token) { - return reply.send(error || { token }) - }) + await t.test('replySign with per-call static key override', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: async function () { return 'global-secret' } }) - fastify.get('/verifyAsync', function (request, reply) { - request.jwtVerify(function (error, decodedToken) { - return reply.send(error || decodedToken) - }) + fastify.post('/sign', async function (request, reply) { + const token = await reply.jwtSign(request.body, { sign: { key: 'override-secret' } }) + return { token } }) await fastify.ready() - await t.test('synchronous', async function (t) { - t.plan(2) + const response = await fastify.inject({ + method: 'post', + url: '/sign', + payload: { foo: 'bar' } + }) - const signResponse = await fastify.inject({ - method: 'post', - url: '/signSync', - payload: { foo: 'bar' } - }) + const result = JSON.parse(response.payload) + t.assert.ok(result.token) - const token = JSON.parse(signResponse.payload).token - t.assert.ok(token) + const { createVerifier } = require('fast-jwt') + const localVerifier = createVerifier({ key: 'override-secret' }) + const decoded = localVerifier(result.token) + t.assert.strictEqual(decoded.foo, 'bar') + }) - const verifyResponse = await fastify.inject({ - method: 'get', - url: '/verifySync', - headers: { - authorization: `Bearer ${token}` - } - }) + await t.test('replySign with per-call function key override', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'global-secret' + }) - const decodedToken = JSON.parse(verifyResponse.payload) - t.assert.strictEqual(decodedToken.foo, 'bar') + fastify.post('/sign', async function (request, reply) { + const keyFn = function (_context, cb) { cb(null, 'function-secret') } + const token = await reply.jwtSign(request.body, { sign: { key: keyFn } }) + return { token } }) - await t.test('with callbacks', async function (t) { - t.plan(2) + await fastify.ready() - const signResponse = await fastify.inject({ - method: 'post', - url: '/signAsync', - payload: { foo: 'bar' } - }) + const response = await fastify.inject({ + method: 'post', + url: '/sign', + payload: { foo: 'bar' } + }) - const token = JSON.parse(signResponse.payload).token - t.assert.ok(token) + const result = JSON.parse(response.payload) + t.assert.ok(result.token) - const verifyResponse = await fastify.inject({ - method: 'get', - url: '/verifyAsync', - headers: { - authorization: `Bearer ${token}` - } - }) - const decodedToken = JSON.parse(verifyResponse.payload) - t.assert.strictEqual(decodedToken.foo, 'bar') + const { createVerifier } = require('fast-jwt') + const localVerifier = createVerifier({ key: 'function-secret' }) + const decoded = localVerifier(result.token) + t.assert.strictEqual(decoded.foo, 'bar') + }) + + await t.test('requestVerify with per-call static key override', async function (t) { + const { createSigner: createLocalSigner } = require('fast-jwt') + const fastify = Fastify() + fastify.register(jwt, { + secret: async function () { return 'global-secret' } + }) + + fastify.get('/verify', async function (request) { + return request.jwtVerify({ verify: { key: 'override-secret' } }) + }) + + await fastify.ready() + + const signer = createLocalSigner({ key: 'override-secret' }) + const token = signer({ foo: 'bar' }) + + const response = await fastify.inject({ + method: 'get', + url: '/verify', + headers: { authorization: `Bearer ${token}` } + }) + + t.assert.strictEqual(response.statusCode, 200) + const result = JSON.parse(response.payload) + t.assert.strictEqual(result.foo, 'bar') + }) + + await t.test('requestVerify with per-call function key override', async function (t) { + const { createSigner: createLocalSigner } = require('fast-jwt') + const fastify = Fastify() + fastify.register(jwt, { + secret: 'global-secret' + }) + + fastify.get('/verify', async function (request) { + const keyFn = function (_context, cb) { cb(null, 'function-secret') } + return request.jwtVerify({ verify: { key: keyFn } }) }) + + await fastify.ready() + + const signer = createLocalSigner({ key: 'function-secret' }) + const token = signer({ foo: 'bar' }) + + const response = await fastify.inject({ + method: 'get', + url: '/verify', + headers: { authorization: `Bearer ${token}` } + }) + + t.assert.strictEqual(response.statusCode, 200) + const result = JSON.parse(response.payload) + t.assert.strictEqual(result.foo, 'bar') }) }) @@ -1279,6 +2265,67 @@ test('sign and verify with RSA/ECDSA certificates and global options', async fun }) }) +test('instance sign and verify honor custom options', async function (t) { + t.plan(4) + + const fastify = Fastify() + fastify.register(jwt, { secret: 'test' }) + + await fastify.ready() + + await t.test('sign with expiresIn option (sync)', function (t) { + t.plan(2) + + const token = fastify.jwt.sign({ foo: 'bar' }, { expiresIn: '1d' }) + const decoded = fastify.jwt.verify(token) + + t.assert.strictEqual(decoded.foo, 'bar') + t.assert.strictEqual(decoded.exp - decoded.iat, 24 * 60 * 60) + }) + + await t.test('sign with expiresIn option (callback)', function (t) { + t.plan(3) + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, { expiresIn: '2h' }, function (error, token) { + t.assert.ifError(error) + + const decoded = fastify.jwt.verify(token) + t.assert.strictEqual(decoded.foo, 'bar') + t.assert.strictEqual(decoded.exp - decoded.iat, 2 * 60 * 60) + resolve() + }) + return promise + }) + + await t.test('sign with notBefore option (sync)', function (t) { + t.plan(2) + + const token = fastify.jwt.sign({ foo: 'bar' }, { notBefore: '1h' }) + const decoded = fastify.jwt.decode(token) + + t.assert.strictEqual(decoded.foo, 'bar') + t.assert.strictEqual(decoded.nbf - decoded.iat, 60 * 60) + }) + + await t.test('verify with maxAge option (callback)', function (t) { + t.plan(1) + + const { promise, resolve } = helper.withResolvers() + + // Sign a token with iat in the past (beyond maxAge) + const pastIat = Math.floor(Date.now() / 1000) - 120 + const token = fastify.jwt.sign({ foo: 'bar', iat: pastIat }) + + fastify.jwt.verify(token, { maxAge: 60 }, function (error) { + t.assert.ok(error) + resolve() + }) + return promise + }) +}) + test('sign and verify with trusted token', async function (t) { t.plan(3) await t.test('Trusted token verification', async function (t) { @@ -1375,7 +2422,7 @@ test('sign and verify with trusted token', async function (t) { }) test('decode', async function (t) { - t.plan(2) + t.plan(4) await t.test('without global options', async function (t) { t.plan(2) @@ -1449,6 +2496,162 @@ test('decode', async function (t) { t.assert.strictEqual(decoded.foo, 'bar') }) }) + + await t.test('malformed token error', async function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { secret: 'test' }) + + await fastify.ready() + + t.assert.throws(function () { + fastify.jwt.decode('not-a-jwt-token') + }, { code: 'FST_JWT_AUTHORIZATION_TOKEN_INVALID' }) + }) + + await t.test('invalid type token error', async function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { secret: 'test', decode: { checkTyp: 'JWT' } }) + + await fastify.ready() + + // Token with typ: "JWR" instead of "JWT" + t.assert.throws(function () { + fastify.jwt.decode('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUiJ9.e30.ha5mKb-6aDOVHh5lRaUBNdDmMAYLOl1no3LQkV2mAMQ') + }, { code: 'FST_JWT_AUTHORIZATION_TOKEN_INVALID' }) + }) +}) + +test('request verification reuses the complete decoder without decode overrides', async function (t) { + const decoderFactory = t.mock.method(require('fast-jwt'), 'createDecoder') + const modulePath = require.resolve('..') + const cachedModule = require.cache[modulePath] + let plugin + try { + delete require.cache[modulePath] + plugin = require('..') + } finally { + require.cache[modulePath] = cachedModule + } + + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(plugin, { secret: 'test', decode: { checkTyp: 'JWT' } }) + let options + fastify.get('/', function (request) { + return request.jwtVerify(options) + }) + await fastify.ready() + + const token = fastify.jwt.sign({ foo: 'bar' }) + for (const scenario of [ + { options: undefined, creations: 0 }, + { options: {}, creations: 0 }, + { options: { verify: {} }, creations: 0 }, + { options: { decode: {} }, creations: 1 }, + { options: { decode: { complete: false } }, creations: 1 } + ]) { + options = scenario.options + decoderFactory.mock.resetCalls() + const response = await fastify.inject({ url: '/', headers: { authorization: `Bearer ${token}` } }) + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(response.json().foo, 'bar') + t.assert.strictEqual(decoderFactory.mock.callCount(), scenario.creations) + } +}) + +test('request verification honors per-call decode options', async function (t) { + const cases = [ + { + name: 'stricter per-call type check', + globalDecode: {}, + decode: { checkTyp: 'JWT' }, + typ: 'OTHER', + messages: { authorizationTokenInvalid: error => `Rejected: ${error.message}` }, + errorMessage: 'Rejected: The type must be "JWT".' + }, + { + name: 'per-call type overrides global type', + globalDecode: { checkTyp: 'JWT' }, + decode: { checkTyp: 'OTHER' }, + typ: 'OTHER' + }, + { + name: 'empty decode options retain global type check', + globalDecode: { checkTyp: 'JWT' }, + decode: {}, + typ: 'OTHER', + messages: { authorizationTokenInvalid: 'Invalid token: %s' }, + errorMessage: 'Invalid token: The type must be "JWT".' + }, + { + name: 'complete false retains full secret context', + globalDecode: { checkTyp: 'JWT' }, + decode: { complete: false }, + typ: 'JWT' + }, + { + name: 'malformed tokens use shared error mapping', + globalDecode: {}, + decode: {}, + token: 'not-a-jwt-token', + errorMessage: 'Authorization token is invalid: The token is malformed.' + } + ] + + for (const useCallback of [false, true]) { + for (const scenario of cases) { + await t.test(`${scenario.name}: ${useCallback ? 'callback' : 'Promise'}`, async function (t) { + const contexts = [] + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.register(jwt, { + secret: function (context, callback) { + contexts.push(context) + callback(null, 'test') + }, + decode: scenario.globalDecode, + messages: scenario.messages + }) + fastify.get('/', function (request, reply) { + const options = { decode: scenario.decode } + if (useCallback) { + request.jwtVerify(options, function (error, result) { + reply.send(error || result) + }) + return reply + } + return request.jwtVerify(options) + }) + + const token = scenario.token || createSigner({ key: 'test', header: { typ: scenario.typ }, noTimestamp: true })({ foo: 'bar' }) + const response = await fastify.inject({ + url: '/', + headers: { authorization: `Bearer ${token}` } + }) + + if (scenario.errorMessage) { + t.assert.strictEqual(response.statusCode, 401) + t.assert.strictEqual(response.json().code, 'FST_JWT_AUTHORIZATION_TOKEN_INVALID') + t.assert.strictEqual(response.json().message, scenario.errorMessage) + t.assert.strictEqual(contexts.length, 0) + } else { + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), { foo: 'bar' }) + t.assert.strictEqual(contexts.length, 1) + const [context] = contexts + t.assert.strictEqual(context.operation, 'verify') + t.assert.strictEqual(context.header.typ, scenario.typ) + t.assert.deepStrictEqual(context.payload, { foo: 'bar' }) + t.assert.strictEqual(context.signature, token.split('.')[2]) + t.assert.ok(context.request) + } + }) + } + } }) test('errors', async function (t) { diff --git a/types/index.d.ts b/types/index.d.ts index 9929047..2c5c57c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,7 +1,6 @@ import { DecoderOptions, JwtHeader, - KeyFetcher, SignerCallback, SignerOptions, VerifierCallback, @@ -146,11 +145,28 @@ declare namespace fastifyJwt { ? T : SignPayloadType - export type TokenOrHeader = JwtHeader | { header: JwtHeader; payload: any } + export interface SecretContextVerify { + operation: 'verify' + header: JwtHeader + payload: any + signature: string + request?: FastifyRequest + } + + export interface SecretContextSign { + operation: 'sign' + payload: any + request?: FastifyRequest + } - export type Secret = string | Buffer | KeyFetcher | { key: Secret; passphrase: string } - | ((request: FastifyRequest, tokenOrHeader: TokenOrHeader, cb: (e: Error | null, secret: string | Buffer | undefined) => void) => void) - | ((request: FastifyRequest, tokenOrHeader: TokenOrHeader) => Promise) + export type SecretContext = SecretContextVerify | SecretContextSign + + export type SecretProvider = ( + context: SecretContext, + cb: (e: Error | null, secret: string | Buffer | undefined) => void + ) => void | Promise + + export type Secret = string | Buffer | SecretProvider | { key: Secret; passphrase: string } export type VerifyPayloadType = object | string export type DecodePayloadType = object | string @@ -159,16 +175,18 @@ declare namespace fastifyJwt { (err: Error, decoded: Decoded): void } + export type KeyOption = string | Buffer | SecretProvider + export interface SignOptions extends Omit { expiresIn: number | string; notBefore: number | string; - key?: string | Buffer + key?: KeyOption } export interface VerifyOptions extends Omit { maxAge: number | string; onlyCookie: boolean; - key?: string | Buffer + key?: KeyOption } export interface FastifyJWTOptions { diff --git a/types/index.tst.ts b/types/index.tst.ts index 3cb032a..0411690 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -1,11 +1,18 @@ -import fastify from 'fastify' +import fastify, { FastifyRequest } from 'fastify' +import { KeyFetcher } from 'fast-jwt' import fastifyJwt, { FastifyJWTOptions, FastifyJwtNamespace, JwtDecodeFunction, + JwtHeader, JwtSignFunction, JwtVerifyFunction, JWT, + KeyOption, + Secret, + SecretContext, + SecretContextSign, + SecretProvider, SignOptions, VerifyOptions } from '..' @@ -24,21 +31,64 @@ const secretOptions = { public: 'publicKey', private: 'privateKey' }, - secretFnCallback: (_req: any, _token: any, cb: any) => { cb(null, 'supersecret') }, - secretFnPromise: (_req: any, _token: any) => Promise.resolve('supersecret'), - secretFnAsync: async (_req: any, _token: any) => 'supersecret', - secretFnBufferCallback: (_req: any, _token: any, cb: any) => { cb(null, Buffer.from('some secret', 'base64')) }, - secretFnBufferPromise: (_req: any, _token: any) => Promise.resolve(Buffer.from('some secret', 'base64')), - secretFnBufferAsync: async (_req: any, _token: any) => Buffer.from('some secret', 'base64'), + secretFnCallback: (context, cb) => { + expect(context).type.toBe() + expect(context.request).type.toBe() + expect(cb).type.toBe<(error: Error | null, secret: string | Buffer | undefined) => void>() + if (context.operation === 'verify') { + expect(context.header).type.toBe() + expect(context.signature).type.toBe() + } else { + expect(context).type.toBe() + } + cb(null, 'supersecret') + }, + secretFnPromise: (context) => { + expect(context).type.toBe() + return Promise.resolve('supersecret') + }, + secretFnAsync: async (context) => { + expect(context).type.toBe() + return 'supersecret' + }, + secretFnAsyncCallback: async (_context, cb) => { cb(null, 'supersecret') }, + secretFnBufferCallback: (_context, cb) => { cb(null, Buffer.from('some secret', 'base64')) }, + secretFnBufferPromise: (_context) => Promise.resolve(Buffer.from('some secret', 'base64')), + secretFnBufferAsync: async (_context) => Buffer.from('some secret', 'base64'), publicPrivateKeyFn: { - public: (_req: any, _rep: any, cb: any) => { cb(null, 'publicKey') }, + public: (_context, cb) => { cb(null, 'publicKey') }, private: 'privateKey' }, publicPrivateKeyFn2: { public: 'publicKey', - private: (_req: any, _rep: any, cb: any) => { cb(null, 'privateKey') }, + private: (_context, cb) => { cb(null, 'privateKey') }, } -} +} satisfies Record + +expect().type.toBeAssignableTo() +expect().type.toBeAssignableTo() +expect().type.not.toBeAssignableTo() +expect().type.not.toBeAssignableTo() + +app.register(fastifyJwt, { + secret: secretOptions.secretFnCallback, + sign: { key: secretOptions.secretFnAsync }, + verify: { key: secretOptions.secretFnBufferCallback } +}) + +app.jwt.sign({ foo: 'bar' }, { + key: (context, callback) => { + expect(context).type.toBe() + callback(null, 'supersecret') + } +}, () => {}) + +app.jwt.verify('token', { + key: async (context) => { + expect(context).type.toBe() + return Buffer.from('supersecret') + } +}, () => {}) const jwtOptions: FastifyJWTOptions = { secret: 'supersecret',