From 1adb0385da943c9ec42b23a9182ab0311a39fc85 Mon Sep 17 00:00:00 2001 From: William Ferreira da Silva Date: Wed, 28 May 2025 21:01:29 -0300 Subject: [PATCH 1/2] feat: add validate option support with tests and types --- jwt.js | 18 ++++++ test/validate-option.test.js | 108 +++++++++++++++++++++++++++++++++++ types/jwt.d.ts | 1 + 3 files changed, 127 insertions(+) create mode 100644 test/validate-option.test.js diff --git a/jwt.js b/jwt.js index 9dad95b..a2437f3 100644 --- a/jwt.js +++ b/jwt.js @@ -100,6 +100,7 @@ function fastifyJwt (fastify, options, next) { sign: initialSignOptions = {}, trusted, decoratorName = 'user', + validate, // TODO: disable on next major // enable errorCacheTTL to prevent breaking change verify: initialVerifyOptions = { errorCacheTTL: 600000 }, @@ -512,6 +513,23 @@ function fastifyJwt (fastify, options, next) { return wrapError(error, callback) } }, + function validateClaims (result, callback) { + if (!validate) return callback(null, result) + + try { + const maybePromise = validate(result) + + if (maybePromise?.then) { + maybePromise + .then(() => callback(null, result)) + .catch(callback) + } else { + callback(null, result) + } + } catch (err) { + callback(err) + } + }, function checkIfIsTrusted (result, callback) { if (!trusted) { callback(null, result) diff --git a/test/validate-option.test.js b/test/validate-option.test.js new file mode 100644 index 0000000..436ecde --- /dev/null +++ b/test/validate-option.test.js @@ -0,0 +1,108 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const Fastify = require('fastify') +const jwt = require('../jwt') + +test('validate option - success case', async (t) => { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'supersecret', + validate: (payload) => { + assert.equal(payload.foo, 'bar') + } + }) + + fastify.get('/protected', { + handler: async (request, reply) => { + await request.jwtVerify() + return { user: request.user } + } + }) + + await fastify.ready() + + const token = fastify.jwt.sign({ foo: 'bar' }) + + const response = await fastify.inject({ + method: 'GET', + url: '/protected', + headers: { + Authorization: `Bearer ${token}` + } + }) + + assert.equal(response.statusCode, 200) + + const body = JSON.parse(response.body) + assert.equal(body.user.foo, 'bar') + assert.ok(body.user.iat) +}) + +test('validate option - should throw and block access', async (t) => { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'supersecret', + validate: (payload) => { + if (!payload.admin) throw new Error('Unauthorized') + } + }) + + fastify.get('/admin', { + handler: async (request, reply) => { + await request.jwtVerify() + return { user: request.user } + } + }) + + await fastify.ready() + + const token = fastify.jwt.sign({ foo: 'bar' }) + + const response = await fastify.inject({ + method: 'GET', + url: '/admin', + headers: { + Authorization: `Bearer ${token}` + } + }) + + assert.equal(response.statusCode, 500) + assert.match(response.body, /Unauthorized/) +}) + +test('validate option - async function', async (t) => { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'supersecret', + validate: async (payload) => { + if (!payload.verified) throw new Error('Not verified') + } + }) + + fastify.get('/async-check', { + handler: async (request, reply) => { + await request.jwtVerify() + return { user: request.user } + } + }) + + await fastify.ready() + + const token = fastify.jwt.sign({ verified: true }) + + const response = await fastify.inject({ + method: 'GET', + url: '/async-check', + headers: { + Authorization: `Bearer ${token}` + } + }) + + assert.equal(response.statusCode, 200) + + const body = JSON.parse(response.body) + assert.equal(body.user.verified, true) + assert.ok(body.user.iat) +}) diff --git a/types/jwt.d.ts b/types/jwt.d.ts index a0ade25..d00dcf0 100644 --- a/types/jwt.d.ts +++ b/types/jwt.d.ts @@ -159,6 +159,7 @@ declare namespace fastifyJwt { decodedToken: { [k: string]: any } ) => boolean | Promise | SignPayloadType | Promise formatUser?: (payload: SignPayloadType) => UserType + validate?: (payload: Record) => void | Promise; jwtDecode?: string namespace?: string jwtVerify?: string From eedfa107682ea51aefa2d15ca7ff71286cfaf075 Mon Sep 17 00:00:00 2001 From: William Ferreira da Silva Date: Thu, 29 May 2025 10:51:54 -0300 Subject: [PATCH 2/2] feat: add validateDecoded option for payload validation --- README.md | 47 +++++++++++++++++++ jwt.js | 11 +++-- ...est.js => validate-decoded-option.test.js} | 45 +++++++++++++++--- types/jwt.d.ts | 2 +- 4 files changed, 92 insertions(+), 13 deletions(-) rename test/{validate-option.test.js => validate-decoded-option.test.js} (65%) diff --git a/README.md b/README.md index ab66cc2..8e3da72 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,37 @@ async function validateToken(request, decodedToken) { } ``` +#### validateDecoded + +`validateDecoded` allows you to validate the decoded payload of the JWT before it is considered trusted. This is useful for custom validation logic such as checking specific claims, types, or applying JSON Schema-based validations. + +This function can be **synchronous** or return a **Promise**. If it throws or rejects, Fastify will return a `400 Bad Request` with the error message. + +```js +fastify.register(jwt, { + secret: 'supersecret', + validateDecoded: (payload) => { + if (!payload.isVerified) { + throw new Error('User is not verified') + } + } +}) +``` + +You can also use an async function: +```js +fastify.register(jwt, { + secret: 'supersecret', + validateDecoded: async (payload) => { + const isAllowed = await checkInDatabase(payload.userId) + if (!isAllowed) { + throw new Error('Not allowed') + } + } +}) +``` + + ### `formatUser` #### Example with formatted user @@ -909,6 +940,22 @@ fastify.get('/', async (request, reply) => { ``` +### Token Payload Validation (`validateDecoded`) + +You can use the `validateDecoded` option to validate the decoded payload after the token is verified but before the request is processed. + +Example: +```js +fastify.register(require('@fastify/jwt'), { + secret: 'supersecret', + validateDecoded: async (payload) => { + if (!payload.role || payload.role !== 'admin') { + throw new Error('Token must include admin role') + } + } +}) +``` + ## Acknowledgments This project is kindly sponsored by: diff --git a/jwt.js b/jwt.js index a2437f3..e7a4f72 100644 --- a/jwt.js +++ b/jwt.js @@ -100,7 +100,7 @@ function fastifyJwt (fastify, options, next) { sign: initialSignOptions = {}, trusted, decoratorName = 'user', - validate, + validateDecoded, // TODO: disable on next major // enable errorCacheTTL to prevent breaking change verify: initialVerifyOptions = { errorCacheTTL: 600000 }, @@ -149,6 +149,7 @@ function fastifyJwt (fastify, options, next) { , 401) const BadRequestError = createError('FST_JWT_BAD_REQUEST', messagesOptions.badRequestErrorMessage, 400) const BadCookieRequestError = createError('FST_JWT_BAD_COOKIE_REQUEST', messagesOptions.badCookieRequestErrorMessage, 400) + const AuthorizationTokenValidationError = createError('FST_JWT_VALIDATION_FAILED', 'Token payload validation failed', 400) const jwtDecorator = { decode, @@ -514,20 +515,20 @@ function fastifyJwt (fastify, options, next) { } }, function validateClaims (result, callback) { - if (!validate) return callback(null, result) + if (!validateDecoded) return callback(null, result) try { - const maybePromise = validate(result) + const maybePromise = validateDecoded(result) if (maybePromise?.then) { maybePromise .then(() => callback(null, result)) - .catch(callback) + .catch(err => callback(new AuthorizationTokenValidationError(err.message))) } else { callback(null, result) } } catch (err) { - callback(err) + callback(new AuthorizationTokenValidationError(err.message)) } }, function checkIfIsTrusted (result, callback) { diff --git a/test/validate-option.test.js b/test/validate-decoded-option.test.js similarity index 65% rename from test/validate-option.test.js rename to test/validate-decoded-option.test.js index 436ecde..f58e508 100644 --- a/test/validate-option.test.js +++ b/test/validate-decoded-option.test.js @@ -5,11 +5,11 @@ const assert = require('node:assert') const Fastify = require('fastify') const jwt = require('../jwt') -test('validate option - success case', async (t) => { +test('validateDecoded option - success case', async (t) => { const fastify = Fastify() fastify.register(jwt, { secret: 'supersecret', - validate: (payload) => { + validateDecoded: (payload) => { assert.equal(payload.foo, 'bar') } }) @@ -40,11 +40,11 @@ test('validate option - success case', async (t) => { assert.ok(body.user.iat) }) -test('validate option - should throw and block access', async (t) => { +test('validateDecoded option - should throw and block access', async (t) => { const fastify = Fastify() fastify.register(jwt, { secret: 'supersecret', - validate: (payload) => { + validateDecoded: (payload) => { if (!payload.admin) throw new Error('Unauthorized') } }) @@ -68,15 +68,15 @@ test('validate option - should throw and block access', async (t) => { } }) - assert.equal(response.statusCode, 500) + assert.equal(response.statusCode, 400) assert.match(response.body, /Unauthorized/) }) -test('validate option - async function', async (t) => { +test('validateDecoded option - async function', async (t) => { const fastify = Fastify() fastify.register(jwt, { secret: 'supersecret', - validate: async (payload) => { + validateDecoded: async (payload) => { if (!payload.verified) throw new Error('Not verified') } }) @@ -106,3 +106,34 @@ test('validate option - async function', async (t) => { assert.equal(body.user.verified, true) assert.ok(body.user.iat) }) +test('validateDecoded - returns 400 with validation failure', async (t) => { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'supersecret', + validateDecoded: (payload) => { + throw new Error('Missing required claim') + } + }) + + fastify.get('/protected', { + handler: async (request, reply) => { + await request.jwtVerify() + return { user: request.user } + } + }) + + await fastify.ready() + + const token = fastify.jwt.sign({ foo: 'bar' }) + + const response = await fastify.inject({ + method: 'GET', + url: '/protected', + headers: { + Authorization: `Bearer ${token}` + } + }) + + assert.equal(response.statusCode, 400) + assert.match(response.body, /Missing required claim/) +}) diff --git a/types/jwt.d.ts b/types/jwt.d.ts index d00dcf0..fcf18d7 100644 --- a/types/jwt.d.ts +++ b/types/jwt.d.ts @@ -159,7 +159,7 @@ declare namespace fastifyJwt { decodedToken: { [k: string]: any } ) => boolean | Promise | SignPayloadType | Promise formatUser?: (payload: SignPayloadType) => UserType - validate?: (payload: Record) => void | Promise; + validateDecoded?: (payload: Record) => void | Promise jwtDecode?: string namespace?: string jwtVerify?: string