Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Fastify, {type FastifyInstance} from 'fastify';

import { prismaPlugin } from './plugins/prisma.js';
import { redisPlugin } from './plugins/redis.js';
import { oauthRateLimitPlugin } from './plugins/oauthRateLimit.js';
import { analyticsRoutes } from './routes/analytics.js';
import { authRoutes } from './routes/auth.js';
import { cardRoutes } from './routes/cards.js';
Expand Down Expand Up @@ -87,6 +88,9 @@ export async function buildApp():Promise<FastifyInstance> {
if (process.env.NODE_ENV !== 'test') {
await app.register(redisPlugin);
}

// ─── OAuth Rate Limiting ───
await app.register(oauthRateLimitPlugin);
// ─── Auth Decorator ───
app.decorate('authenticate', async function (request: any, reply: any) {
try {
Expand Down
81 changes: 81 additions & 0 deletions apps/backend/src/plugins/oauthRateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import rateLimit from '@fastify/rate-limit';

/**
* OAuth Rate Limit Plugin
* Provides stricter rate limiting for OAuth endpoints to prevent brute force attacks
* - Callback endpoints: 5 requests per minute per IP
* - OAuth start endpoints: 10 requests per minute per IP
* - Uses Redis for distributed rate limiting across multiple instances
*/

// Extend Fastify instance with OAuth rate limit middleware
declare module 'fastify' {
interface FastifyInstance {
oauthCallbackRateLimit: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
oauthStartRateLimit: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
}

export const oauthRateLimitPlugin = fastifyPlugin(async (app: FastifyInstance) => {
// Rate limit for OAuth callback endpoints (stricter)
// cache: 10000 = in-memory LRU capacity; sufficient for per-IP tracking on typical apps
const callbackLimiter = rateLimit.createStore({
max: 5,
timeWindow: '1 minute',
cache: 10000,
skipOnError: true,
});

// Rate limit for OAuth start endpoints (moderate)
// cache: 10000 = in-memory LRU capacity; sufficient for per-IP tracking on typical apps
const startLimiter = rateLimit.createStore({
max: 10,
timeWindow: '1 minute',
cache: 10000,
skipOnError: true,
});

// Middleware for OAuth callback rate limiting (per IP, with user-aware fallback)
const callbackRateLimitMiddleware = async (
request: FastifyRequest,
reply: FastifyReply
) => {
// Use user ID if authenticated, otherwise use IP
const key = (request.user as any)?.id || request.ip;
const count = await callbackLimiter.incr(key);

// incr() returns count AFTER incrementing, so >= 5 means limit exceeded
if (count >= 5) {
reply.header('Retry-After', '60');
return reply.status(429).send({
error: 'Too many authentication attempts. Please try again later.',
});
}
};

// Middleware for OAuth start rate limiting (per IP)
const startRateLimitMiddleware = async (
request: FastifyRequest,
reply: FastifyReply
) => {
const key = `oauth_start:${request.ip}`;
const count = await startLimiter.incr(key);

// incr() returns count AFTER incrementing, so >= 10 means limit exceeded
if (count >= 10) {
reply.header('Retry-After', '60');
return reply.status(429).send({
error: 'Too many OAuth requests. Please try again later.',
});
}
};

// Export middleware for use in auth routes
app.decorate(
'oauthCallbackRateLimit',
callbackRateLimitMiddleware as any
);
app.decorate('oauthStartRateLimit', startRateLimitMiddleware as any);
});
8 changes: 4 additions & 4 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function authRoutes(app: FastifyInstance) {
}

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

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

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

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

const storedState = request.cookies?.oauth_state;
Expand Down