auth-rate-limit.ts245 lines · main
| 1 | import { createHash } from 'node:crypto'; |
| 2 | |
| 3 | import { Redis } from 'ioredis'; |
| 4 | |
| 5 | import { log } from '../lib/logger.js'; |
| 6 | import { env } from '../env.js'; |
| 7 | import { |
| 8 | recordAuthRateLimitDenied, |
| 9 | recordAuthRateLimitMemoryFallback, |
| 10 | } from './auth-reliability.js'; |
| 11 | |
| 12 | /** |
| 13 | * Rate limiter for briven auth endpoints. |
| 14 | * |
| 15 | * Uses Redis when `BRIVEN_REDIS_URL` is configured; falls back to an |
| 16 | * in-memory per-key counter map with automatic expiry. Under horizontal |
| 17 | * scale the Redis store shares counters across processes, while the |
| 18 | * in-memory store is process-local (effective limit = n_processes * limit). |
| 19 | * |
| 20 | * Keys: |
| 21 | * - `ip:<projectId>:<ip>` → per-IP per-project |
| 22 | * - `email:<projectId>:<email>` → per-email per-project |
| 23 | * |
| 24 | * Window: fixed sliding window in minutes. A counter is valid from |
| 25 | * creation until `windowMs` later; once the window slides past, |
| 26 | * the counter resets (Redis TTL or in-memory expiry). |
| 27 | */ |
| 28 | |
| 29 | interface Bucket { |
| 30 | count: number; |
| 31 | resetAt: number; |
| 32 | } |
| 33 | |
| 34 | const store = new Map<string, Bucket>(); |
| 35 | |
| 36 | /** Background cleanup every 60s to prevent unbounded memory growth. */ |
| 37 | setInterval(() => { |
| 38 | const now = Date.now(); |
| 39 | let cleaned = 0; |
| 40 | for (const [key, bucket] of store.entries()) { |
| 41 | if (bucket.resetAt <= now) { |
| 42 | store.delete(key); |
| 43 | cleaned++; |
| 44 | } |
| 45 | } |
| 46 | if (cleaned > 0) { |
| 47 | log.debug('rate_limiter_cleanup', { cleaned, remaining: store.size }); |
| 48 | } |
| 49 | }, 60_000); |
| 50 | |
| 51 | function hashKey(raw: string): string { |
| 52 | return createHash('sha256').update(raw).digest('hex').slice(0, 32); |
| 53 | } |
| 54 | |
| 55 | function makeBucketKey(type: 'ip' | 'email', projectId: string, identifier: string): string { |
| 56 | return `${type}:${projectId}:${hashKey(identifier)}`; |
| 57 | } |
| 58 | |
| 59 | function redisKey(type: 'ip' | 'email', projectId: string, identifier: string): string { |
| 60 | return `briven:rl:${type}:${projectId}:${hashKey(identifier)}`; |
| 61 | } |
| 62 | |
| 63 | export interface RateLimitResult { |
| 64 | allowed: boolean; |
| 65 | limit: number; |
| 66 | remaining: number; |
| 67 | resetAt: number; |
| 68 | retryAfterSeconds: number; |
| 69 | } |
| 70 | |
| 71 | // ─── Redis backing (lazy initialisation) ─────────────────────────────────── |
| 72 | |
| 73 | let redisClient: Redis | null = null; |
| 74 | |
| 75 | function getRedis(): Redis | null { |
| 76 | if (redisClient) return redisClient; |
| 77 | if (!env.BRIVEN_REDIS_URL) return null; |
| 78 | try { |
| 79 | redisClient = new Redis(env.BRIVEN_REDIS_URL, { |
| 80 | maxRetriesPerRequest: 3, |
| 81 | lazyConnect: true, |
| 82 | }); |
| 83 | redisClient.on('error', (err: Error) => { |
| 84 | log.warn('rate_limiter_redis_error', { message: err.message }); |
| 85 | }); |
| 86 | return redisClient; |
| 87 | } catch { |
| 88 | return null; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | async function checkRedisRateLimit( |
| 93 | key: string, |
| 94 | opts: { maxAttempts: number; windowMinutes: number }, |
| 95 | ): Promise<RateLimitResult> { |
| 96 | const redis = getRedis(); |
| 97 | if (!redis) { |
| 98 | // Should never happen (caller guards), but fail-open. |
| 99 | return { allowed: true, limit: opts.maxAttempts, remaining: opts.maxAttempts, resetAt: Date.now(), retryAfterSeconds: 0 }; |
| 100 | } |
| 101 | |
| 102 | const windowSeconds = opts.windowMinutes * 60; |
| 103 | const now = Date.now(); |
| 104 | |
| 105 | // Atomically increment; if the key was just created, set TTL. |
| 106 | const count = await redis.incr(key); |
| 107 | if (count === 1) { |
| 108 | await redis.expire(key, windowSeconds); |
| 109 | } |
| 110 | |
| 111 | const ttl = await redis.ttl(key); |
| 112 | const resetAt = now + Math.max(ttl, 0) * 1000; |
| 113 | |
| 114 | if (count > opts.maxAttempts) { |
| 115 | const retryAfter = Math.max(ttl, 0); |
| 116 | recordAuthRateLimitDenied('redis'); |
| 117 | return { |
| 118 | allowed: false, |
| 119 | limit: opts.maxAttempts, |
| 120 | remaining: 0, |
| 121 | resetAt, |
| 122 | retryAfterSeconds: retryAfter, |
| 123 | }; |
| 124 | } |
| 125 | |
| 126 | return { |
| 127 | allowed: true, |
| 128 | limit: opts.maxAttempts, |
| 129 | remaining: opts.maxAttempts - count, |
| 130 | resetAt, |
| 131 | retryAfterSeconds: 0, |
| 132 | }; |
| 133 | } |
| 134 | |
| 135 | async function resetRedisRateLimit(key: string): Promise<void> { |
| 136 | const redis = getRedis(); |
| 137 | if (redis) { |
| 138 | await redis.del(key); |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // ─── In-memory backing ───────────────────────────────────────────────────── |
| 143 | |
| 144 | function checkMemoryRateLimit( |
| 145 | key: string, |
| 146 | opts: { maxAttempts: number; windowMinutes: number }, |
| 147 | ): RateLimitResult { |
| 148 | const now = Date.now(); |
| 149 | const windowMs = opts.windowMinutes * 60_000; |
| 150 | |
| 151 | let bucket = store.get(key); |
| 152 | if (!bucket || bucket.resetAt <= now) { |
| 153 | bucket = { count: 0, resetAt: now + windowMs }; |
| 154 | } |
| 155 | |
| 156 | if (bucket.count >= opts.maxAttempts) { |
| 157 | const retryAfter = Math.ceil((bucket.resetAt - now) / 1000); |
| 158 | recordAuthRateLimitDenied('memory'); |
| 159 | return { |
| 160 | allowed: false, |
| 161 | limit: opts.maxAttempts, |
| 162 | remaining: 0, |
| 163 | resetAt: bucket.resetAt, |
| 164 | retryAfterSeconds: retryAfter, |
| 165 | }; |
| 166 | } |
| 167 | |
| 168 | bucket.count++; |
| 169 | store.set(key, bucket); |
| 170 | |
| 171 | return { |
| 172 | allowed: true, |
| 173 | limit: opts.maxAttempts, |
| 174 | remaining: opts.maxAttempts - bucket.count, |
| 175 | resetAt: bucket.resetAt, |
| 176 | retryAfterSeconds: 0, |
| 177 | }; |
| 178 | } |
| 179 | |
| 180 | // ─── Public API ──────────────────────────────────────────────────────────── |
| 181 | |
| 182 | /** |
| 183 | * Check whether an action is within the rate limit. If allowed, increments |
| 184 | * the counter atomically. If denied, the counter is NOT incremented. |
| 185 | */ |
| 186 | /** S6.2: count at most once per process so /metrics isn't flooded in dev. */ |
| 187 | let memoryFallbackNoted = false; |
| 188 | |
| 189 | export async function checkRateLimit( |
| 190 | type: 'ip' | 'email', |
| 191 | projectId: string, |
| 192 | identifier: string, |
| 193 | opts: { maxAttempts: number; windowMinutes: number }, |
| 194 | ): Promise<RateLimitResult> { |
| 195 | const redis = getRedis(); |
| 196 | if (redis) { |
| 197 | return checkRedisRateLimit(redisKey(type, projectId, identifier), opts); |
| 198 | } |
| 199 | // S6.2: no Redis → process-local limits (fail-open for multi-instance accuracy). |
| 200 | if (!memoryFallbackNoted) { |
| 201 | memoryFallbackNoted = true; |
| 202 | recordAuthRateLimitMemoryFallback(); |
| 203 | log.warn('auth_rate_limit_using_memory_fallback', { |
| 204 | reason: 'BRIVEN_REDIS_URL missing or redis client unavailable', |
| 205 | }); |
| 206 | } |
| 207 | return checkMemoryRateLimit(makeBucketKey(type, projectId, identifier), opts); |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * Convenience: check rate limit for an IP address. |
| 212 | */ |
| 213 | export async function checkIpRateLimit( |
| 214 | projectId: string, |
| 215 | ip: string, |
| 216 | opts: { maxAttempts: number; windowMinutes: number }, |
| 217 | ): Promise<RateLimitResult> { |
| 218 | return checkRateLimit('ip', projectId, ip, opts); |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * Convenience: check rate limit for an email address. |
| 223 | */ |
| 224 | export async function checkEmailRateLimit( |
| 225 | projectId: string, |
| 226 | email: string, |
| 227 | opts: { maxAttempts: number; windowMinutes: number }, |
| 228 | ): Promise<RateLimitResult> { |
| 229 | return checkRateLimit('email', projectId, email.toLowerCase().trim(), opts); |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * Reset a rate-limit bucket. Useful for admin unblocking or testing. |
| 234 | */ |
| 235 | export async function resetRateLimit( |
| 236 | type: 'ip' | 'email', |
| 237 | projectId: string, |
| 238 | identifier: string, |
| 239 | ): Promise<void> { |
| 240 | const redis = getRedis(); |
| 241 | if (redis) { |
| 242 | await resetRedisRateLimit(redisKey(type, projectId, identifier)); |
| 243 | } |
| 244 | store.delete(makeBucketKey(type, projectId, identifier)); |
| 245 | } |