fix(fastify-core): address jwt rollout review findings

This commit is contained in:
saravanakumardb1 2026-03-06 13:44:22 -08:00
parent 39caac159b
commit 63b0d20b07
5 changed files with 75 additions and 9 deletions

View File

@ -0,0 +1,47 @@
import { SignJWT } from 'jose';
import { describe, expect, it } from 'vitest';
import { ForbiddenError, UnauthorizedError } from '@bytelyst/errors';
import { requireAuth, requireRole, verifyJwtToken } from './auth.js';
describe('mcp auth helpers', () => {
const secret = new TextEncoder().encode('test-secret');
it('verifyJwtToken accepts tokens from the bytelyst-platform issuer', async () => {
const token = await new SignJWT({ sub: 'user_1', role: 'admin' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuer('bytelyst-platform')
.setExpirationTime('1h')
.sign(secret);
await expect(verifyJwtToken(token, secret)).resolves.toMatchObject({
sub: 'user_1',
role: 'admin',
});
});
it('verifyJwtToken rejects tokens from the wrong issuer', async () => {
const token = await new SignJWT({ sub: 'user_1' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuer('wrong-issuer')
.setExpirationTime('1h')
.sign(secret);
await expect(verifyJwtToken(token, secret)).rejects.toThrow();
});
it('requireAuth throws when jwtPayload is missing', () => {
expect(() => requireAuth({ headers: {} })).toThrow(UnauthorizedError);
});
it('requireRole throws when role is below the minimum required level', () => {
expect(() =>
requireRole(
{
headers: {},
jwtPayload: { sub: 'user_1', role: 'viewer' },
},
'admin'
)
).toThrow(ForbiddenError);
});
});

View File

@ -1,7 +1,10 @@
import { jwtVerify } from 'jose';
import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors';
export type Role = 'viewer' | 'admin' | 'super_admin';
const JWT_ISSUER = 'bytelyst-platform';
export interface JwtPayload {
sub: string;
role?: Role;
@ -22,6 +25,11 @@ declare module 'fastify' {
}
}
export async function verifyJwtToken(token: string, secret: Uint8Array): Promise<JwtPayload> {
const { payload } = await jwtVerify(token, secret, { issuer: JWT_ISSUER });
return payload as JwtPayload;
}
export function requireAuth(req: AuthRequest): JwtPayload {
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
return req.jwtPayload as JwtPayload;

View File

@ -25,8 +25,7 @@
import { createServiceApp, registerOptionalJwtContext, startService } from '@bytelyst/fastify-core';
import { config } from './lib/config.js';
import { JwtPayload } from './lib/auth.js';
import { jwtVerify } from 'jose';
import { JwtPayload, verifyJwtToken } from './lib/auth.js';
import { toolRoutes } from './modules/tools/routes.js';
// Register all tool namespaces (side-effect: populates the tool registry)
@ -89,10 +88,7 @@ const app = await createServiceApp({
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;
},
verifyToken: async (token: string) => verifyJwtToken(token, jwtSecret) as Promise<JwtPayload>,
});
// Register tool routes

View File

@ -7,6 +7,10 @@ const startServiceMock = vi.fn(async () => undefined);
const loadProductCacheMock = vi.fn(async () => undefined);
const initCosmosIfNeededMock = vi.fn(async () => undefined);
const verifyTokenMock = vi.fn(async () => ({ sub: 'user_1', productId: 'lysnrai' }));
const seedDefaultFlagsMock = vi.fn(async () => undefined);
const runPendingMigrationsMock = vi.fn(async () => undefined);
const registerDiagnosticsSubscribersMock = vi.fn();
const startTriggerEvaluationJobMock = vi.fn();
const appMock = {
register: vi.fn(async () => undefined),
@ -62,6 +66,14 @@ vi.mock('./modules/themes/routes.js', () => ({ themeRoutes: vi.fn() }));
vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock }));
vi.mock('./lib/config.js', () => ({ config: { CORS_ORIGIN: '*', PORT: 4003, HOST: '0.0.0.0' } }));
vi.mock('./modules/auth/jwt.js', () => ({ verifyToken: verifyTokenMock }));
vi.mock('./modules/flags/seed.js', () => ({ seedDefaultFlags: seedDefaultFlagsMock }));
vi.mock('./migrations/runner.js', () => ({ runPendingMigrations: runPendingMigrationsMock }));
vi.mock('./modules/diagnostics/subscribers.js', () => ({
registerDiagnosticsSubscribers: registerDiagnosticsSubscribersMock,
}));
vi.mock('./modules/diagnostics/trigger-job.js', () => ({
startTriggerEvaluationJob: startTriggerEvaluationJobMock,
}));
describe('server bootstrap', () => {
beforeEach(() => {
@ -80,6 +92,10 @@ describe('server bootstrap', () => {
expect(loadProductCacheMock).toHaveBeenCalledOnce();
expect(createServiceAppMock).toHaveBeenCalledOnce();
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
expect(runPendingMigrationsMock).toHaveBeenCalledOnce();
expect(seedDefaultFlagsMock).toHaveBeenCalledOnce();
expect(registerDiagnosticsSubscribersMock).toHaveBeenCalledOnce();
expect(startTriggerEvaluationJobMock).toHaveBeenCalledOnce();
expect(appMock.register).toHaveBeenCalled();
expect(appMock.register.mock.calls.length).toBeGreaterThan(15);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' });

View File

@ -73,9 +73,11 @@ import { marketplaceRoutes } from './modules/marketplace/routes.js';
import { predictiveAnalyticsRoutes } from './modules/predictive-analytics/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
import type { JwtPayload } from './lib/request-context.js';
import { seedDefaultFlags } from './modules/flags/seed.js';
import { runPendingMigrations } from './migrations/runner.js';
import { registerDiagnosticsSubscribers } from './modules/diagnostics/subscribers.js';
import { verifyToken } from './modules/auth/jwt.js';
await initCosmosIfNeeded();
await loadProductCache();
@ -103,9 +105,6 @@ const app = await createServiceApp({
metrics: true,
});
import { verifyToken } from './modules/auth/jwt.js';
import type { JwtPayload } from './lib/request-context.js';
await registerOptionalJwtContext(app, {
verifyToken: async token => (await verifyToken(token)) as JwtPayload,
});