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 9dad95b..e7a4f72 100644 --- a/jwt.js +++ b/jwt.js @@ -100,6 +100,7 @@ function fastifyJwt (fastify, options, next) { sign: initialSignOptions = {}, trusted, decoratorName = 'user', + validateDecoded, // TODO: disable on next major // enable errorCacheTTL to prevent breaking change verify: initialVerifyOptions = { errorCacheTTL: 600000 }, @@ -148,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, @@ -512,6 +514,23 @@ function fastifyJwt (fastify, options, next) { return wrapError(error, callback) } }, + function validateClaims (result, callback) { + if (!validateDecoded) return callback(null, result) + + try { + const maybePromise = validateDecoded(result) + + if (maybePromise?.then) { + maybePromise + .then(() => callback(null, result)) + .catch(err => callback(new AuthorizationTokenValidationError(err.message))) + } else { + callback(null, result) + } + } catch (err) { + callback(new AuthorizationTokenValidationError(err.message)) + } + }, function checkIfIsTrusted (result, callback) { if (!trusted) { callback(null, result) diff --git a/test/validate-decoded-option.test.js b/test/validate-decoded-option.test.js new file mode 100644 index 0000000..f58e508 --- /dev/null +++ b/test/validate-decoded-option.test.js @@ -0,0 +1,139 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const Fastify = require('fastify') +const jwt = require('../jwt') + +test('validateDecoded option - success case', async (t) => { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'supersecret', + validateDecoded: (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('validateDecoded option - should throw and block access', async (t) => { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'supersecret', + validateDecoded: (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, 400) + assert.match(response.body, /Unauthorized/) +}) + +test('validateDecoded option - async function', async (t) => { + const fastify = Fastify() + fastify.register(jwt, { + secret: 'supersecret', + validateDecoded: 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) +}) +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 a0ade25..fcf18d7 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 + validateDecoded?: (payload: Record) => void | Promise jwtDecode?: string namespace?: string jwtVerify?: string