feat(fastify-core): add shared optional jwt context
This commit is contained in:
parent
acfad8a042
commit
cfca118c71
@ -1008,8 +1008,12 @@ The ecosystem finishes with fewer duplicate patterns and fewer partial migration
|
|||||||
|
|
||||||
## Implementation Progress Log
|
## 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
|
- 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`
|
- Commit: `PENDING_COMMIT_SHA`
|
||||||
- Status: In progress
|
- 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] Add `requestId` to all shared error responses
|
||||||
- [x] Make `startService(...)` signal registration idempotent
|
- [x] Make `startService(...)` signal registration idempotent
|
||||||
- [x] Stop forcing unconditional `process.exit(...)` from shared startup helper or make it configurable
|
- [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
|
- [ ] Migrate all product backends and `mcp-server` to the shared auth helper
|
||||||
|
|
||||||
## P1 — Should Do
|
## P1 — Should Do
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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', () => {
|
describe('createServiceApp', () => {
|
||||||
it('returns a Fastify instance', async () => {
|
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', () => {
|
describe('startService', () => {
|
||||||
it('sets readiness state after successful listen', async () => {
|
it('sets readiness state after successful listen', async () => {
|
||||||
const app = await createServiceApp({
|
const app = await createServiceApp({
|
||||||
@ -234,7 +326,7 @@ describe('startService', () => {
|
|||||||
const errorMock = vi.fn();
|
const errorMock = vi.fn();
|
||||||
|
|
||||||
app.listen = listenMock as typeof app.listen;
|
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.info = infoMock as typeof app.log.info;
|
||||||
app.log.error = errorMock as typeof app.log.error;
|
app.log.error = errorMock as typeof app.log.error;
|
||||||
|
|
||||||
|
|||||||
32
packages/fastify-core/src/auth.ts
Normal file
32
packages/fastify-core/src/auth.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export { createServiceApp } from './create-app.js';
|
export { createServiceApp } from './create-app.js';
|
||||||
|
export { registerOptionalJwtContext } from './auth.js';
|
||||||
export { startService } from './start.js';
|
export { startService } from './start.js';
|
||||||
export type { ServiceAppOptions, StartOptions, FastifyApp } from './types.js';
|
export type { ServiceAppOptions, StartOptions, FastifyApp } from './types.js';
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { jwtVerify } from 'jose';
|
|
||||||
import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors';
|
import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors';
|
||||||
import { config } from './config.js';
|
|
||||||
|
|
||||||
export type Role = 'viewer' | 'admin' | 'super_admin';
|
export type Role = 'viewer' | 'admin' | 'super_admin';
|
||||||
|
|
||||||
@ -18,26 +16,12 @@ export interface AuthRequest {
|
|||||||
jwtPayload?: JwtPayload;
|
jwtPayload?: JwtPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Augment FastifyRequest with jwtPayload for the onRequest hook
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
jwtPayload?: JwtPayload;
|
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 {
|
export function requireAuth(req: AuthRequest): JwtPayload {
|
||||||
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
|
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
|
||||||
return req.jwtPayload as JwtPayload;
|
return req.jwtPayload as JwtPayload;
|
||||||
|
|||||||
@ -23,9 +23,10 @@
|
|||||||
* Role gating: viewer / admin / super_admin per tool.
|
* 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 { 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';
|
import { toolRoutes } from './modules/tools/routes.js';
|
||||||
|
|
||||||
// Register all tool namespaces (side-effect: populates the tool registry)
|
// Register all tool namespaces (side-effect: populates the tool registry)
|
||||||
@ -85,8 +86,14 @@ const app = await createServiceApp({
|
|||||||
logLevel: config.LOG_LEVEL,
|
logLevel: config.LOG_LEVEL,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse JWT on every request (best-effort)
|
const jwtSecret = new TextEncoder().encode(config.JWT_SECRET);
|
||||||
app.addHook('onRequest', parseJwt);
|
|
||||||
|
await registerOptionalJwtContext(app, {
|
||||||
|
verifyToken: async (token: string) => {
|
||||||
|
const { payload } = await jwtVerify(token, jwtSecret);
|
||||||
|
return payload as JwtPayload;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Register tool routes
|
// Register tool routes
|
||||||
await app.register(toolRoutes, { prefix: '/api' });
|
await app.register(toolRoutes, { prefix: '/api' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user