refactor(platform-service): use shared optional jwt context

This commit is contained in:
saravanakumardb1 2026-03-06 12:57:00 -08:00
parent cfca118c71
commit e4baa2fc16
3 changed files with 34 additions and 34 deletions

View File

@ -1012,8 +1012,12 @@ The ecosystem finishes with fewer duplicate patterns and fewer partial migration
- Scope: readiness support, requestId in error responses, configurable startup fatal-exit behavior, idempotent signal handling, optional plugin failure policy, wrapper tests - Scope: readiness support, requestId in error responses, configurable startup fatal-exit behavior, idempotent signal handling, optional plugin failure policy, wrapper tests
- Commit: `acfad8a` - Commit: `acfad8a`
- Status: Completed - Status: Completed
- [ ] **Increment 2 — Phase 2 shared optional JWT hook and first consumer migration** - [x] **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 - Scope: shared optional JWT context helper in `@bytelyst/fastify-core`, direct helper tests, `mcp-server` migrated off local `parseJwt`, obsolete local parser removed
- Commit: `cfca118`
- Status: Completed
- [ ] **Increment 3 — Shared JWT helper migration in platform-service**
- Scope: `platform-service` migrated from inline optional JWT parsing to `registerOptionalJwtContext`, bootstrap tests updated, shared-package and service validations passed
- Commit: `PENDING_COMMIT_SHA` - Commit: `PENDING_COMMIT_SHA`
- Status: In progress - Status: In progress

View File

@ -2,13 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const resolveSecretsMock = vi.fn(async () => undefined); const resolveSecretsMock = vi.fn(async () => undefined);
const createServiceAppMock = vi.fn(); const createServiceAppMock = vi.fn();
const registerOptionalJwtContextMock = vi.fn(async () => undefined);
const startServiceMock = vi.fn(async () => undefined); const startServiceMock = vi.fn(async () => undefined);
const loadProductCacheMock = vi.fn(async () => undefined); const loadProductCacheMock = vi.fn(async () => undefined);
const initCosmosIfNeededMock = vi.fn(async () => undefined); const initCosmosIfNeededMock = vi.fn(async () => undefined);
const verifyTokenMock = vi.fn(async () => ({ sub: 'user_1', productId: 'lysnrai' })); const verifyTokenMock = vi.fn(async () => ({ sub: 'user_1', productId: 'lysnrai' }));
const appMock = { const appMock = {
addHook: vi.fn(),
register: vi.fn(async () => undefined), register: vi.fn(async () => undefined),
}; };
@ -32,6 +32,7 @@ vi.mock('@bytelyst/config', () => ({
vi.mock('@bytelyst/fastify-core', () => ({ vi.mock('@bytelyst/fastify-core', () => ({
createServiceApp: createServiceAppMock, createServiceApp: createServiceAppMock,
registerOptionalJwtContext: registerOptionalJwtContextMock,
startService: startServiceMock, startService: startServiceMock,
})); }));
@ -67,7 +68,6 @@ describe('server bootstrap', () => {
vi.resetModules(); vi.resetModules();
vi.clearAllMocks(); vi.clearAllMocks();
createServiceAppMock.mockResolvedValue(appMock); createServiceAppMock.mockResolvedValue(appMock);
appMock.addHook.mockReset();
appMock.register.mockReset(); appMock.register.mockReset();
appMock.register.mockResolvedValue(undefined); appMock.register.mockResolvedValue(undefined);
}); });
@ -79,38 +79,43 @@ describe('server bootstrap', () => {
expect(initCosmosIfNeededMock).toHaveBeenCalledOnce(); expect(initCosmosIfNeededMock).toHaveBeenCalledOnce();
expect(loadProductCacheMock).toHaveBeenCalledOnce(); expect(loadProductCacheMock).toHaveBeenCalledOnce();
expect(createServiceAppMock).toHaveBeenCalledOnce(); expect(createServiceAppMock).toHaveBeenCalledOnce();
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
expect(appMock.register).toHaveBeenCalled(); expect(appMock.register).toHaveBeenCalled();
expect(appMock.register.mock.calls.length).toBeGreaterThan(15); expect(appMock.register.mock.calls.length).toBeGreaterThan(15);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' }); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' });
}); });
it('onRequest hook sets jwtPayload when bearer token is valid', async () => { it('registers optional jwt parsing with the shared fastify-core helper', async () => {
await import('./server.js'); await import('./server.js');
const onRequestHook = appMock.addHook.mock.calls.find(([name]) => name === 'onRequest')?.[1]; const helperCall = registerOptionalJwtContextMock.mock.calls[0] as unknown as [
expect(onRequestHook).toBeTypeOf('function'); unknown,
{
const req = { headers: { authorization: 'Bearer token_abc' } } as { verifyToken: (token: string) => Promise<unknown>;
headers: { authorization: string }; },
jwtPayload?: unknown; ];
}; const options = helperCall[1];
await onRequestHook(req); expect(options).toBeDefined();
expect(options.verifyToken).toBeTypeOf('function');
await expect(options.verifyToken('token_abc')).resolves.toEqual({
sub: 'user_1',
productId: 'lysnrai',
});
expect(verifyTokenMock).toHaveBeenCalledWith('token_abc'); expect(verifyTokenMock).toHaveBeenCalledWith('token_abc');
expect(req.jwtPayload).toEqual({ sub: 'user_1', productId: 'lysnrai' });
}); });
it('onRequest hook ignores invalid bearer token errors', async () => { it('passes through verifyToken rejections to the shared helper callback', async () => {
verifyTokenMock.mockRejectedValueOnce(new Error('invalid token')); verifyTokenMock.mockRejectedValueOnce(new Error('invalid token'));
await import('./server.js'); await import('./server.js');
const onRequestHook = appMock.addHook.mock.calls.find(([name]) => name === 'onRequest')?.[1]; const helperCall = registerOptionalJwtContextMock.mock.calls[0] as unknown as [
const req = { headers: { authorization: 'Bearer broken' } } as { unknown,
headers: { authorization: string }; {
jwtPayload?: unknown; verifyToken: (token: string) => Promise<unknown>;
}; },
];
const options = helperCall[1];
await expect(onRequestHook(req)).resolves.toBeUndefined(); await expect(options.verifyToken('broken')).rejects.toThrow('invalid token');
expect(req.jwtPayload).toBeUndefined();
}); });
}); });

