diff --git a/services/mcp-server/src/lib/auth.test.ts b/services/mcp-server/src/lib/auth.test.ts new file mode 100644 index 00000000..61e09bf6 --- /dev/null +++ b/services/mcp-server/src/lib/auth.test.ts @@ -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); + }); +}); diff --git a/services/mcp-server/src/lib/auth.ts b/services/mcp-server/src/lib/auth.ts index cfccfbb9..7056a686 100644 --- a/services/mcp-server/src/lib/auth.ts +++ b/services/mcp-server/src/lib/auth.ts @@ -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 { + 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; diff --git a/services/mcp-server/src/server.ts b/services/mcp-server/src/server.ts index 9b9074b1..99a9fdab 100644 --- a/services/mcp-server/src/server.ts +++ b/services/mcp-server/src/server.ts @@ -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, }); // Register tool routes diff --git a/services/platform-service/src/server.test.ts b/services/platform-service/src/server.test.ts index fc326760..c9c4e056 100644 --- a/services/platform-service/src/server.test.ts +++ b/services/platform-service/src/server.test.ts @@ -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' }); diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 2e25dc85..5d896b42 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -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, });