feat(fastify-core): add shared optional jwt context

This commit is contained in:
saravanakumardb1 2026-03-06 12:52:49 -08:00
parent acfad8a042
commit cfca118c71
6 changed files with 144 additions and 24 deletions

View File

@ -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

View File

@ -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;

View File

@ -0,0 +1,32 @@
import type { FastifyRequest } from 'fastify';
import type { FastifyApp } from './types.js';
interface JwtCarrier {
jwtPayload?: unknown;
}
export interface OptionalJwtContextOptions<TPayload> {
onError?: (error: unknown, request: FastifyRequest) => Promise<void> | void;
verifyToken: (token: string, request: FastifyRequest) => Promise<TPayload> | TPayload;
}
export async function registerOptionalJwtContext<TPayload>(
app: FastifyApp,
options: OptionalJwtContextOptions<TPayload>
): Promise<void> {
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);
}
}
});
}

View File

@ -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';

View File

@ -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<void> {
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;

View File

@ -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' });