export interface RateLimitDecision { allowed: boolean; retryAfterSeconds: number; } interface Bucket { startedAt: number; count: number; } export interface FixedWindowRateLimiterOptions { maxRequests: number; windowMs: number; maxKeys: number; now?: () => number; } export class FixedWindowRateLimiter { private readonly buckets = new Map(); private readonly now: () => number; constructor(private readonly options: FixedWindowRateLimiterOptions) { if (!Number.isSafeInteger(options.maxRequests) || options.maxRequests < 1) throw new Error("Rate limit maxRequests must be positive"); if (!Number.isSafeInteger(options.windowMs) || options.windowMs < 1_000) throw new Error("Rate limit windowMs must be at least one second"); if (!Number.isSafeInteger(options.maxKeys) || options.maxKeys < 1 || options.maxKeys > 100_000) throw new Error("Rate limit maxKeys is invalid"); this.now = options.now ?? Date.now; } check(key: string): RateLimitDecision { const now = this.now(); this.prune(now); let bucket = this.buckets.get(key); if (!bucket) { if (this.buckets.size >= this.options.maxKeys) { return { allowed: false, retryAfterSeconds: Math.ceil(this.options.windowMs / 1_000) }; } bucket = { startedAt: now, count: 0 }; this.buckets.set(key, bucket); } const elapsed = now - bucket.startedAt; if (elapsed >= this.options.windowMs) { bucket.startedAt = now; bucket.count = 0; } bucket.count += 1; const retryAfterSeconds = Math.max(1, Math.ceil((bucket.startedAt + this.options.windowMs - now) / 1_000)); return { allowed: bucket.count <= this.options.maxRequests, retryAfterSeconds }; } private prune(now: number): void { for (const [key, bucket] of this.buckets) { if (now - bucket.startedAt >= this.options.windowMs) this.buckets.delete(key); } } } export interface OAuthRouteRateLimiter { login(clientKey: string): RateLimitDecision; callback(clientKey: string): RateLimitDecision; } export function createOAuthRouteRateLimiter(now: () => number = Date.now): OAuthRouteRateLimiter { const loginGlobal = new FixedWindowRateLimiter({ maxRequests: 30, windowMs: 10 * 60_000, maxKeys: 1, now }); const loginClient = new FixedWindowRateLimiter({ maxRequests: 6, windowMs: 10 * 60_000, maxKeys: 1_024, now }); const callbackGlobal = new FixedWindowRateLimiter({ maxRequests: 60, windowMs: 10 * 60_000, maxKeys: 1, now }); const callbackClient = new FixedWindowRateLimiter({ maxRequests: 12, windowMs: 10 * 60_000, maxKeys: 1_024, now }); return { login(clientKey) { return combine(loginGlobal.check("global"), loginClient.check(clientKey)); }, callback(clientKey) { return combine(callbackGlobal.check("global"), callbackClient.check(clientKey)); }, }; } function combine(global: RateLimitDecision, client: RateLimitDecision): RateLimitDecision { return { allowed: global.allowed && client.allowed, retryAfterSeconds: Math.max(global.retryAfterSeconds, client.retryAfterSeconds), }; }