refactor(platform-service): use shared optional jwt context
This commit is contained in:
parent
cfca118c71
commit
e4baa2fc16
@ -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
|
||||
- Commit: `acfad8a`
|
||||
- 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
|
||||
- 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`
|
||||
- Status: In progress
|
||||
|
||||
|
||||
@ -2,13 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const resolveSecretsMock = vi.fn(async () => undefined);
|
||||
const createServiceAppMock = vi.fn();
|
||||
const registerOptionalJwtContextMock = vi.fn(async () => undefined);
|
||||
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 appMock = {
|
||||
addHook: vi.fn(),
|
||||
register: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
@ -32,6 +32,7 @@ vi.mock('@bytelyst/config', () => ({
|
||||
|
||||
vi.mock('@bytelyst/fastify-core', () => ({
|
||||
createServiceApp: createServiceAppMock,
|
||||
registerOptionalJwtContext: registerOptionalJwtContextMock,
|
||||
startService: startServiceMock,
|
||||
}));
|
||||
|
||||
@ -67,7 +68,6 @@ describe('server bootstrap', () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
createServiceAppMock.mockResolvedValue(appMock);
|
||||
appMock.addHook.mockReset();
|
||||
appMock.register.mockReset();
|
||||
appMock.register.mockResolvedValue(undefined);
|
||||
});
|
||||
@ -79,38 +79,43 @@ describe('server bootstrap', () => {
|
||||
expect(initCosmosIfNeededMock).toHaveBeenCalledOnce();
|
||||
expect(loadProductCacheMock).toHaveBeenCalledOnce();
|
||||
expect(createServiceAppMock).toHaveBeenCalledOnce();
|
||||
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
|
||||
expect(appMock.register).toHaveBeenCalled();
|
||||
expect(appMock.register.mock.calls.length).toBeGreaterThan(15);
|
||||
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');
|
||||
|
||||
const onRequestHook = appMock.addHook.mock.calls.find(([name]) => name === 'onRequest')?.[1];
|
||||
expect(onRequestHook).toBeTypeOf('function');
|
||||
|
||||
const req = { headers: { authorization: 'Bearer token_abc' } } as {
|
||||
headers: { authorization: string };
|
||||
jwtPayload?: unknown;
|
||||
};
|
||||
await onRequestHook(req);
|
||||
|
||||
const helperCall = registerOptionalJwtContextMock.mock.calls[0] as unknown as [
|
||||
unknown,
|
||||
{
|
||||
verifyToken: (token: string) => Promise<unknown>;
|
||||
},
|
||||
];
|
||||
const options = helperCall[1];
|
||||
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(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'));
|
||||
await import('./server.js');
|
||||
|
||||
const onRequestHook = appMock.addHook.mock.calls.find(([name]) => name === 'onRequest')?.[1];
|
||||
const req = { headers: { authorization: 'Bearer broken' } } as {
|
||||
headers: { authorization: string };
|
||||
jwtPayload?: unknown;
|
||||
};
|
||||
const helperCall = registerOptionalJwtContextMock.mock.calls[0] as unknown as [
|
||||
unknown,
|
||||
{
|
||||
verifyToken: (token: string) => Promise<unknown>;
|
||||
},
|
||||
];
|
||||
const options = helperCall[1];
|
||||
|
||||
await expect(onRequestHook(req)).resolves.toBeUndefined();
|
||||
expect(req.jwtPayload).toBeUndefined();
|
||||
await expect(options.verifyToken('broken')).rejects.toThrow('invalid token');
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,7 +20,7 @@ await resolveSecrets([
|
||||
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 { loadProductCache } from './modules/products/cache.js';
|
||||
import { authRoutes } from './modules/auth/routes.js';
|
||||
@ -103,20 +103,11 @@ const app = await createServiceApp({
|
||||
metrics: true,
|
||||
});
|
||||
|
||||
// Parse JWT on every request (best-effort — doesn't block unauthenticated routes)
|
||||
import { verifyToken } from './modules/auth/jwt.js';
|
||||
import type { JwtPayload } from './lib/request-context.js';
|
||||
|
||||
app.addHook('onRequest', async req => {
|
||||
const auth = req.headers.authorization;
|
||||
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.
|
||||
}
|
||||
await registerOptionalJwtContext(app, {
|
||||
verifyToken: async token => (await verifyToken(token)) as JwtPayload,
|
||||
});
|
||||
|
||||
// Register route modules
|
||||
|
||||
Loading…
Reference in New Issue
Block a user