diff --git a/.env.example b/.env.example index eea8e45..04cdb0f 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ PORT=3000 # CORS (comma-separated origins, or * for all) CORS_ORIGIN=* +# API authentication (required in production, optional in dev/test) +# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# API_KEY=your-64-char-hex-key-here + # Optional # LOG_LEVEL=info # RATE_LIMIT_IP_WHITELIST=127.0.0.1 diff --git a/app.ts b/app.ts index 41882de..ff817e1 100644 --- a/app.ts +++ b/app.ts @@ -20,6 +20,7 @@ import { router } from './routes/disasters.js'; import { typeDefs } from './graphql/schema.js'; import { resolvers } from './graphql/resolvers.js'; import { errorHandler } from './middleware/error.js'; +import { apiKeyAuth } from './middleware/auth.js'; import type { GraphQLFormattedError } from 'graphql'; import { CREATE_DISASTERS_TABLE_SQL, @@ -59,7 +60,11 @@ const envSchema = Joi.object({ PORT: Joi.number().integer().min(1).max(65535).default(3000), POSTGRES_URI: Joi.string().uri().required(), CORS_ORIGIN: Joi.string().allow('*').default('*'), - // Add more as needed + API_KEY: Joi.string().min(32).when('NODE_ENV', { + is: 'production', + then: Joi.required(), + otherwise: Joi.optional(), + }), }).unknown(); const { value: env, error: envError } = envSchema.validate(process.env, { abortEarly: false }); @@ -214,19 +219,26 @@ async function createApp(pgPool?: Pool): Promise { }), ); - // ApolloServer initialization (now synchronous) - await initApollo(app); - // Only apply JSON body parsing to REST routes (built into Express 5) app.use('/api', express.json()); app.use(helmet()); app.use(hpp()); - // Swagger UI + // Swagger UI (no auth required — operational endpoint) if (openApiSpec) { app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiSpec)); } + // API key authentication: protect /api and /graphql routes. + // Skip auth in test environment to avoid breaking existing tests. + if (process.env.NODE_ENV !== 'test') { + app.use('/api', apiKeyAuth); + app.use('/graphql', apiKeyAuth); + } + + // ApolloServer initialization + await initApollo(app); + // Fine-tuned Helmet configuration app.use( helmet({ diff --git a/middleware/auth.test.ts b/middleware/auth.test.ts new file mode 100644 index 0000000..c1f6a89 --- /dev/null +++ b/middleware/auth.test.ts @@ -0,0 +1,132 @@ +process.env.NODE_ENV = 'test'; + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { apiKeyAuth } from './auth.js'; +import type { Request, Response, NextFunction } from 'express'; + +const VALID_API_KEY = 'a'.repeat(64); + +describe('apiKeyAuth middleware', () => { + let req: Partial; + let res: Partial & { status: jest.Mock; json: jest.Mock }; + let next: jest.Mock; + let originalApiKey: string | undefined; + + beforeEach(() => { + originalApiKey = process.env.API_KEY; + process.env.API_KEY = VALID_API_KEY; + + req = { + headers: {}, + } as Partial; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as Partial & { status: jest.Mock; json: jest.Mock }; + + next = jest.fn(); + jest.clearAllMocks(); + }); + + afterEach(() => { + if (originalApiKey === undefined) { + delete process.env.API_KEY; + } else { + process.env.API_KEY = originalApiKey; + } + }); + + it('should return 500 when API_KEY env var is not set', () => { + delete process.env.API_KEY; + + apiKeyAuth(req as Request, res as Response, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Server misconfigured: API_KEY not set' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when Authorization header is missing', () => { + apiKeyAuth(req as Request, res as Response, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Missing or invalid Authorization header' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when Authorization header does not start with Bearer', () => { + req.headers = { authorization: 'Basic some-token' }; + + apiKeyAuth(req as Request, res as Response, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Missing or invalid Authorization header' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when Bearer token is empty', () => { + req.headers = { authorization: 'Bearer ' }; + + apiKeyAuth(req as Request, res as Response, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid API key' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when API key is invalid', () => { + req.headers = { authorization: 'Bearer wrong-key' }; + + apiKeyAuth(req as Request, res as Response, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid API key' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when API key has correct length but wrong content', () => { + const wrongKey = 'b'.repeat(64); + req.headers = { authorization: `Bearer ${wrongKey}` }; + + apiKeyAuth(req as Request, res as Response, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid API key' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should call next() when API key is valid', () => { + req.headers = { authorization: `Bearer ${VALID_API_KEY}` }; + + apiKeyAuth(req as Request, res as Response, next as NextFunction); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); + + it('should use timing-safe comparison (same-length keys take similar time)', () => { + // This test verifies the code path exercises timingSafeEqual + // by checking that a key of equal length to the valid key is still rejected + const sameLengthWrongKey = 'z'.repeat(64); + req.headers = { authorization: `Bearer ${sameLengthWrongKey}` }; + + apiKeyAuth(req as Request, res as Response, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid API key' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject key that is a substring of the valid key', () => { + const partialKey = VALID_API_KEY.slice(0, 32); + req.headers = { authorization: `Bearer ${partialKey}` }; + + apiKeyAuth(req as Request, res as Response, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid API key' }); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/middleware/auth.ts b/middleware/auth.ts new file mode 100644 index 0000000..ab22e2e --- /dev/null +++ b/middleware/auth.ts @@ -0,0 +1,36 @@ +import { Request, Response, NextFunction } from 'express'; +import { timingSafeEqual } from 'crypto'; + +/** + * Express middleware that authenticates requests using a Bearer token + * in the Authorization header. The token is compared against the API_KEY + * environment variable using constant-time comparison to prevent timing attacks. + * + * Excluded from auth (handled at the app level): /healthz, /readyz, /metrics, /api-docs + */ +export function apiKeyAuth(req: Request, res: Response, next: NextFunction): void { + const apiKey = process.env.API_KEY; + + if (!apiKey) { + res.status(500).json({ error: 'Server misconfigured: API_KEY not set' }); + return; + } + + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid Authorization header' }); + return; + } + + const token = authHeader.slice(7); // Strip 'Bearer ' prefix + const tokenBuffer = Buffer.from(token); + const keyBuffer = Buffer.from(apiKey); + + if (tokenBuffer.length !== keyBuffer.length || !timingSafeEqual(tokenBuffer, keyBuffer)) { + res.status(401).json({ error: 'Invalid API key' }); + return; + } + + next(); +} diff --git a/openapi.json b/openapi.json index 3f79d8f..10bf724 100644 --- a/openapi.json +++ b/openapi.json @@ -413,9 +413,9 @@ }, "securitySchemes": { "ApiKeyAuth": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key" + "type": "http", + "scheme": "bearer", + "description": "API key passed as a Bearer token in the Authorization header" } } },