From 832ef9ed22ffdce44e45d5068f1c705dff91e45f Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 10 Mar 2026 09:52:54 +0000 Subject: [PATCH 1/2] types: infer jwt decorators from registration --- README.md | 36 ++-- types/jwt.d.ts | 77 +++++---- types/jwt.test-d.ts | 405 ++++++++++++++++++++++---------------------- 3 files changed, 269 insertions(+), 249 deletions(-) diff --git a/README.md b/README.md index ab66cc2..b991ba0 100644 --- a/README.md +++ b/README.md @@ -501,31 +501,35 @@ fastify.listen(3000, function (err) { }) ``` -#### Typescript +#### TypeScript -To simplify the use of namespaces in TypeScript you can use the `FastifyJwtNamespace` helper type: +To describe namespaced JWT decorators in TypeScript you can use the +`FastifyJwtNamespace` helper type directly: ```typescript -declare module 'fastify' { - interface FastifyInstance extends - FastifyJwtNamespace<{namespace: 'security'}> { - } -} +type SecurityJwt = FastifyJwtNamespace<{ namespace: 'security' }> + +declare const securityJwt: SecurityJwt + +securityJwt.securityJwtDecode +securityJwt.securityJwtSign +securityJwt.securityJwtVerify ``` Alternatively you can type each key explicitly: ```typescript -declare module 'fastify' { - interface FastifyInstance extends - FastifyJwtNamespace<{ - jwtDecode: 'securityJwtDecode', - jwtSign: 'securityJwtSign', - jwtVerify: 'securityJwtVerify', - }> { } -} +type SecurityJwt = FastifyJwtNamespace<{ + jwtDecode: 'securityJwtDecode', + jwtSign: 'securityJwtSign', + jwtVerify: 'securityJwtVerify', +}> ``` +When you rename JWT decorators with `namespace`, `decoratorName`, `jwtVerify`, +`jwtDecode`, or `jwtSign`, declare those renamed properties explicitly in your +own local types. + ### `messages` For your convenience, you can override the default HTTP response messages sent when an unauthorized or bad request error occurs. You can choose the specific messages to override and the rest will fallback to the default messages. The object must be in the format specified in the example below. @@ -872,6 +876,8 @@ fastify.listen({ port: 3000 }) This plugin has two available exports, the default plugin function `fastifyJwt` and the plugin options object `FastifyJWTOptions`. +When you register `@fastify/jwt` with the default decorator names, Fastify can also infer `fastify.jwt`, `request.jwtVerify()`, `request.jwtDecode()`, and `reply.jwtSign()` from that registered instance. If you customize `namespace`, `decoratorName`, `jwtVerify`, `jwtDecode`, or `jwtSign`, keep using declaration merging helpers for the renamed decorators. + Import them like so: ```ts diff --git a/types/jwt.d.ts b/types/jwt.d.ts index a0ade25..623b804 100644 --- a/types/jwt.d.ts +++ b/types/jwt.d.ts @@ -8,40 +8,14 @@ import { VerifierOptions } from 'fast-jwt' import { + AnyFastifyInstance, + ApplyDecorators, FastifyPluginCallback, - FastifyRequest + FastifyRequest, + UnEncapsulatedPlugin } from 'fastify' -declare module 'fastify' { - interface FastifyInstance { - jwt: fastifyJwt.JWT - } - - interface FastifyReply { - jwtSign(payload: fastifyJwt.SignPayloadType, options?: fastifyJwt.FastifyJwtSignOptions): Promise - jwtSign(payload: fastifyJwt.SignPayloadType, callback: SignerCallback): void - jwtSign(payload: fastifyJwt.SignPayloadType, options: fastifyJwt.FastifyJwtSignOptions, callback: SignerCallback): void - jwtSign(payload: fastifyJwt.SignPayloadType, options?: Partial): Promise - jwtSign(payload: fastifyJwt.SignPayloadType, options: Partial, callback: SignerCallback): void - } - - interface FastifyRequest { - jwtVerify(options?: fastifyJwt.FastifyJwtVerifyOptions): Promise - // eslint-disable-next-line @typescript-eslint/no-unused-vars - jwtVerify(callback: VerifierCallback): void - // eslint-disable-next-line @typescript-eslint/no-unused-vars - jwtVerify(options: fastifyJwt.FastifyJwtVerifyOptions, callback: VerifierCallback): void - jwtVerify(options?: Partial): Promise - // eslint-disable-next-line @typescript-eslint/no-unused-vars - jwtVerify(options: Partial, callback: VerifierCallback): void - jwtDecode(options?: fastifyJwt.FastifyJwtDecodeOptions): Promise - jwtDecode(callback: fastifyJwt.DecodeCallback): void - jwtDecode(options: fastifyJwt.FastifyJwtDecodeOptions, callback: fastifyJwt.DecodeCallback): void - user: fastifyJwt.UserType - } -} - -type FastifyJwt = FastifyPluginCallback +type FastifyJwt = fastifyJwt.FastifyJwtPlugin declare namespace fastifyJwt { @@ -72,6 +46,47 @@ declare namespace fastifyJwt { : never, JWT['verify']> + export interface FastifyJwtReplyDecorators { + jwtSign(payload: fastifyJwt.SignPayloadType, options?: fastifyJwt.FastifyJwtSignOptions): Promise + jwtSign(payload: fastifyJwt.SignPayloadType, callback: SignerCallback): void + jwtSign(payload: fastifyJwt.SignPayloadType, options: fastifyJwt.FastifyJwtSignOptions, callback: SignerCallback): void + jwtSign(payload: fastifyJwt.SignPayloadType, options?: Partial): Promise + jwtSign(payload: fastifyJwt.SignPayloadType, options: Partial, callback: SignerCallback): void + } + + export interface FastifyJwtRequestDecorators { + jwtVerify(options?: fastifyJwt.FastifyJwtVerifyOptions): Promise + // eslint-disable-next-line @typescript-eslint/no-unused-vars + jwtVerify(callback: VerifierCallback): void + // eslint-disable-next-line @typescript-eslint/no-unused-vars + jwtVerify(options: fastifyJwt.FastifyJwtVerifyOptions, callback: VerifierCallback): void + jwtVerify(options?: Partial): Promise + // eslint-disable-next-line @typescript-eslint/no-unused-vars + jwtVerify(options: Partial, callback: VerifierCallback): void + jwtDecode(options?: fastifyJwt.FastifyJwtDecodeOptions): Promise + jwtDecode(callback: fastifyJwt.DecodeCallback): void + jwtDecode(options: fastifyJwt.FastifyJwtDecodeOptions, callback: fastifyJwt.DecodeCallback): void + user: fastifyJwt.UserType + } + + export interface FastifyJwtInstanceDecorators { + jwt: fastifyJwt.JWT + } + + export type FastifyJwtPluginDecorators = { + fastify: FastifyJwtInstanceDecorators; + request: FastifyJwtRequestDecorators; + reply: FastifyJwtReplyDecorators; + } + + export type FastifyJwtPlugin = UnEncapsulatedPlugin< + FastifyPluginCallback< + fastifyJwt.FastifyJWTOptions, + TInstance, + ApplyDecorators + > + > + /** * for declaration merging * @example diff --git a/types/jwt.test-d.ts b/types/jwt.test-d.ts index ef58d34..0a0a5d0 100644 --- a/types/jwt.test-d.ts +++ b/types/jwt.test-d.ts @@ -1,203 +1,202 @@ -import fastify from 'fastify' -import fastifyJwt, { FastifyJWTOptions, FastifyJwtNamespace, JWT, SignOptions, VerifyOptions } from '..' -import { expectAssignable, expectType } from 'tsd' - -const app = fastify() - -const secretOptions = { - secret: 'supersecret', - publicPrivateKey: { - 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'), - publicPrivateKeyFn: { - public: (_req: any, _rep: any, cb: any) => { cb(null, 'publicKey') }, - private: 'privateKey' - }, - publicPrivateKeyFn2: { - public: 'publicKey', - private: (_req: any, _rep: any, cb: any) => { cb(null, 'privateKey') }, - } -} - -const jwtOptions: FastifyJWTOptions = { - secret: 'supersecret', - sign: { - expiresIn: 3600 - }, - cookie: { - cookieName: 'jwt', - signed: false - }, - verify: { - maxAge: '1 hour', - extractToken: () => 'token', - onlyCookie: false - }, - decode: { - complete: true - }, - messages: { - badRequestErrorMessage: 'Bad Request', - badCookieRequestErrorMessage: 'Bad Cookie Request', - noAuthorizationInHeaderMessage: 'No Header', - noAuthorizationInCookieMessage: 'No Cookie', - authorizationTokenExpiredMessage: 'Token Expired', - authorizationTokenInvalid: (err) => `${err.message}`, - authorizationTokenUntrusted: 'Token untrusted' - }, - trusted: () => false || '' || Buffer.from('foo'), - formatUser: payload => { - const objectPayload = typeof payload === 'string' - ? JSON.parse(payload) - : Buffer.isBuffer(payload) - ? JSON.parse(payload.toString()) - : payload - return { name: objectPayload.userName } - }, - namespace: 'security', - jwtVerify: 'securityVerify', - jwtSign: 'securitySign' -} - -app.register(fastifyJwt, jwtOptions) - -Object.values(secretOptions).forEach((value) => { - app.register(fastifyJwt, { ...jwtOptions, secret: value }) -}) - -app.register(fastifyJwt, { ...jwtOptions, trusted: () => Promise.resolve(false || '' || Buffer.from('foo')) }) - -app.register(fastifyJwt, { - secret: { - private: { - key: 'privateKey', - passphrase: 'super secret passphrase', - }, - public: 'publicKey', - }, - sign: { algorithm: 'ES256' }, -}) - -app.register(fastifyJwt, { ...jwtOptions, decoratorName: 'token' }) - -// expect jwt and its subsequent methods have merged with the fastify instance -expectAssignable(app.jwt) -expectAssignable(app.jwt.sign) -expectAssignable(app.jwt.verify) -expectAssignable(app.jwt.decode) -expectAssignable(app.jwt.lookupToken) -expectAssignable(app.jwt.cookie) - -app.addHook('preHandler', async (request, reply) => { - // assert request and reply specific interface merges - expectAssignable(request.jwtVerify) - expectAssignable(request.jwtDecode) - expectAssignable(request.user) - expectAssignable(reply.jwtSign) - - try { - await request.jwtVerify() - } catch (err) { - reply.send(err) - } -}) - -app.post('/signup', async (req, reply) => { - const token = app.jwt.sign({ user: 'userName' }) - reply.send({ token }) -}) - -// define custom payload -// declare module './jwt' { -// interface FastifyJWT { -// payload: { -// user: string -// } -// } -// } - -// Custom payload with formatUser -// declare module './jwt' { -// interface FastifyJWT { -// payload: { -// user: string -// } -// user: { -// name: string -// } -// } -// } - -expectType(({} as FastifyJwtNamespace<{ namespace: 'security' }>).securityJwtDecode) -expectType(({} as FastifyJwtNamespace<{ namespace: 'security' }>).securityJwtSign) -expectType(({} as FastifyJwtNamespace<{ namespace: 'security' }>).securityJwtVerify) - -declare module 'fastify' { - interface FastifyInstance extends FastifyJwtNamespace<{ namespace: 'tsdTest' }> { - } -} - -expectType(app.tsdTestJwtDecode) -expectType(app.tsdTestJwtSign) -expectType(app.tsdTestJwtVerify) - -expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtDecode: 'decode' }>).decode) -expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtDecode: 'decode' }>).securityJwtSign) -expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtDecode: 'decode' }>).securityJwtVerify) - -expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtSign: 'decode' }>).securityJwtDecode) -expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtSign: 'sign' }>).sign) -expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtSign: 'decode' }>).securityJwtVerify) - -expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtVerify: 'verify' }>).securityJwtDecode) -expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtVerify: 'verify' }>).securityJwtSign) -expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtVerify: 'verify' }>).verify) - -expectType(({} as FastifyJwtNamespace<{ jwtDecode: 'decode' }>).decode) -expectType(({} as FastifyJwtNamespace<{ jwtSign: 'sign' }>).sign) -expectType(({} as FastifyJwtNamespace<{ jwtVerify: 'verify' }>).verify) - -let signOptions: SignOptions = { - key: 'supersecret', - algorithm: 'HS256', - mutatePayload: true, - expiresIn: 3600, - notBefore: 0, -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -signOptions = { - key: Buffer.from('supersecret', 'utf-8'), - algorithm: 'HS256', - mutatePayload: true, - expiresIn: 3600, - notBefore: 0, -} - -let verifyOptions: VerifyOptions = { - key: 'supersecret', - algorithms: ['HS256'], - complete: true, - cache: true, - cacheTTL: 3600, - maxAge: '1 hour', - onlyCookie: false, -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -verifyOptions = { - key: Buffer.from('supersecret', 'utf-8'), - algorithms: ['HS256'], - complete: true, - cache: 3600, - cacheTTL: 3600, - maxAge: 3600, - onlyCookie: true, -} +import fastify from 'fastify' +import { + expectAssignable, + expectType +} from 'tsd' +import fastifyJwt, { + FastifyJWTOptions, + FastifyJwtNamespace, + FastifyJwtPlugin, + FastifyJwtPluginDecorators, + JWT, + SignOptions, + VerifyOptions +} from '..' + +const app = fastify() + +const secretOptions = { + secret: 'supersecret', + publicPrivateKey: { + 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'), + publicPrivateKeyFn: { + public: (_req: any, _rep: any, cb: any) => { cb(null, 'publicKey') }, + private: 'privateKey' + }, + publicPrivateKeyFn2: { + public: 'publicKey', + private: (_req: any, _rep: any, cb: any) => { cb(null, 'privateKey') }, + } +} + +const jwtOptions: FastifyJWTOptions = { + secret: 'supersecret', + sign: { + expiresIn: 3600 + }, + cookie: { + cookieName: 'jwt', + signed: false + }, + verify: { + maxAge: '1 hour', + extractToken: () => 'token', + onlyCookie: false + }, + decode: { + complete: true + }, + messages: { + badRequestErrorMessage: 'Bad Request', + badCookieRequestErrorMessage: 'Bad Cookie Request', + noAuthorizationInHeaderMessage: 'No Header', + noAuthorizationInCookieMessage: 'No Cookie', + authorizationTokenExpiredMessage: 'Token Expired', + authorizationTokenInvalid: (err) => `${err.message}`, + authorizationTokenUntrusted: 'Token untrusted' + }, + trusted: () => false || '' || Buffer.from('foo'), + formatUser: payload => { + const objectPayload = typeof payload === 'string' + ? JSON.parse(payload) + : Buffer.isBuffer(payload) + ? JSON.parse(payload.toString()) + : payload + return { name: objectPayload.userName } + } +} + +expectType(fastifyJwt) + +const registeredApp = fastify().register(fastifyJwt, jwtOptions) + +Object.values(secretOptions).forEach((value) => { + app.register(fastifyJwt, { ...jwtOptions, secret: value }) +}) + +app.register(fastifyJwt, { ...jwtOptions, trusted: () => Promise.resolve(false || '' || Buffer.from('foo')) }) + +app.register(fastifyJwt, { + secret: { + private: { + key: 'privateKey', + passphrase: 'super secret passphrase', + }, + public: 'publicKey', + }, + sign: { algorithm: 'ES256' }, +}) + +app.register(fastifyJwt, { ...jwtOptions, decoratorName: 'token' }) + +expectType(registeredApp.jwt) +expectAssignable(registeredApp.jwt.sign) +expectAssignable(registeredApp.jwt.verify) +expectAssignable(registeredApp.jwt.decode) +expectAssignable(registeredApp.jwt.lookupToken) +expectAssignable(registeredApp.jwt.cookie) + +registeredApp.get('/verify', async (request, reply) => { + expectType(request.jwtVerify) + expectType(request.jwtDecode) + expectAssignable(request.user) + expectType(reply.jwtSign) + + try { + await request.jwtVerify() + } catch (err) { + reply.send(err) + } +}) + +registeredApp.post('/signup', async (req, reply) => { + const token = registeredApp.jwt.sign({ user: 'userName' }) + reply.send({ token }) +}) + +// define custom payload +// declare module './jwt' { +// interface FastifyJWT { +// payload: { +// user: string +// } +// } +// } + +// Custom payload with formatUser +// declare module './jwt' { +// interface FastifyJWT { +// payload: { +// user: string +// } +// user: { +// name: string +// } +// } +// } + +expectType(({} as FastifyJwtNamespace<{ namespace: 'security' }>).securityJwtDecode) +expectType(({} as FastifyJwtNamespace<{ namespace: 'security' }>).securityJwtSign) +expectType(({} as FastifyJwtNamespace<{ namespace: 'security' }>).securityJwtVerify) + +expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtDecode: 'decode' }>).decode) +expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtDecode: 'decode' }>).securityJwtSign) +expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtDecode: 'decode' }>).securityJwtVerify) + +expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtSign: 'decode' }>).securityJwtDecode) +expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtSign: 'sign' }>).sign) +expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtSign: 'decode' }>).securityJwtVerify) + +expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtVerify: 'verify' }>).securityJwtDecode) +expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtVerify: 'verify' }>).securityJwtSign) +expectType(({} as FastifyJwtNamespace<{ namespace: 'security', jwtVerify: 'verify' }>).verify) + +expectType(({} as FastifyJwtNamespace<{ jwtDecode: 'decode' }>).decode) +expectType(({} as FastifyJwtNamespace<{ jwtSign: 'sign' }>).sign) +expectType(({} as FastifyJwtNamespace<{ jwtVerify: 'verify' }>).verify) + +let signOptions: SignOptions = { + key: 'supersecret', + algorithm: 'HS256', + mutatePayload: true, + expiresIn: 3600, + notBefore: 0, +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +signOptions = { + key: Buffer.from('supersecret', 'utf-8'), + algorithm: 'HS256', + mutatePayload: true, + expiresIn: 3600, + notBefore: 0, +} + +let verifyOptions: VerifyOptions = { + key: 'supersecret', + algorithms: ['HS256'], + complete: true, + cache: true, + cacheTTL: 3600, + maxAge: '1 hour', + onlyCookie: false, +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +verifyOptions = { + key: Buffer.from('supersecret', 'utf-8'), + algorithms: ['HS256'], + complete: true, + cache: 3600, + cacheTTL: 3600, + maxAge: 3600, + onlyCookie: true, +} From aa5d7dd4b413fc229475cf2fd735ef7015fcde75 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 15 Mar 2026 17:41:17 +0000 Subject: [PATCH 2/2] ci: pin fastify dev dependency to typed-decorators branch --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 382a058..8f816d2 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@types/node": "^25.0.3", "c8": "^11.0.0", "eslint": "^9.17.0", - "fastify": "^5.0.0", + "fastify": "github:fastify/fastify#feat/typed-decorators", "neostandard": "^0.13.0", "tsd": "^0.33.0" },