From cfca118c712049d1d66134933dbf7a193f9b0adb Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 6 Mar 2026 12:52:49 -0800 Subject: [PATCH] feat(fastify-core): add shared optional jwt context --- ...STIFY_CORE_AUDIT_AND_ROADMAP_2026-03-06.md | 8 +- .../src/__tests__/fastify-core.test.ts | 96 ++++++++++++++++++- packages/fastify-core/src/auth.ts | 32 +++++++ packages/fastify-core/src/index.ts | 1 + services/mcp-server/src/lib/auth.ts | 16 ---- services/mcp-server/src/server.ts | 15 ++- 6 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 packages/fastify-core/src/auth.ts diff --git a/docs/audits/FASTIFY_CORE_AUDIT_AND_ROADMAP_2026-03-06.md b/docs/audits/FASTIFY_CORE_AUDIT_AND_ROADMAP_2026-03-06.md index 8e487d93..097d3d88 100644 --- a/docs/audits/FASTIFY_CORE_AUDIT_AND_ROADMAP_2026-03-06.md +++ b/docs/audits/FASTIFY_CORE_AUDIT_AND_ROADMAP_2026-03-06.md @@ -1008,8 +1008,12 @@ The ecosystem finishes with fewer duplicate patterns and fewer partial migration ## Implementation Progress Log -- [ ] **Increment 1 — Phase 1 wrapper hardening** +- [x] **Increment 1 — Phase 1 wrapper hardening** - Scope: readiness support, requestId in error responses, configurable startup fatal-exit behavior, idempotent signal handling, optional plugin failure policy, wrapper tests + - Commit: `acfad8a` + - Status: Completed +- [ ] **Increment 2 — Phase 2 shared optional JWT hook and first consumer migration** + - Scope: shared optional JWT context helper in `@bytelyst/fastify-core`, direct helper tests, `mcp-server` migrated off local `parseJwt`, obsolete local parser removed - Commit: `PENDING_COMMIT_SHA` - Status: In progress @@ -1018,7 +1022,7 @@ The ecosystem finishes with fewer duplicate patterns and fewer partial migration - [x] Add `requestId` to all shared error responses - [x] Make `startService(...)` signal registration idempotent - [x] Stop forcing unconditional `process.exit(...)` from shared startup helper or make it configurable -- [ ] Create shared optional bearer JWT hook/helper +- [x] Create shared optional bearer JWT hook/helper - [ ] Migrate all product backends and `mcp-server` to the shared auth helper ## P1 — Should Do diff --git a/packages/fastify-core/src/__tests__/fastify-core.test.ts b/packages/fastify-core/src/__tests__/fastify-core.test.ts index 027abf87..0fb60f5f 100644 --- a/packages/fastify-core/src/__tests__/fastify-core.test.ts +++ b/packages/fastify-core/src/__tests__/fastify-core.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { createServiceApp, startService } from '../index.js'; +import { createServiceApp, registerOptionalJwtContext, startService } from '../index.js'; + +type JwtRequest = { jwtPayload?: unknown }; describe('createServiceApp', () => { it('returns a Fastify instance', async () => { @@ -219,6 +221,96 @@ describe('createServiceApp', () => { }); }); +describe('registerOptionalJwtContext', () => { + it('attaches jwtPayload when bearer token verification succeeds', async () => { + const app = await createServiceApp({ + name: 'jwt-context', + version: '1.0.0', + logger: false, + }); + + await registerOptionalJwtContext(app, { + verifyToken: async token => ({ sub: `user:${token}`, role: 'admin' }), + }); + + app.get('/secure', async req => ({ jwtPayload: (req as typeof req & JwtRequest).jwtPayload })); + + const res = await app.inject({ + method: 'GET', + url: '/secure', + headers: { authorization: 'Bearer abc123' }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload)).toEqual({ + jwtPayload: { sub: 'user:abc123', role: 'admin' }, + }); + + await app.close(); + }); + + it('swallows verification errors by default for optional auth', async () => { + const app = await createServiceApp({ + name: 'jwt-optional', + version: '1.0.0', + logger: false, + }); + + await registerOptionalJwtContext(app, { + verifyToken: async () => { + throw new Error('invalid token'); + }, + }); + + app.get('/secure', async req => ({ + jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null, + })); + + const res = await app.inject({ + method: 'GET', + url: '/secure', + headers: { authorization: 'Bearer broken' }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload)).toEqual({ jwtPayload: null }); + + await app.close(); + }); + + it('invokes onError callback when token verification fails', async () => { + const app = await createServiceApp({ + name: 'jwt-onerror', + version: '1.0.0', + logger: false, + }); + + const onError = vi.fn(); + + await registerOptionalJwtContext(app, { + verifyToken: async () => { + throw new Error('bad token'); + }, + onError, + }); + + app.get('/secure', async req => ({ + jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null, + })); + + const res = await app.inject({ + method: 'GET', + url: '/secure', + headers: { authorization: 'Bearer broken' }, + }); + + expect(res.statusCode).toBe(200); + expect(onError).toHaveBeenCalledOnce(); + + await app.close(); + }); +}); + describe('startService', () => { it('sets readiness state after successful listen', async () => { const app = await createServiceApp({ @@ -234,7 +326,7 @@ describe('startService', () => { const errorMock = vi.fn(); app.listen = listenMock as typeof app.listen; - app.close = closeMock as typeof app.close; + app.close = closeMock as unknown as typeof app.close; app.log.info = infoMock as typeof app.log.info; app.log.error = errorMock as typeof app.log.error; diff --git a/packages/fastify-core/src/auth.ts b/packages/fastify-core/src/auth.ts new file mode 100644 index 00000000..a4439893 --- /dev/null +++ b/packages/fastify-core/src/auth.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest } from 'fastify'; +import type { FastifyApp } from './types.js'; + +interface JwtCarrier { + jwtPayload?: unknown; +} + +export interface OptionalJwtContextOptions { + onError?: (error: unknown, request: FastifyRequest) => Promise | void; + verifyToken: (token: string, request: FastifyRequest) => Promise | TPayload; +} + +export async function registerOptionalJwtContext( + app: FastifyApp, + options: OptionalJwtContextOptions +): Promise { + const { verifyToken, onError } = options; + + app.addHook('onRequest', async req => { + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) return; + + try { + const payload = await verifyToken(auth.slice(7), req); + (req as FastifyRequest & JwtCarrier).jwtPayload = payload; + } catch (error) { + if (onError) { + await onError(error, req); + } + } + }); +} diff --git a/packages/fastify-core/src/index.ts b/packages/fastify-core/src/index.ts index 68da77e5..e198e583 100644 --- a/packages/fastify-core/src/index.ts +++ b/packages/fastify-core/src/index.ts @@ -1,3 +1,4 @@ export { createServiceApp } from './create-app.js'; +export { registerOptionalJwtContext } from './auth.js'; export { startService } from './start.js'; export type { ServiceAppOptions, StartOptions, FastifyApp } from './types.js'; diff --git a/services/mcp-server/src/lib/auth.ts b/services/mcp-server/src/lib/auth.ts index e81ad395..cfccfbb9 100644 --- a/services/mcp-server/src/lib/auth.ts +++ b/services/mcp-server/src/lib/auth.ts @@ -1,6 +1,4 @@ -import { jwtVerify } from 'jose'; import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; -import { config } from './config.js'; export type Role = 'viewer' | 'admin' | 'super_admin'; @@ -18,26 +16,12 @@ export interface AuthRequest { jwtPayload?: JwtPayload; } -// Augment FastifyRequest with jwtPayload for the onRequest hook -import type { FastifyRequest } from 'fastify'; declare module 'fastify' { interface FastifyRequest { jwtPayload?: JwtPayload; } } -export async function parseJwt(req: FastifyRequest): Promise { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) return; - try { - const secret = new TextEncoder().encode(config.JWT_SECRET); - const { payload } = await jwtVerify(auth.slice(7), secret); - req.jwtPayload = payload as JwtPayload; - } catch { - // Invalid / expired — auth-required handlers will reject below - } -} - export function requireAuth(req: AuthRequest): JwtPayload { if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required'); return req.jwtPayload as JwtPayload; diff --git a/services/mcp-server/src/server.ts b/services/mcp-server/src/server.ts index 4ba8c388..9b9074b1 100644 --- a/services/mcp-server/src/server.ts +++ b/services/mcp-server/src/server.ts @@ -23,9 +23,10 @@ * Role gating: viewer / admin / super_admin per tool. */ -import { createServiceApp, startService } from '@bytelyst/fastify-core'; +import { createServiceApp, registerOptionalJwtContext, startService } from '@bytelyst/fastify-core'; import { config } from './lib/config.js'; -import { parseJwt } from './lib/auth.js'; +import { JwtPayload } from './lib/auth.js'; +import { jwtVerify } from 'jose'; import { toolRoutes } from './modules/tools/routes.js'; // Register all tool namespaces (side-effect: populates the tool registry) @@ -85,8 +86,14 @@ const app = await createServiceApp({ logLevel: config.LOG_LEVEL, }); -// Parse JWT on every request (best-effort) -app.addHook('onRequest', parseJwt); +const jwtSecret = new TextEncoder().encode(config.JWT_SECRET); + +await registerOptionalJwtContext(app, { + verifyToken: async (token: string) => { + const { payload } = await jwtVerify(token, jwtSecret); + return payload as JwtPayload; + }, +}); // Register tool routes await app.register(toolRoutes, { prefix: '/api' });