fix(fastify-core): address jwt rollout review findings
This commit is contained in:
parent
39caac159b
commit
63b0d20b07
47
services/mcp-server/src/lib/auth.test.ts
Normal file
47
services/mcp-server/src/lib/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user