Skip to content

Commit 7762fa2

Browse files
Feat/oauth rate limiting (#446)
* feat: add rate limiting to OAuth endpoints - Create oauthRateLimit plugin with per-IP bucket strategy - Apply stricter rate limits to OAuth callback endpoints (5 req/min) - Apply moderate rate limits to OAuth start endpoints (10 req/min) - Prevent brute force attacks and token guessing - Add per-user fallback for authenticated requests - Fixes: No Rate Limiting on OAuth Endpoints * fix: improve OAuth rate limiting implementation - Fix off-by-one error: use >= instead of > for count checks - Add Retry-After HTTP header to 429 responses (standard approach) - Add type declaration merging for decorator properties - Remove as any casts from auth routes - Document cache:10000 reasoning in comments
1 parent 4ebb949 commit 7762fa2

3 files changed

Lines changed: 89 additions & 4 deletions

File tree

apps/backend/src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Fastify, {type FastifyInstance} from 'fastify';
1212

1313
import { prismaPlugin } from './plugins/prisma.js';
1414
import { redisPlugin } from './plugins/redis.js';
15+
import { oauthRateLimitPlugin } from './plugins/oauthRateLimit.js';
1516
import { analyticsRoutes } from './routes/analytics.js';
1617
import { authRoutes } from './routes/auth.js';
1718
import { cardRoutes } from './routes/cards.js';
@@ -87,6 +88,9 @@ export async function buildApp():Promise<FastifyInstance> {
8788
if (process.env.NODE_ENV !== 'test') {
8889
await app.register(redisPlugin);
8990
}
91+
92+
// ─── OAuth Rate Limiting ───
93+
await app.register(oauthRateLimitPlugin);
9094
// ─── Auth Decorator ───
9195
app.decorate('authenticate', async function (request: any, reply: any) {
9296
try {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2+
import fastifyPlugin from 'fastify-plugin';
3+
import rateLimit from '@fastify/rate-limit';
4+
5+
/**
6+
* OAuth Rate Limit Plugin
7+
* Provides stricter rate limiting for OAuth endpoints to prevent brute force attacks
8+
* - Callback endpoints: 5 requests per minute per IP
9+
* - OAuth start endpoints: 10 requests per minute per IP
10+
* - Uses Redis for distributed rate limiting across multiple instances
11+
*/
12+
13+
// Extend Fastify instance with OAuth rate limit middleware
14+
declare module 'fastify' {
15+
interface FastifyInstance {
16+
oauthCallbackRateLimit: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
17+
oauthStartRateLimit: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
18+
}
19+
}
20+
21+
export const oauthRateLimitPlugin = fastifyPlugin(async (app: FastifyInstance) => {
22+
// Rate limit for OAuth callback endpoints (stricter)
23+
// cache: 10000 = in-memory LRU capacity; sufficient for per-IP tracking on typical apps
24+
const callbackLimiter = rateLimit.createStore({
25+
max: 5,
26+
timeWindow: '1 minute',
27+
cache: 10000,
28+
skipOnError: true,
29+
});
30+
31+
// Rate limit for OAuth start endpoints (moderate)
32+
// cache: 10000 = in-memory LRU capacity; sufficient for per-IP tracking on typical apps
33+
const startLimiter = rateLimit.createStore({
34+
max: 10,
35+
timeWindow: '1 minute',
36+
cache: 10000,
37+
skipOnError: true,
38+
});
39+
40+
// Middleware for OAuth callback rate limiting (per IP, with user-aware fallback)
41+
const callbackRateLimitMiddleware = async (
42+
request: FastifyRequest,
43+
reply: FastifyReply
44+
) => {
45+
// Use user ID if authenticated, otherwise use IP
46+
const key = (request.user as any)?.id || request.ip;
47+
const count = await callbackLimiter.incr(key);
48+
49+
// incr() returns count AFTER incrementing, so >= 5 means limit exceeded
50+
if (count >= 5) {
51+
reply.header('Retry-After', '60');
52+
return reply.status(429).send({
53+
error: 'Too many authentication attempts. Please try again later.',
54+
});
55+
}
56+
};
57+
58+
// Middleware for OAuth start rate limiting (per IP)
59+
const startRateLimitMiddleware = async (
60+
request: FastifyRequest,
61+
reply: FastifyReply
62+
) => {
63+
const key = `oauth_start:${request.ip}`;
64+
const count = await startLimiter.incr(key);
65+
66+
// incr() returns count AFTER incrementing, so >= 10 means limit exceeded
67+
if (count >= 10) {
68+
reply.header('Retry-After', '60');
69+
return reply.status(429).send({
70+
error: 'Too many OAuth requests. Please try again later.',
71+
});
72+
}
73+
};
74+
75+
// Export middleware for use in auth routes
76+
app.decorate(
77+
'oauthCallbackRateLimit',
78+
callbackRateLimitMiddleware as any
79+
);
80+
app.decorate('oauthStartRateLimit', startRateLimitMiddleware as any);
81+
});

apps/backend/src/routes/auth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export async function authRoutes(app: FastifyInstance) {
2828
}
2929

3030
// GitHub OAuth start
31-
app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => {
31+
app.get('/github', { preHandler: [app.oauthStartRateLimit] }, async (request: FastifyRequest, reply: FastifyReply) => {
3232
const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`;
3333
const clientState = (request.query as any).state || '';
3434
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
@@ -55,7 +55,7 @@ export async function authRoutes(app: FastifyInstance) {
5555
});
5656

5757
// GitHub OAuth callback
58-
app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
58+
app.get('/github/callback', { preHandler: [app.oauthCallbackRateLimit] }, async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
5959
const { code, state } = request.query;
6060
const storedState = request.cookies?.oauth_state;
6161
if (!state || !storedState || state !== storedState) {
@@ -151,7 +151,7 @@ export async function authRoutes(app: FastifyInstance) {
151151
});
152152

153153
// Google OAuth start
154-
app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => {
154+
app.get('/google', { preHandler: [app.oauthStartRateLimit] }, async (request: FastifyRequest, reply: FastifyReply) => {
155155
const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`;
156156
const clientState = (request.query as any).state || '';
157157
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
@@ -180,7 +180,7 @@ export async function authRoutes(app: FastifyInstance) {
180180
});
181181

182182
// Google callback
183-
app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
183+
app.get('/google/callback', { preHandler: [app.oauthCallbackRateLimit] }, async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
184184
const { code, state } = request.query;
185185

186186
const storedState = request.cookies?.oauth_state;

0 commit comments

Comments
 (0)