diff --git a/src/core/server/Controller.ts b/src/core/server/Controller.ts new file mode 100644 index 0000000..03df5af --- /dev/null +++ b/src/core/server/Controller.ts @@ -0,0 +1,5 @@ +import type { FastifyInstance } from 'fastify'; + +export abstract class Controller { + abstract register(app: FastifyInstance): void; +} diff --git a/src/core/server/authHook.ts b/src/core/server/authHook.ts new file mode 100644 index 0000000..b864d23 --- /dev/null +++ b/src/core/server/authHook.ts @@ -0,0 +1,19 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export function registerAuthHook( + app: FastifyInstance, + secretKey: string, + exemptPaths: string[] = ['/health'] +): void { + app.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + const path = request.url.split('?')[0]; + if (!secretKey || exemptPaths.includes(path)) { + return; + } + const header = request.headers.authorization ?? ''; + const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined; + if (token !== secretKey) { + reply.code(401).send({ error: 'unauthorized' }); + } + }); +} diff --git a/src/core/server/buildServer.ts b/src/core/server/buildServer.ts new file mode 100644 index 0000000..a4fb5e5 --- /dev/null +++ b/src/core/server/buildServer.ts @@ -0,0 +1,17 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import type { Controller } from './Controller'; +import { registerAuthHook } from './authHook'; + +export interface BuildServerOptions { + secretKey: string; + controllers: Controller[]; +} + +export function buildServer(options: BuildServerOptions): FastifyInstance { + const app = Fastify({ logger: false }); + registerAuthHook(app, options.secretKey); + for (const controller of options.controllers) { + controller.register(app); + } + return app; +} diff --git a/test/core/server/authHook.test.ts b/test/core/server/authHook.test.ts new file mode 100644 index 0000000..e8f4e24 --- /dev/null +++ b/test/core/server/authHook.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import Fastify from 'fastify'; +import { registerAuthHook } from '../../../src/core/server/authHook'; + +function buildApp(secretKey: string) { + const app = Fastify(); + registerAuthHook(app, secretKey); + app.get('/health', async () => ({ status: 'ok' })); + app.get('/recommendations', async () => ({ items: [] })); + return app; +} + +describe('registerAuthHook', () => { + it('allows all requests through when secretKey is empty', async () => { + const app = buildApp(''); + const res = await app.inject({ method: 'GET', url: '/recommendations' }); + expect(res.statusCode).toBe(200); + }); + + it('always exempts /health even when a secret is configured', async () => { + const app = buildApp('s3cret'); + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(200); + }); + + it('rejects requests without a matching bearer token', async () => { + const app = buildApp('s3cret'); + const res = await app.inject({ method: 'GET', url: '/recommendations' }); + expect(res.statusCode).toBe(401); + }); + + it('allows requests with the correct bearer token', async () => { + const app = buildApp('s3cret'); + const res = await app.inject({ + method: 'GET', + url: '/recommendations', + headers: { authorization: 'Bearer s3cret' }, + }); + expect(res.statusCode).toBe(200); + }); +});