feat: create HTTP middleware layer
Create auth.ts and rate-limit.ts middleware files in the interfaces layer as part of the DDD/clean architecture restructure. Also add a config re-export at src/interfaces/config/index.ts so the middleware can access configuration through the interfaces layer boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Configuration re-export for the interfaces layer.
|
||||
*
|
||||
* Provides a centralized access point for application configuration
|
||||
* within the interfaces / HTTP adapter layer, re-exporting the
|
||||
* canonical `config` object defined at the application root.
|
||||
*/
|
||||
export { config } from '../../env';
|
||||
@@ -0,0 +1,357 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { config } from '../../config/index';
|
||||
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const SIGNATURE_SEPARATOR = '.';
|
||||
|
||||
/** A request handler function that returns a Response. */
|
||||
type Handler = (req: Request) => Response | Promise<Response>;
|
||||
|
||||
/**
|
||||
* Represents an authenticated user session after successful
|
||||
* authentication via cookie or bearer token.
|
||||
*/
|
||||
export interface AuthSession {
|
||||
/** The authenticated username (always "admin" in this implementation). */
|
||||
username: string;
|
||||
/**
|
||||
* Expiration date of the session, or `null` for bearer-token
|
||||
* sessions which do not expire at the session level.
|
||||
*/
|
||||
expiresAt: Date | null;
|
||||
/** The authentication method used to establish this session. */
|
||||
method: 'cookie' | 'bearer';
|
||||
}
|
||||
|
||||
/** Options for configuring cookie-based session behaviour. */
|
||||
interface CookieOptions {
|
||||
/** HMAC signing secret (defaults to {@link config.adminApiToken}). */
|
||||
secret?: string;
|
||||
/** Name of the session cookie (defaults to {@link config.sessionCookieName}). */
|
||||
cookieName?: string;
|
||||
/** Session lifetime in milliseconds (defaults to {@link config.sessionMaxAgeMs}). */
|
||||
maxAgeMs?: number;
|
||||
}
|
||||
|
||||
/** Shape of the serialised cookie payload. */
|
||||
interface SessionPayload {
|
||||
u: string;
|
||||
e: number;
|
||||
}
|
||||
|
||||
const getSecret = (secret?: string): string => secret ?? config.adminApiToken;
|
||||
const getCookieName = (cookieName?: string): string => cookieName ?? config.sessionCookieName;
|
||||
const getMaxAgeMs = (maxAgeMs?: number): number => maxAgeMs ?? config.sessionMaxAgeMs;
|
||||
|
||||
const encodePayload = (value: string): string =>
|
||||
Buffer.from(value, 'utf8').toString('base64url');
|
||||
|
||||
const decodePayload = (value: string): string | null => {
|
||||
try {
|
||||
return Buffer.from(value, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether authentication is enabled.
|
||||
*
|
||||
* Authentication is considered enabled when the admin API token is
|
||||
* non-empty.
|
||||
*
|
||||
* @param secret - Secret to check (defaults to `config.adminApiToken`).
|
||||
* @returns `true` when auth is enabled, `false` otherwise.
|
||||
*/
|
||||
export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0;
|
||||
|
||||
/**
|
||||
* Compares two strings using a timing-safe algorithm to prevent
|
||||
* timing side-channel attacks.
|
||||
*
|
||||
* @param left - First string to compare.
|
||||
* @param right - Second string to compare.
|
||||
* @returns `true` when the strings are equal, `false` otherwise.
|
||||
*/
|
||||
export const timingSafeCompare = (left: string, right: string): boolean => {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||
};
|
||||
|
||||
/**
|
||||
* Signs an arbitrary payload string with HMAC-SHA256 using the given
|
||||
* secret, producing a base64url-encoded signature.
|
||||
*
|
||||
* @param payload - The value to sign.
|
||||
* @param secret - HMAC signing key.
|
||||
* @returns The base64url-encoded signature.
|
||||
*/
|
||||
export const signCookiePayload = (payload: string, secret: string): string =>
|
||||
createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
|
||||
/**
|
||||
* Verifies the HMAC signature on a cookie value and returns the
|
||||
* original signed payload.
|
||||
*
|
||||
* The cookie value is expected to be in the format
|
||||
* `<payload>.<signature>`. Returns `null` when the format is
|
||||
* invalid or the signature does not match.
|
||||
*
|
||||
* @param cookieValue - The full cookie value including signature.
|
||||
* @param secret - HMAC signing key.
|
||||
* @returns The unsigned payload string, or `null` on failure.
|
||||
*/
|
||||
export const verifyCookieSignature = (
|
||||
cookieValue: string,
|
||||
secret: string,
|
||||
): string | null => {
|
||||
const separatorIndex = cookieValue.lastIndexOf(SIGNATURE_SEPARATOR);
|
||||
if (separatorIndex <= 0 || separatorIndex === cookieValue.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = cookieValue.slice(0, separatorIndex);
|
||||
const signature = cookieValue.slice(separatorIndex + 1);
|
||||
const expectedSignature = signCookiePayload(payload, secret);
|
||||
|
||||
if (!timingSafeCompare(signature, expectedSignature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the `Set-Cookie` attribute string for a given max-age in
|
||||
* seconds. The cookie is HttpOnly, SameSite=Lax, Secure, and
|
||||
* scoped to the root path.
|
||||
*
|
||||
* @param maxAgeSeconds - Max-Age in seconds.
|
||||
* @returns The cookie attribute string (excluding name=value).
|
||||
*/
|
||||
const cookieAttributes = (maxAgeSeconds: number): string =>
|
||||
[
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
'Secure',
|
||||
].join('; ');
|
||||
|
||||
/**
|
||||
* Creates a signed session cookie string suitable for use as a
|
||||
* `Set-Cookie` header value.
|
||||
*
|
||||
* The cookie embeds a base64url-encoded JSON payload containing the
|
||||
* username and expiration timestamp, signed with HMAC-SHA256.
|
||||
*
|
||||
* @param username - Session username (default `"admin"`).
|
||||
* @param options - Optional cookie settings.
|
||||
* @returns A fully-formed `Set-Cookie` header value.
|
||||
*/
|
||||
export const createSessionCookie = (
|
||||
username = ADMIN_USERNAME,
|
||||
options: CookieOptions = {},
|
||||
): string => {
|
||||
const secret = getSecret(options.secret);
|
||||
const cookieName = getCookieName(options.cookieName);
|
||||
const maxAgeMs = getMaxAgeMs(options.maxAgeMs);
|
||||
const expiresAt = Date.now() + maxAgeMs;
|
||||
const payload = encodePayload(
|
||||
JSON.stringify({ u: username, e: expiresAt } satisfies SessionPayload),
|
||||
);
|
||||
const signature = signCookiePayload(payload, secret);
|
||||
const maxAgeSeconds = Math.max(1, Math.floor(maxAgeMs / 1000));
|
||||
|
||||
return `${cookieName}=${payload}${SIGNATURE_SEPARATOR}${signature}; ${cookieAttributes(maxAgeSeconds)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a `Set-Cookie` header value that immediately expires the
|
||||
* session cookie, effectively logging the user out.
|
||||
*
|
||||
* @param cookieName - Name of the cookie to clear (defaults to
|
||||
* `config.sessionCookieName`).
|
||||
* @returns A `Set-Cookie` header value with Max-Age=0.
|
||||
*/
|
||||
export const clearSessionCookie = (cookieName = config.sessionCookieName): string =>
|
||||
`${cookieName}=; ${cookieAttributes(0)}`;
|
||||
|
||||
/**
|
||||
* Finds the value of a named cookie from a raw `Cookie` header
|
||||
* string.
|
||||
*
|
||||
* @param cookieHeader - The raw `Cookie` header value, or `null`.
|
||||
* @param cookieName - Name of the cookie to look for.
|
||||
* @returns The cookie value, or `null` if not found.
|
||||
*/
|
||||
const findCookieValue = (cookieHeader: string | null, cookieName: string): string | null => {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
for (const rawCookie of cookieHeader.split(';')) {
|
||||
const cookie = rawCookie.trim();
|
||||
const equalsIndex = cookie.indexOf('=');
|
||||
if (equalsIndex <= 0) continue;
|
||||
|
||||
const name = cookie.slice(0, equalsIndex);
|
||||
if (name === cookieName) {
|
||||
return cookie.slice(equalsIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses an {@link AuthSession} from a signed session cookie.
|
||||
*
|
||||
* The function verifies the HMAC signature, decodes the payload,
|
||||
* and validates the expiration timestamp. Returns `null` when the
|
||||
* cookie is missing, malformed, expired, or the signature is
|
||||
* invalid. Also returns `null` when auth is disabled (empty
|
||||
* admin API token).
|
||||
*
|
||||
* @param cookieHeader - The `Cookie` header value, or `null`.
|
||||
* @param options - Optional overrides for secret / cookie name.
|
||||
* @returns The parsed session, or `null`.
|
||||
*/
|
||||
export const parseSessionFromCookie = (
|
||||
cookieHeader: string | null,
|
||||
options: Pick<CookieOptions, 'secret' | 'cookieName'> = {},
|
||||
): AuthSession | null => {
|
||||
const secret = getSecret(options.secret);
|
||||
const cookieName = getCookieName(options.cookieName);
|
||||
if (!isAuthEnabled(secret)) return null;
|
||||
|
||||
const cookieValue = findCookieValue(cookieHeader, cookieName);
|
||||
if (!cookieValue) return null;
|
||||
|
||||
const encodedPayload = verifyCookieSignature(cookieValue, secret);
|
||||
if (!encodedPayload) return null;
|
||||
|
||||
const rawPayload = decodePayload(encodedPayload);
|
||||
if (!rawPayload) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(rawPayload) as Partial<SessionPayload>;
|
||||
if (payload.u !== ADMIN_USERNAME || typeof payload.e !== 'number') return null;
|
||||
if (!Number.isFinite(payload.e) || payload.e <= Date.now()) return null;
|
||||
|
||||
return {
|
||||
username: payload.u,
|
||||
expiresAt: new Date(payload.e),
|
||||
method: 'cookie',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a `Bearer` token from the `Authorization` header using
|
||||
* timing-safe comparison.
|
||||
*
|
||||
* @param authorizationHeader - The raw `Authorization` header, or `null`.
|
||||
* @param secret - Expected bearer token (defaults to
|
||||
* `config.adminApiToken`).
|
||||
* @returns `true` when the token is valid, `false` otherwise.
|
||||
*/
|
||||
export const checkBearerToken = (
|
||||
authorizationHeader: string | null,
|
||||
secret = config.adminApiToken,
|
||||
): boolean => {
|
||||
if (!isAuthEnabled(secret) || !authorizationHeader) return false;
|
||||
|
||||
const [scheme, ...rest] = authorizationHeader.split(' ');
|
||||
if (scheme !== 'Bearer' || rest.length === 0) return false;
|
||||
|
||||
const token = rest.join(' ').trim();
|
||||
return token.length > 0 && timingSafeCompare(token, secret);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the authenticated session from a request.
|
||||
*
|
||||
* Tries cookie-based authentication first, then falls back to a
|
||||
* Bearer token in the `Authorization` header. When auth is
|
||||
* disabled (empty API token) the function returns a synthetic
|
||||
* session with method `"bearer"` and no expiry, effectively
|
||||
* granting access to all requests.
|
||||
*
|
||||
* @param req - The incoming HTTP request.
|
||||
* @param options - Optional overrides for secret / cookie name.
|
||||
* @returns The authenticated session, or `null` when unauthenticated.
|
||||
*/
|
||||
export const getAuthSession = (
|
||||
req: Request,
|
||||
options: Pick<CookieOptions, 'secret' | 'cookieName'> = {},
|
||||
): AuthSession | null => {
|
||||
const secret = getSecret(options.secret);
|
||||
if (!isAuthEnabled(secret)) {
|
||||
return {
|
||||
username: ADMIN_USERNAME,
|
||||
expiresAt: null,
|
||||
method: 'bearer',
|
||||
};
|
||||
}
|
||||
|
||||
const cookieSession = parseSessionFromCookie(req.headers.get('cookie'), options);
|
||||
if (cookieSession) return cookieSession;
|
||||
|
||||
if (checkBearerToken(req.headers.get('authorization'), secret)) {
|
||||
return {
|
||||
username: ADMIN_USERNAME,
|
||||
expiresAt: null,
|
||||
method: 'bearer',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a 401 Unauthorized JSON response with a standard error
|
||||
* body.
|
||||
*
|
||||
* @returns A `Response` with status 401 and JSON body
|
||||
* `{ error: "Unauthorized" }`.
|
||||
*/
|
||||
export const unauthorizedResponse = (): Response =>
|
||||
Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
/**
|
||||
* Middleware that wraps a request handler with authentication.
|
||||
*
|
||||
* When auth is enabled the wrapper checks for a valid session
|
||||
* (cookie or Bearer token) before delegating to the handler.
|
||||
* Unauthenticated requests receive a 401 response. When auth is
|
||||
* disabled the handler is always invoked.
|
||||
*
|
||||
* @param handler - The request handler to protect.
|
||||
* @param options - Optional overrides for secret / cookie name.
|
||||
* @returns A wrapped handler that performs the auth check.
|
||||
*/
|
||||
export const requireAuth = (
|
||||
handler: Handler,
|
||||
options: Pick<CookieOptions, 'secret' | 'cookieName'> = {},
|
||||
): ((req: Request) => Promise<Response>) => {
|
||||
return async (req: Request): Promise<Response> => {
|
||||
const secret = getSecret(options.secret);
|
||||
if (!isAuthEnabled(secret)) {
|
||||
return handler(req);
|
||||
}
|
||||
|
||||
const session = getAuthSession(req, options);
|
||||
if (!session) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
return handler(req);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { config } from '../../config/index';
|
||||
import { extractClientIp } from '../../../shared/utils/ip';
|
||||
import logger from '../../../shared/logger/index';
|
||||
|
||||
/** An entry in the in-memory rate-limit store. */
|
||||
interface RateLimitEntry {
|
||||
/** Number of requests received during the current window. */
|
||||
count: number;
|
||||
/** Epoch timestamp (ms) when the current window expires. */
|
||||
resetTime: number;
|
||||
}
|
||||
|
||||
/** In-memory store mapping keys (typically client IPs) to rate-limit entries. */
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
/** Maximum number of tracked entries before LRU eviction kicks in. */
|
||||
const MAX_STORE_ENTRIES = 50000;
|
||||
|
||||
/**
|
||||
* Removes all expired entries from the rate-limit store.
|
||||
*
|
||||
* @param now - Current epoch timestamp in milliseconds (defaults to `Date.now()`).
|
||||
* @returns The number of entries that were cleaned.
|
||||
*/
|
||||
const evictExpiredEntries = (now = Date.now()): number => {
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [key, entry] of rateLimitStore.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
rateLimitStore.delete(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures the store stays below {@link MAX_STORE_ENTRIES} by first
|
||||
* evicting expired entries, then dropping the oldest entries if the
|
||||
* store is still over capacity.
|
||||
*
|
||||
* @param now - Current epoch timestamp in milliseconds.
|
||||
*/
|
||||
const ensureStoreCapacity = (now: number): void => {
|
||||
if (rateLimitStore.size < MAX_STORE_ENTRIES) return;
|
||||
|
||||
evictExpiredEntries(now);
|
||||
while (rateLimitStore.size >= MAX_STORE_ENTRIES) {
|
||||
const oldestKey = rateLimitStore.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
rateLimitStore.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether the given key (typically a client IP) has exceeded
|
||||
* the allowed rate limit.
|
||||
*
|
||||
* On the first request within a window the entry is created and the
|
||||
* caller is allowed through. Subsequent requests increment the
|
||||
* counter. Returns `false` (and logs a warning) when the counter
|
||||
* exceeds the configured maximum.
|
||||
*
|
||||
* @param key - The key to check (e.g. a client IP address).
|
||||
* @returns `true` if the request is within the limit, `false` if
|
||||
* rate-limited.
|
||||
*/
|
||||
export const checkRateLimit = (key: string): boolean => {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitStore.get(key);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
ensureStoreCapacity(now);
|
||||
rateLimitStore.set(key, {
|
||||
count: 1,
|
||||
resetTime: now + config.rateLimitWindowMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.count >= config.rateLimitMaxRequests) {
|
||||
logger.warn('Rate limit exceeded', { key, count: entry.count });
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware that wraps a request handler with rate-limiting based
|
||||
* on the client IP address.
|
||||
*
|
||||
* When the client has exceeded the allowed number of requests within
|
||||
* the configured window a 429 Too Many Requests response is returned.
|
||||
*
|
||||
* @typeParam T - The request type (must extend `Request`).
|
||||
* @param handler - The request handler to protect.
|
||||
* @returns A wrapped handler that applies rate-limiting.
|
||||
*/
|
||||
export const withRateLimit = <T extends Request>(
|
||||
handler: (req: T) => Promise<Response>,
|
||||
): ((req: T) => Promise<Response>) => {
|
||||
return async (req: T): Promise<Response> => {
|
||||
const ip = extractClientIp(req, config.trustProxy);
|
||||
if (!checkRateLimit(ip)) {
|
||||
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||||
}
|
||||
|
||||
return handler(req);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually evicts all expired entries from the rate-limit cache and
|
||||
* logs a debug message with the count of removed entries.
|
||||
*/
|
||||
export const cleanupRateLimitCache = (): void => {
|
||||
const cleaned = evictExpiredEntries();
|
||||
|
||||
if (cleaned > 0) {
|
||||
logger.debug('Rate limit cache cleanup', { cleaned, remaining: rateLimitStore.size });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns diagnostic statistics about the current state of the
|
||||
* rate-limit store.
|
||||
*
|
||||
* @returns An object with tracked-IP count, configured window size,
|
||||
* max requests, and max tracked entries.
|
||||
*/
|
||||
export const getRateLimitStats = () => ({
|
||||
trackedIPs: rateLimitStore.size,
|
||||
windowSize: config.rateLimitWindowMs,
|
||||
maxRequests: config.rateLimitMaxRequests,
|
||||
maxTrackedIPs: MAX_STORE_ENTRIES,
|
||||
});
|
||||
|
||||
/**
|
||||
* Clears all entries from the rate-limit cache (used primarily in
|
||||
* tests).
|
||||
*/
|
||||
export const clearRateLimitCache = (): void => {
|
||||
rateLimitStore.clear();
|
||||
};
|
||||
Reference in New Issue
Block a user