From fa02b88850089847b8a284c26e1f184739a98371 Mon Sep 17 00:00:00 2001 From: Paolo Chiodi Date: Wed, 13 May 2026 14:45:22 +0200 Subject: [PATCH 1/8] feat!: unify secret function signature across all code paths Fixes #388 BREAKING CHANGE: The secret function signature is now called with (context, callback) where context is { operation, payload, header?, signature?, request? } instead of the previous inconsistent signatures (request, token, callback) or (request, payload). --- README.md | 24 ++- index.js | 147 +++++++++-------- test/jwt.test.js | 401 ++++++++++++++++++++++++++++++++++++++++++++- types/index.d.ts | 20 ++- types/index.tst.ts | 16 +- 5 files changed, 522 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index b2c4101..7a72d45 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,16 @@ In this object `{ private, public }` the `private` key is a string, buffer, or o 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`. + +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()`) + +When using a function-based secret with `fastify.jwt.sign()` or `fastify.jwt.verify()`, a callback argument is required. #### Verify-only mode @@ -140,20 +149,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 +807,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') } }) diff --git a/index.js b/index.js index 486c3bb..4bdb3e2 100644 --- a/index.js +++ b/index.js @@ -22,9 +22,15 @@ 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) + } + + const result = secretValue(context, callback) + + if (result && typeof result.then === 'function') { + result.then(secret => callback(null, secret), callback) } } @@ -121,16 +127,8 @@ 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 hasStaticPublicKey = typeof secretOrPublicKey !== 'function' + const hasStaticPrivateKey = typeof secretOrPrivateKey !== 'function' const signOptions = convertTemporalProps(initialSignOptions) const verifyOptions = convertTemporalProps(initialVerifyOptions, true) @@ -192,13 +190,17 @@ function fastifyJwt (fastify, options, next) { fastify.decorateReply(jwtSignName, replySign) const signerConfig = checkAndMergeSignOptions() - // no signer when configured in verify-mode + // no signer when configured in verify-mode or when secret is a function const signer = signerConfig.options.key ? createSigner(signerConfig.options) : null const decoder = createDecoder(decodeOptions) + const completeDecoder = createDecoder(Object.assign({}, decodeOptions, { 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 + ? createVerifier(verifierConfig.options) + : null next() @@ -230,8 +232,6 @@ function fastifyJwt (fastify, options, next) { try { return selectedDecoder(token) } catch (error) { - // Ignoring the else branch because it's not possible to test it, - // it's just a safeguard for future changes in the fast-jwt library if (error.code === TokenError.codes.malformed) { throw new AuthorizationTokenInvalidError(error.message) } else if (error.code === TokenError.codes.invalidType) { @@ -289,21 +289,25 @@ 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) { + const key = usePrivateKey ? secretOrPrivateKey : secretOrPublicKey + if (typeof key === 'function') { + // Key will be resolved per-call via resolveSecret + return Object.assign({}, options) } + return Object.assign(!options.key ? { key } : {}, options) + } + + 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 +320,60 @@ 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') { - const token = localSigner(payload) - signerConfig.callback(null, token) - } else { + if (typeof signerConfig.callback !== 'function') { + assert(hasStaticPrivateKey, 'callback is required when secret is a function') + let localSigner = signer + if (options && typeof options !== 'function') { + localSigner = createSigner(signerConfig.options) + } return localSigner(payload) } + + const cb = signerConfig.callback + const context = { operation: 'sign', payload } + resolveSecret(secretOrPrivateKey, context, function (err, secret) { + if (err) return cb(err) + const resolvedOptions = withResolvedKey(signerConfig.options, secret) + const localSigner = createSigner(resolvedOptions) + cb(null, localSigner(payload)) + }) } function verify (token, options, callback) { assert(token, 'missing token') assert(secretOrPublicKey, 'missing secret') - let localVerifier = verifier - const localOptions = convertTemporalProps(options, true) const verifierConfig = checkAndMergeVerifyOptions(localOptions, callback) - if (options && typeof options !== 'function') { - localVerifier = getVerifier(verifierConfig.options) - } - - if (typeof verifierConfig.callback === 'function') { - const result = localVerifier(token) - verifierConfig.callback(null, result) - } else { + if (typeof verifierConfig.callback !== 'function') { + assert(hasStaticPublicKey, 'callback is required when secret is a function') + let localVerifier = verifier + if (options && typeof options !== 'function') { + localVerifier = getVerifier(verifierConfig.options) + } return localVerifier(token) } + + const cb = verifierConfig.callback + const decoded = completeDecoder(token) + const context = { operation: 'verify', header: decoded.header, payload: decoded.payload, signature: decoded.signature } + resolveSecret(secretOrPublicKey, context, function (err, secret) { + if (err) return cb(err) + const resolvedOptions = withResolvedKey(verifierConfig.options, secret) + const localVerifier = getVerifier(resolvedOptions) + cb(null, localVerifier(token)) + }) } function replySign (payload, options, next) { - // if a global signer was not created, sign mode is not supported - assert(signer, 'unable to sign: secret is configured in verify mode') + // 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,12 +401,12 @@ 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) { @@ -401,20 +415,19 @@ function fastifyJwt (fastify, options, next) { 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(secretOrPrivateKey, context, callback) }, function sign (secretOrPrivateKey, callback) { if (useLocalSigner) { - const signerOptions = mergeOptionsWithKey(options.sign || options, secretOrPrivateKey) + const signerOptions = withResolvedKey(options.sign || options, secretOrPrivateKey) const localSigner = createSigner(signerOptions) const token = localSigner(payload) callback(null, token) } else { - const token = signer(payload) + const signerOptions = withResolvedKey(signerConfig.options, secretOrPrivateKey) + const localSigner = signer || createSigner(signerOptions) + const token = localSigner(payload) callback(null, token) } } @@ -491,24 +504,30 @@ function fastifyJwt (fastify, options, next) { } let token - let decodedToken + let completeDecode try { token = lookupToken(request, options.verify || options) - decodedToken = decode(token, options.decode || decodeOptions) + completeDecode = decode(token, Object.assign({}, options.decode || decodeOptions, { complete: true })) } catch (err) { return next(err) } 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(secretOrPublicKey, context, callback) }, function verify (secretOrPublicKey, callback) { try { - const verifierOptions = mergeOptionsWithKey(options.verify || options, secretOrPublicKey) + const verifierOptions = secretOrPublicKey + ? withResolvedKey(options.verify || options, secretOrPublicKey) + : Object.assign({}, options.verify || options) const localVerifier = getVerifier(verifierOptions, useGlobalOptions) const verifyResult = localVerifier(token) if (verifyResult && typeof verifyResult.then === 'function') { diff --git a/test/jwt.test.js b/test/jwt.test.js index fa79532..6b281bf 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -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')) }) }) @@ -421,6 +421,372 @@ test('sign and verify with HS-secret', async function (t) { }) }) +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('requestVerify context includes request', async function (t) { + const fastify = Fastify() + let verifyContext = null + fastify.register(jwt, { + secret: function (context, cb) { + if (context.operation === 'verify') { + verifyContext = context + } + cb(null, 'test-secret') + } + }) + + fastify.get('/verify', function (request) { + return request.jwtVerify() + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, function (error, token) { + t.assert.ifError(error) + + fastify.inject({ + method: 'get', + url: '/verify', + headers: { authorization: `Bearer ${token}` } + }).then(function (response) { + const decoded = JSON.parse(response.payload) + t.assert.strictEqual(decoded.foo, 'bar') + t.assert.ok(verifyContext) + t.assert.strictEqual(verifyContext.operation, 'verify') + t.assert.ok(verifyContext.request) + t.assert.ok(verifyContext.header) + t.assert.strictEqual(verifyContext.payload.foo, 'bar') + t.assert.strictEqual(typeof verifyContext.payload.iat, 'number') + t.assert.ok(verifyContext.signature) + resolve() + }) + }) + return promise + }) + + await t.test('replySign context includes request', async function (t) { + const fastify = Fastify() + let signContext = null + fastify.register(jwt, { + secret: function (context, cb) { + if (context.operation === 'sign') { + signContext = context + } + cb(null, 'test-secret') + } + }) + + fastify.post('/sign', async function (request, reply) { + const token = await reply.jwtSign(request.body) + return { 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) + t.assert.ok(signContext) + t.assert.strictEqual(signContext.operation, 'sign') + t.assert.ok(signContext.request) + t.assert.deepStrictEqual(signContext.payload, { foo: 'bar' }) + }) + + await t.test('replySign context shape', async function (t) { + const fastify = Fastify() + let signContext = null + fastify.register(jwt, { + secret: function (context, cb) { + if (context.operation === 'sign') { + signContext = context + } + cb(null, 'test-secret') + } + }) + + fastify.post('/sign', async function (request, reply) { + const token = await reply.jwtSign(request.body) + return { token } + }) + + await fastify.ready() + + await fastify.inject({ + method: 'post', + url: '/sign', + payload: { foo: 'bar' } + }) + + t.assert.ok(signContext) + t.assert.strictEqual(signContext.operation, 'sign') + t.assert.deepStrictEqual(signContext.payload, { foo: 'bar' }) + t.assert.ok(signContext.request) + 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() + let verifyContext = null + fastify.register(jwt, { + secret: function (context, cb) { + if (context.operation === 'verify') { + verifyContext = context + } + cb(null, 'test-secret') + } + }) + + fastify.get('/verify', function (request) { + return request.jwtVerify() + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + fastify.jwt.sign({ foo: 'bar' }, function (error, token) { + t.assert.ifError(error) + + fastify.inject({ + method: 'get', + url: '/verify', + headers: { authorization: `Bearer ${token}` } + }).then(function (response) { + t.assert.strictEqual(response.statusCode, 200) + 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(typeof verifyContext.signature, 'string') + t.assert.ok(verifyContext.request) + t.assert.strictEqual(verifyContext.request.method, 'GET') + resolve() + }) + }) + return promise + }) + + 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 + }) +}) + test('sign and verify with RSA/ECDSA certificates and global options', async function (t) { t.plan(5) @@ -1375,7 +1741,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 +1815,33 @@ 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('errors', async function (t) { diff --git a/types/index.d.ts b/types/index.d.ts index 9929047..3c4f65a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -146,11 +146,25 @@ 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 SecretContext = SecretContextVerify | SecretContextSign 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) + | ((context: SecretContext, cb: (e: Error | null, secret: string | Buffer | undefined) => void) => void) + | ((context: SecretContext) => Promise) export type VerifyPayloadType = object | string export type DecodePayloadType = object | string diff --git a/types/index.tst.ts b/types/index.tst.ts index 3cb032a..4847860 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -24,19 +24,19 @@ 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: any, cb: any) => { cb(null, 'supersecret') }, + secretFnPromise: (_context: any) => Promise.resolve('supersecret'), + secretFnAsync: async (_context: any) => 'supersecret', + secretFnBufferCallback: (_context: any, cb: any) => { cb(null, Buffer.from('some secret', 'base64')) }, + secretFnBufferPromise: (_context: any) => Promise.resolve(Buffer.from('some secret', 'base64')), + secretFnBufferAsync: async (_context: any) => Buffer.from('some secret', 'base64'), publicPrivateKeyFn: { - public: (_req: any, _rep: any, cb: any) => { cb(null, 'publicKey') }, + public: (_context: any, cb: any) => { cb(null, 'publicKey') }, private: 'privateKey' }, publicPrivateKeyFn2: { public: 'publicKey', - private: (_req: any, _rep: any, cb: any) => { cb(null, 'privateKey') }, + private: (_context: any, cb: any) => { cb(null, 'privateKey') }, } } From 9f22a6a892e49a971cb34baf6a29957643febf9a Mon Sep 17 00:00:00 2001 From: Paolo Chiodi Date: Mon, 18 May 2026 14:35:38 +0200 Subject: [PATCH 2/8] fix: allow options to be overridden on instance methods The fixes in the previous commit broke the options override for key/secret. This commit brings them back in the new setup. --- index.js | 49 +++++----- test/jwt.test.js | 242 +++++++++++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 8 +- 3 files changed, 274 insertions(+), 25 deletions(-) diff --git a/index.js b/index.js index 4bdb3e2..3146ff6 100644 --- a/index.js +++ b/index.js @@ -127,9 +127,6 @@ function fastifyJwt (fastify, options, next) { secretOrPrivateKey = secretOrPublicKey = secret } - const hasStaticPublicKey = typeof secretOrPublicKey !== 'function' - const hasStaticPrivateKey = typeof secretOrPrivateKey !== 'function' - const signOptions = convertTemporalProps(initialSignOptions) const verifyOptions = convertTemporalProps(initialVerifyOptions, true) const messagesOptions = Object.assign({}, messages, pluginOptions.messages) @@ -190,15 +187,15 @@ function fastifyJwt (fastify, options, next) { fastify.decorateReply(jwtSignName, replySign) const signerConfig = checkAndMergeSignOptions() - // no signer when configured in verify-mode or when secret is a function - 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 completeDecoder = createDecoder(Object.assign({}, decodeOptions, { complete: true })) const verifierConfig = checkAndMergeVerifyOptions() // no global verifier when secret is a function (resolved per-call) - const verifier = verifierConfig.options.key + const verifier = (verifierConfig.options.key && typeof verifierConfig.options.key !== 'function') ? createVerifier(verifierConfig.options) : null @@ -207,7 +204,7 @@ function fastifyJwt (fastify, options, next) { function getVerifier (options, globalOptions) { const useGlobalOptions = globalOptions ?? options === verifierConfig.options // Use global verifier if using global options with static key - if (useGlobalOptions && hasStaticPublicKey) return verifier + 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) @@ -232,6 +229,8 @@ function fastifyJwt (fastify, options, next) { try { return selectedDecoder(token) } catch (error) { + // Ignoring the else branch because it's not possible to test it, + // it's just a safeguard for future changes in the fast-jwt library if (error.code === TokenError.codes.malformed) { throw new AuthorizationTokenInvalidError(error.message) } else if (error.code === TokenError.codes.invalidType) { @@ -290,12 +289,10 @@ function fastifyJwt (fastify, options, next) { } function withStaticKey (options, usePrivateKey) { + if (options.key) return Object.assign({}, options) const key = usePrivateKey ? secretOrPrivateKey : secretOrPublicKey - if (typeof key === 'function') { - // Key will be resolved per-call via resolveSecret - return Object.assign({}, options) - } - return Object.assign(!options.key ? { key } : {}, options) + if (!key) return Object.assign({}, options) + return Object.assign({}, options, { key }) } function withResolvedKey (options, key) { @@ -326,7 +323,7 @@ function fastifyJwt (fastify, options, next) { const signerConfig = checkAndMergeSignOptions(localOptions, callback) if (typeof signerConfig.callback !== 'function') { - assert(hasStaticPrivateKey, 'callback is required when secret is a 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) @@ -336,7 +333,7 @@ function fastifyJwt (fastify, options, next) { const cb = signerConfig.callback const context = { operation: 'sign', payload } - resolveSecret(secretOrPrivateKey, context, function (err, secret) { + resolveSecret(signerConfig.options.key, context, function (err, secret) { if (err) return cb(err) const resolvedOptions = withResolvedKey(signerConfig.options, secret) const localSigner = createSigner(resolvedOptions) @@ -352,7 +349,7 @@ function fastifyJwt (fastify, options, next) { const verifierConfig = checkAndMergeVerifyOptions(localOptions, callback) if (typeof verifierConfig.callback !== 'function') { - assert(hasStaticPublicKey, 'callback is required when secret is a 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) @@ -363,7 +360,7 @@ function fastifyJwt (fastify, options, next) { const cb = verifierConfig.callback const decoded = completeDecoder(token) const context = { operation: 'verify', header: decoded.header, payload: decoded.payload, signature: decoded.signature } - resolveSecret(secretOrPublicKey, context, function (err, secret) { + resolveSecret(verifierConfig.options.key, context, function (err, secret) { if (err) return cb(err) const resolvedOptions = withResolvedKey(verifierConfig.options, secret) const localVerifier = getVerifier(resolvedOptions) @@ -413,14 +410,16 @@ function fastifyJwt (fastify, options, next) { return next(new Error('jwtSign requires a payload')) } + const replySignOptions = options.sign || options + steed.waterfall([ function getSecret (callback) { const context = { operation: 'sign', payload, request: reply.request } - resolveSecret(secretOrPrivateKey, context, callback) + resolveSecret(replySignOptions.key, context, callback) }, function sign (secretOrPrivateKey, callback) { if (useLocalSigner) { - const signerOptions = withResolvedKey(options.sign || options, secretOrPrivateKey) + const signerOptions = withResolvedKey(replySignOptions, secretOrPrivateKey) const localSigner = createSigner(signerOptions) const token = localSigner(payload) callback(null, token) @@ -495,12 +494,12 @@ function fastifyJwt (fastify, options, next) { // New supported contract, options supports both decode and verify options = { decode: Object.assign({}, decodeOptions, options.decode), - verify: Object.assign({}, verifyOptions, localVerifyOptions) + 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 @@ -512,6 +511,8 @@ function fastifyJwt (fastify, options, next) { return next(err) } + const requestVerifyOptions = options.verify || options + steed.waterfall([ function getSecret (callback) { const context = { @@ -521,15 +522,17 @@ function fastifyJwt (fastify, options, next) { signature: completeDecode.signature, request } - resolveSecret(secretOrPublicKey, context, callback) + resolveSecret(requestVerifyOptions.key, context, callback) }, function verify (secretOrPublicKey, callback) { try { const verifierOptions = secretOrPublicKey - ? withResolvedKey(options.verify || options, secretOrPublicKey) - : Object.assign({}, options.verify || options) + ? withResolvedKey(requestVerifyOptions, secretOrPublicKey) + /* c8 ignore next */ + : Object.assign({}, requestVerifyOptions) const localVerifier = getVerifier(verifierOptions, useGlobalOptions) const verifyResult = localVerifier(token) + /* c8 ignore next 2 */ if (verifyResult && typeof verifyResult.then === 'function') { verifyResult.then(result => callback(null, result), error => wrapError(error, callback)) } else { diff --git a/test/jwt.test.js b/test/jwt.test.js index 6b281bf..2fc6cc2 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -785,6 +785,248 @@ test('sign and verify with function secret (server methods)', async function (t) }) 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() + + const { promise, resolve } = helper.withResolvers() + + const keyFn = function (_context, cb) { cb(null, 'function-secret') } + + 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('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' + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + 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('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: async function () { return 'global-secret' } + }) + + 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('replySign with per-call static key override', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: async function () { return 'global-secret' } + }) + + fastify.post('/sign', async function (request, reply) { + const token = await reply.jwtSign(request.body, { sign: { key: 'override-secret' } }) + return { 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 { createVerifier } = require('fast-jwt') + const localVerifier = createVerifier({ key: 'override-secret' }) + const decoded = localVerifier(result.token) + t.assert.strictEqual(decoded.foo, 'bar') + }) + + await t.test('replySign with per-call function key override', async function (t) { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'global-secret' + }) + + 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 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 { 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') + }) }) test('sign and verify with RSA/ECDSA certificates and global options', async function (t) { diff --git a/types/index.d.ts b/types/index.d.ts index 3c4f65a..8dd4ff9 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -173,16 +173,20 @@ declare namespace fastifyJwt { (err: Error, decoded: Decoded): void } + export type KeyOption = string | Buffer + | ((context: SecretContext, cb: (e: Error | null, secret: string | Buffer | undefined) => void) => void) + | ((context: SecretContext) => Promise) + 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 { From fcea95b38166f1883dbd3da7273969436ac7ac64 Mon Sep 17 00:00:00 2001 From: Paolo Chiodi Date: Mon, 18 May 2026 15:01:14 +0200 Subject: [PATCH 3/8] feat: ensure callback only called once --- index.js | 11 +++++++++-- test/jwt.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 3146ff6..884c5e1 100644 --- a/index.js +++ b/index.js @@ -27,10 +27,17 @@ function resolveSecret (secretValue, context, callback) { return callback(null, secretValue) } - const result = secretValue(context, callback) + let called = false + function once (err, val) { + if (called) return + called = true + callback(err, val) + } + + const result = secretValue(context, once) if (result && typeof result.then === 'function') { - result.then(secret => callback(null, secret), callback) + result.then(secret => once(null, secret), once) } } diff --git a/test/jwt.test.js b/test/jwt.test.js index 2fc6cc2..4e219c7 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -916,6 +916,35 @@ test('sign and verify with function secret (server methods)', async function (t) 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' + }) + + await fastify.ready() + + const { promise, resolve } = helper.withResolvers() + + // 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) { + 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('replySign with per-call static key override', async function (t) { const fastify = Fastify() fastify.register(jwt, { From a05919cc88791965d6043886b1a5d6e9430725e7 Mon Sep 17 00:00:00 2001 From: Paolo Chiodi Date: Mon, 18 May 2026 15:56:02 +0200 Subject: [PATCH 4/8] fix: re-add fast-path for static keys --- index.js | 44 +++++++++++++++++++++++---- test/jwt.test.js | 79 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 884c5e1..8a1c703 100644 --- a/index.js +++ b/index.js @@ -339,12 +339,29 @@ function fastifyJwt (fastify, options, next) { } const cb = signerConfig.callback + + // Fast-path: reuse global signer when no custom options were passed + if (signer && (!options || typeof options === 'function')) { + try { + cb(null, signer(payload)) + /* c8 ignore next 3 */ + } catch (error) { + cb(error) + } + return + } + const context = { operation: 'sign', payload } resolveSecret(signerConfig.options.key, context, function (err, secret) { if (err) return cb(err) - const resolvedOptions = withResolvedKey(signerConfig.options, secret) - const localSigner = createSigner(resolvedOptions) - cb(null, localSigner(payload)) + try { + const resolvedOptions = withResolvedKey(signerConfig.options, secret) + const localSigner = createSigner(resolvedOptions) + cb(null, localSigner(payload)) + /* c8 ignore next 3 */ + } catch (error) { + cb(error) + } }) } @@ -365,13 +382,28 @@ function fastifyJwt (fastify, options, next) { } const cb = verifierConfig.callback + + // Fast-path: reuse global verifier when no custom options were passed + if (verifier && (!options || typeof options === 'function')) { + try { + cb(null, verifier(token)) + } catch (error) { + cb(error) + } + return + } + const decoded = completeDecoder(token) 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) - const resolvedOptions = withResolvedKey(verifierConfig.options, secret) - const localVerifier = getVerifier(resolvedOptions) - cb(null, localVerifier(token)) + try { + const resolvedOptions = withResolvedKey(verifierConfig.options, secret) + const localVerifier = getVerifier(resolvedOptions) + cb(null, localVerifier(token)) + } catch (error) { + cb(error) + } }) } diff --git a/test/jwt.test.js b/test/jwt.test.js index 4e219c7..f761f66 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -308,7 +308,7 @@ test('sign and verify with HS-secret', async function (t) { t.plan(2) await t.test('server methods', async function (t) { - t.plan(2) + t.plan(3) const fastify = Fastify() fastify.register(jwt, { secret: 'test' }) @@ -340,6 +340,22 @@ test('sign and verify with HS-secret', async function (t) { }) 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) { @@ -1916,6 +1932,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) { From 2a01eb07b90273486b46961d6f76a7fd98018d9a Mon Sep 17 00:00:00 2001 From: Paolo Chiodi Date: Fri, 18 Sep 2026 15:48:32 +0200 Subject: [PATCH 5/8] fix: handle JWT callback errors consistently Deliver decoding and synchronous secret provider errors through callbacks without retrying consumer callbacks. Preserve existing error types and add regression coverage for provider completion and per-request decode options. Signed-off-by: Paolo Chiodi --- index.js | 46 ++++--- test/jwt.test.js | 311 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 339 insertions(+), 18 deletions(-) diff --git a/index.js b/index.js index 8a1c703..418096c 100644 --- a/index.js +++ b/index.js @@ -34,10 +34,15 @@ function resolveSecret (secretValue, context, callback) { callback(err, val) } - const result = secretValue(context, once) + try { + const result = secretValue(context, once) - if (result && typeof result.then === 'function') { - result.then(secret => once(null, secret), once) + if (result && typeof result.then === 'function') { + result.then(secret => once(null, secret), once) + } + } catch (error) { + if (called) throw error + once(error) } } @@ -342,26 +347,27 @@ function fastifyJwt (fastify, options, next) { // Fast-path: reuse global signer when no custom options were passed if (signer && (!options || typeof options === 'function')) { + let token try { - cb(null, signer(payload)) - /* c8 ignore next 3 */ + token = signer(payload) } catch (error) { - cb(error) + return cb(error) } - return + 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) - cb(null, localSigner(payload)) - /* c8 ignore next 3 */ + token = localSigner(payload) } catch (error) { - cb(error) + return cb(error) } + cb(null, token) }) } @@ -385,25 +391,33 @@ function fastifyJwt (fastify, options, next) { // Fast-path: reuse global verifier when no custom options were passed if (verifier && (!options || typeof options === 'function')) { + let result try { - cb(null, verifier(token)) + result = verifier(token) } catch (error) { - cb(error) + return cb(error) } - return + return cb(null, result) } - const decoded = completeDecoder(token) + let decoded + try { + decoded = completeDecoder(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) - cb(null, localVerifier(token)) + result = localVerifier(token) } catch (error) { - cb(error) + return cb(error) } + cb(null, result) }) } diff --git a/test/jwt.test.js b/test/jwt.test.js index f761f66..aabb188 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('..') @@ -437,6 +437,218 @@ test('sign and verify with HS-secret', async function (t) { }) }) +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 }, callback) + } else if (mode === 'static with options') { + fastify.jwt.verify(token, {}, 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, 0) + }) + } + } +}) + +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('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('sign and verify with function secret (server methods)', async function (t) { await t.test('with callback secret', async function (t) { const fastify = Fastify() @@ -941,6 +1153,7 @@ test('sign and verify with function secret (server methods)', async function (t) 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) { @@ -949,6 +1162,7 @@ test('sign and verify with function secret (server methods)', async function (t) } fastify.jwt.sign({ foo: 'bar' }, { key: keyFn }, function (error, token) { + callbackCalls++ t.assert.ifError(error) t.assert.ok(token) @@ -958,7 +1172,9 @@ test('sign and verify with function secret (server methods)', async function (t) t.assert.strictEqual(result.foo, 'bar') resolve() }) - return promise + await promise + await new Promise(resolve => setImmediate(resolve)) + t.assert.strictEqual(callbackCalls, 1) }) await t.test('replySign with per-call static key override', async function (t) { @@ -2192,6 +2408,97 @@ test('decode', async function (t) { }) }) +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) { t.plan(16) From 19c458023d7d9a3c8db5c3ac4c5a60f9c7ff4f9e Mon Sep 17 00:00:00 2001 From: Paolo Chiodi Date: Fri, 18 Sep 2026 16:25:45 +0200 Subject: [PATCH 6/8] refactor: simplify unified secret provider support --- README.md | 8 ++- UPGRADING.md | 13 +++++ index.js | 3 +- test/jwt.test.js | 136 ++++++++++----------------------------------- types/index.d.ts | 14 ++--- types/index.tst.ts | 70 +++++++++++++++++++---- 6 files changed, 116 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 7a72d45..087aaf5 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ 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). @@ -119,6 +119,8 @@ In this object `{ private, public }` the `public` key is a string or buffer cont 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. 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 @@ -126,7 +128,9 @@ The `context` object has the following shape: - `signature`: the JWT signature (only for `'verify'`) - `request`: the Fastify request object (only available in `request.jwtVerify()` and `reply.jwtSign()`) -When using a function-based secret with `fastify.jwt.sign()` or `fastify.jwt.verify()`, a callback argument is required. +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 diff --git a/UPGRADING.md b/UPGRADING.md index 260c906..62f1e10 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,6 +1,19 @@ ## 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. +- 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 418096c..f7cf03e 100644 --- a/index.js +++ b/index.js @@ -477,8 +477,7 @@ function fastifyJwt (fastify, options, next) { const token = localSigner(payload) callback(null, token) } else { - const signerOptions = withResolvedKey(signerConfig.options, secretOrPrivateKey) - const localSigner = signer || createSigner(signerOptions) + const localSigner = signer || createSigner(withResolvedKey(signerConfig.options, secretOrPrivateKey)) const token = localSigner(payload) callback(null, token) } diff --git a/test/jwt.test.js b/test/jwt.test.js index aabb188..05f8b0c 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -758,111 +758,38 @@ test('sign and verify with function secret (server methods)', async function (t) return promise }) - await t.test('requestVerify context includes request', async function (t) { - const fastify = Fastify() - let verifyContext = null - fastify.register(jwt, { - secret: function (context, cb) { - if (context.operation === 'verify') { - verifyContext = context - } - cb(null, 'test-secret') - } - }) - - fastify.get('/verify', function (request) { - return request.jwtVerify() - }) - - await fastify.ready() - - const { promise, resolve } = helper.withResolvers() - - fastify.jwt.sign({ foo: 'bar' }, function (error, token) { - t.assert.ifError(error) - - fastify.inject({ - method: 'get', - url: '/verify', - headers: { authorization: `Bearer ${token}` } - }).then(function (response) { - const decoded = JSON.parse(response.payload) - t.assert.strictEqual(decoded.foo, 'bar') - t.assert.ok(verifyContext) - t.assert.strictEqual(verifyContext.operation, 'verify') - t.assert.ok(verifyContext.request) - t.assert.ok(verifyContext.header) - t.assert.strictEqual(verifyContext.payload.foo, 'bar') - t.assert.strictEqual(typeof verifyContext.payload.iat, 'number') - t.assert.ok(verifyContext.signature) - resolve() - }) - }) - return promise - }) - - await t.test('replySign context includes request', async function (t) { - const fastify = Fastify() - let signContext = null - fastify.register(jwt, { - secret: function (context, cb) { - if (context.operation === 'sign') { - signContext = context - } - cb(null, 'test-secret') - } - }) - - fastify.post('/sign', async function (request, reply) { - const token = await reply.jwtSign(request.body) - return { 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) - t.assert.ok(signContext) - t.assert.strictEqual(signContext.operation, 'sign') - t.assert.ok(signContext.request) - t.assert.deepStrictEqual(signContext.payload, { foo: 'bar' }) - }) - 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) { - if (context.operation === 'sign') { - signContext = context - } + 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() - await fastify.inject({ + 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.ok(signContext.request) + 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) @@ -870,46 +797,41 @@ test('sign and verify with function secret (server methods)', async function (t) 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) { - if (context.operation === 'verify') { - verifyContext = context - } + verifyContext = context cb(null, 'test-secret') } }) fastify.get('/verify', function (request) { + routeRequest = request return request.jwtVerify() }) await fastify.ready() - const { promise, resolve } = helper.withResolvers() - - fastify.jwt.sign({ foo: 'bar' }, function (error, token) { - t.assert.ifError(error) - - fastify.inject({ - method: 'get', - url: '/verify', - headers: { authorization: `Bearer ${token}` } - }).then(function (response) { - t.assert.strictEqual(response.statusCode, 200) - 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(typeof verifyContext.signature, 'string') - t.assert.ok(verifyContext.request) - t.assert.strictEqual(verifyContext.request.method, 'GET') - resolve() - }) + const token = createSigner({ key: 'test-secret' })({ foo: 'bar' }) + const response = await fastify.inject({ + method: 'get', + url: '/verify', + headers: { authorization: `Bearer ${token}` } }) - return promise + + 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) { diff --git a/types/index.d.ts b/types/index.d.ts index 8dd4ff9..2c5c57c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,7 +1,6 @@ import { DecoderOptions, JwtHeader, - KeyFetcher, SignerCallback, SignerOptions, VerifierCallback, @@ -162,9 +161,12 @@ declare namespace fastifyJwt { export type SecretContext = SecretContextVerify | SecretContextSign - export type Secret = string | Buffer | KeyFetcher | { key: Secret; passphrase: string } - | ((context: SecretContext, cb: (e: Error | null, secret: string | Buffer | undefined) => void) => void) - | ((context: SecretContext) => Promise) + 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 @@ -173,9 +175,7 @@ declare namespace fastifyJwt { (err: Error, decoded: Decoded): void } - export type KeyOption = string | Buffer - | ((context: SecretContext, cb: (e: Error | null, secret: string | Buffer | undefined) => void) => void) - | ((context: SecretContext) => Promise) + export type KeyOption = string | Buffer | SecretProvider export interface SignOptions extends Omit { expiresIn: number | string; diff --git a/types/index.tst.ts b/types/index.tst.ts index 4847860..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: (_context: any, cb: any) => { cb(null, 'supersecret') }, - secretFnPromise: (_context: any) => Promise.resolve('supersecret'), - secretFnAsync: async (_context: any) => 'supersecret', - secretFnBufferCallback: (_context: any, cb: any) => { cb(null, Buffer.from('some secret', 'base64')) }, - secretFnBufferPromise: (_context: any) => Promise.resolve(Buffer.from('some secret', 'base64')), - secretFnBufferAsync: async (_context: 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: (_context: any, cb: any) => { cb(null, 'publicKey') }, + public: (_context, cb) => { cb(null, 'publicKey') }, private: 'privateKey' }, publicPrivateKeyFn2: { public: 'publicKey', - private: (_context: 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', From b311048445f005af094b15e1e61a30f3ba10afc3 Mon Sep 17 00:00:00 2001 From: Paolo Chiodi Date: Fri, 18 Sep 2026 16:31:16 +0200 Subject: [PATCH 7/8] perf: avoid redundant decoding for static key verification --- index.js | 7 +++++-- test/jwt.test.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index f7cf03e..c9cd17e 100644 --- a/index.js +++ b/index.js @@ -205,6 +205,7 @@ function fastifyJwt (fastify, options, next) { : null const decoder = createDecoder(decodeOptions) const completeDecoder = createDecoder(Object.assign({}, decodeOptions, { complete: true })) + const hasDecodeTypeCheck = Boolean(decodeOptions.checkTyp) const verifierConfig = checkAndMergeVerifyOptions() // no global verifier when secret is a function (resolved per-call) const verifier = (verifierConfig.options.key && typeof verifierConfig.options.key !== 'function') @@ -390,10 +391,12 @@ function fastifyJwt (fastify, options, next) { const cb = verifierConfig.callback // Fast-path: reuse global verifier when no custom options were passed - if (verifier && (!options || typeof options === 'function')) { + const useGlobalVerifier = verifier && (!options || typeof options === 'function') + if (useGlobalVerifier || (typeof verifierConfig.options.key !== 'function' && !hasDecodeTypeCheck)) { let result try { - result = verifier(token) + const localVerifier = useGlobalVerifier ? verifier : getVerifier(verifierConfig.options) + result = localVerifier(token) } catch (error) { return cb(error) } diff --git a/test/jwt.test.js b/test/jwt.test.js index 05f8b0c..033a983 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -486,6 +486,53 @@ test('instance verify delivers decode errors through callbacks', async function } }) +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 retains decode type checks with static key overrides', async function (testContext) { + const fastify = Fastify() + testContext.after(() => fastify.close()) + fastify.register(jwt, { secret: 'test', decode: { checkTyp: 'JWT' } }) + await fastify.ready() + + const token = createSigner({ key: 'test', header: { typ: 'OTHER' } })({ foo: 'bar' }) + const callback = testContext.mock.fn() + fastify.jwt.verify(token, { key: 'test', checkTyp: 'OTHER' }, callback) + + testContext.assert.strictEqual(callback.mock.callCount(), 1) + const [error, result] = callback.mock.calls[0].arguments + testContext.assert.ok(error instanceof TokenError) + testContext.assert.strictEqual(error.code, TokenError.codes.invalidType) + testContext.assert.strictEqual(result, undefined) +}) + test('instance methods handle secret provider completion', async function (t) { const token = createSigner({ key: 'test' })({ foo: 'bar' }) From 62128b53625089be9638e4995adbf3a0208b0c5f Mon Sep 17 00:00:00 2001 From: Paolo Chiodi Date: Fri, 18 Sep 2026 17:07:14 +0200 Subject: [PATCH 8/8] fix: harden secret resolution and reuse request decoders --- README.md | 7 +- UPGRADING.md | 1 + index.js | 42 ++++----- test/jwt.test.js | 220 +++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 229 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 087aaf5..6710511 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ In this object `{ private, public }` the `public` key is a string or buffer cont 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. Function-valued `sign.key` and `verify.key` options, including per-call overrides, use this same contract. +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'` @@ -881,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 62f1e10..0106922 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -10,6 +10,7 @@ Function-based secrets now use the same `(context, callback)` signature in `fast - 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. diff --git a/index.js b/index.js index c9cd17e..9bcd626 100644 --- a/index.js +++ b/index.js @@ -31,6 +31,10 @@ function resolveSecret (secretValue, context, callback) { 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) } @@ -204,8 +208,10 @@ function fastifyJwt (fastify, options, next) { ? createSigner(signerConfig.options) : null const decoder = createDecoder(decodeOptions) - const completeDecoder = createDecoder(Object.assign({}, decodeOptions, { complete: true })) - const hasDecodeTypeCheck = Boolean(decodeOptions.checkTyp) + 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() // no global verifier when secret is a function (resolved per-call) const verifier = (verifierConfig.options.key && typeof verifierConfig.options.key !== 'function') @@ -233,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) } @@ -392,7 +401,7 @@ function fastifyJwt (fastify, options, next) { // 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' && !hasDecodeTypeCheck)) { + if (useGlobalVerifier || typeof verifierConfig.options.key !== 'function') { let result try { const localVerifier = useGlobalVerifier ? verifier : getVerifier(verifierConfig.options) @@ -405,7 +414,7 @@ function fastifyJwt (fastify, options, next) { let decoded try { - decoded = completeDecoder(token) + decoded = verifyDecoder(token) } catch (error) { return cb(error) } @@ -548,7 +557,7 @@ 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), + decode: options.decode ? Object.assign({}, decodeOptions, options.decode, { complete: true }) : completeDecodeOptions, verify: withStaticKey(Object.assign({}, verifyOptions, localVerifyOptions), false) } } else { @@ -561,7 +570,7 @@ function fastifyJwt (fastify, options, next) { let completeDecode try { token = lookupToken(request, options.verify || options) - completeDecode = decode(token, Object.assign({}, options.decode || decodeOptions, { complete: true })) + completeDecode = decode(token, options.decode || completeDecodeOptions) } catch (err) { return next(err) } @@ -580,22 +589,15 @@ function fastifyJwt (fastify, options, next) { resolveSecret(requestVerifyOptions.key, context, callback) }, function verify (secretOrPublicKey, callback) { + let verifyResult try { - const verifierOptions = secretOrPublicKey - ? withResolvedKey(requestVerifyOptions, secretOrPublicKey) - /* c8 ignore next */ - : Object.assign({}, requestVerifyOptions) + const verifierOptions = withResolvedKey(requestVerifyOptions, secretOrPublicKey) const localVerifier = getVerifier(verifierOptions, useGlobalOptions) - const verifyResult = localVerifier(token) - /* c8 ignore next 2 */ - 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 033a983..e370985 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -469,9 +469,9 @@ test('instance verify delivers decode errors through callbacks', async function t.assert.doesNotThrow(function () { if (mode === 'function key') { - fastify.jwt.verify(token, { key: secret }, callback) + fastify.jwt.verify(token, { key: secret, checkTyp: 'JWT' }, callback) } else if (mode === 'static with options') { - fastify.jwt.verify(token, {}, callback) + fastify.jwt.verify(token, { checkTyp: 'JWT' }, callback) } else { fastify.jwt.verify(token, callback) } @@ -480,7 +480,7 @@ test('instance verify delivers decode errors through callbacks', async function t.assert.ok(receivedError instanceof TokenError) t.assert.strictEqual(receivedError.code, code) t.assert.strictEqual(receivedError.statusCode, undefined) - t.assert.strictEqual(secretCalls, 0) + t.assert.strictEqual(secretCalls, code === TokenError.codes.invalidType && mode.startsWith('function') ? 1 : 0) }) } } @@ -516,21 +516,44 @@ test('instance verify with static key overrides decodes only once', async functi } }) -test('instance verify retains decode type checks with static key overrides', async function (testContext) { - const fastify = Fastify() - testContext.after(() => fastify.close()) - fastify.register(jwt, { secret: 'test', decode: { checkTyp: 'JWT' } }) - await fastify.ready() - +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 callback = testContext.mock.fn() - fastify.jwt.verify(token, { key: 'test', checkTyp: 'OTHER' }, callback) - - testContext.assert.strictEqual(callback.mock.callCount(), 1) - const [error, result] = callback.mock.calls[0].arguments - testContext.assert.ok(error instanceof TokenError) - testContext.assert.strictEqual(error.code, TokenError.codes.invalidType) - testContext.assert.strictEqual(result, undefined) + 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) { @@ -604,6 +627,99 @@ test('instance methods handle secret provider completion', async function (t) { } }) +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' }) @@ -696,6 +812,38 @@ test('route methods deliver synchronous secret provider errors', async function } }) +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() @@ -2377,6 +2525,44 @@ test('decode', async function (t) { }) }) +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 = [ {