From e40dfd8084eebf93598d7e7da72886d870536faf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:06:38 +0700 Subject: [PATCH] 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) --- src/interfaces/config/index.ts | 8 + src/interfaces/http/middleware/auth.ts | 357 +++++++++++++++++++ src/interfaces/http/middleware/rate-limit.ts | 146 ++++++++ 3 files changed, 511 insertions(+) create mode 100644 src/interfaces/config/index.ts create mode 100644 src/interfaces/http/middleware/auth.ts create mode 100644 src/interfaces/http/middleware/rate-limit.ts diff --git a/src/interfaces/config/index.ts b/src/interfaces/config/index.ts new file mode 100644 index 0000000..fde6f70 --- /dev/null +++ b/src/interfaces/config/index.ts @@ -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'; diff --git a/src/interfaces/http/middleware/auth.ts b/src/interfaces/http/middleware/auth.ts new file mode 100644 index 0000000..0468a37 --- /dev/null +++ b/src/interfaces/http/middleware/auth.ts @@ -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; + +/** + * 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 + * `.`. 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 = {}, +): 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; + 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 = {}, +): 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 = {}, +): ((req: Request) => Promise) => { + return async (req: Request): Promise => { + const secret = getSecret(options.secret); + if (!isAuthEnabled(secret)) { + return handler(req); + } + + const session = getAuthSession(req, options); + if (!session) { + return unauthorizedResponse(); + } + + return handler(req); + }; +}; diff --git a/src/interfaces/http/middleware/rate-limit.ts b/src/interfaces/http/middleware/rate-limit.ts new file mode 100644 index 0000000..ecb3761 --- /dev/null +++ b/src/interfaces/http/middleware/rate-limit.ts @@ -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(); +/** 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 = ( + handler: (req: T) => Promise, +): ((req: T) => Promise) => { + return async (req: T): Promise => { + 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(); +};