View File

@ -20,7 +20,7 @@ await resolveSecrets([
LYSNR_SECRETS.AZURE_BLOB_ACCOUNT_KEY, LYSNR_SECRETS.AZURE_BLOB_ACCOUNT_KEY,
]); ]);
import { createServiceApp, startService } from '@bytelyst/fastify-core'; import { createServiceApp, registerOptionalJwtContext, startService } from '@bytelyst/fastify-core';
import { productRoutes } from './modules/products/routes.js'; import { productRoutes } from './modules/products/routes.js';
import { loadProductCache } from './modules/products/cache.js'; import { loadProductCache } from './modules/products/cache.js';
import { authRoutes } from './modules/auth/routes.js'; import { authRoutes } from './modules/auth/routes.js';
@ -103,20 +103,11 @@ const app = await createServiceApp({
metrics: true, metrics: true,
}); });
// Parse JWT on every request (best-effort — doesn't block unauthenticated routes)
import { verifyToken } from './modules/auth/jwt.js'; import { verifyToken } from './modules/auth/jwt.js';
import type { JwtPayload } from './lib/request-context.js'; import type { JwtPayload } from './lib/request-context.js';
app.addHook('onRequest', async req => { await registerOptionalJwtContext(app, {
const auth = req.headers.authorization; verifyToken: async token => (await verifyToken(token)) as JwtPayload,
if (!auth?.startsWith('Bearer ')) return;
try {
const payload = await verifyToken(auth.slice(7));
req.jwtPayload = payload as JwtPayload;
} catch {
// Token invalid/expired — leave jwtPayload undefined.
// Auth-required routes will handle this in their own validation.
}
}); });
// Register route modules // Register route modules