auth-rate-limit.ts245 lines · main
1import { createHash } from 'node:crypto';
2
3import { Redis } from 'ioredis';
4
5import { log } from '../lib/logger.js';
6import { env } from '../env.js';
7import {
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
29interface Bucket {
30 count: number;
31 resetAt: number;
32}
33
34const store = new Map<string, Bucket>();
35
36/** Background cleanup every 60s to prevent unbounded memory growth. */
37setInterval(() => {
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
51function hashKey(raw: string): string {
52 return createHash('sha256').update(raw).digest('hex').slice(0, 32);
53}
54
55function makeBucketKey(type: 'ip' | 'email', projectId: string, identifier: string): string {
56 return `${type}:${projectId}:${hashKey(identifier)}`;
57}
58
59function redisKey(type: 'ip' | 'email', projectId: string, identifier: string): string {
60 return `briven:rl:${type}:${projectId}:${hashKey(identifier)}`;
61}
62
63export 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
73let redisClient: Redis | null = null;
74
75function 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
92async 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
135async 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
144function 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. */
187let memoryFallbackNoted = false;
188
189export 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 */
213export 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 */
224export 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 */
235export 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}