Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -214,19 +219,26 @@ async function createApp(pgPool?: Pool): Promise<express.Application> {
}),
);

// 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({
Expand Down
132 changes: 132 additions & 0 deletions middleware/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<Request>;
let res: Partial<Response> & { 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<Request>;

res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
} as Partial<Response> & { 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();
});
});
36 changes: 36 additions & 0 deletions middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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();
}
6 changes: 3 additions & 3 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down