diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 0f8f2f5..1e82856 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -4,6 +4,9 @@ import { checkRateLimit } from '@/lib/rate-limit' const AUTH_RATE_LIMIT = 10 // requests per window const AUTH_RATE_WINDOW_MS = 60 * 1000 // 1 minute +const CSRF_RATE_LIMIT = 20 // requests per window +const CSRF_RATE_WINDOW_MS = 15 * 60 * 1000 // 15 minutes + function getClientIp(req: Request): string { return ( req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || @@ -12,15 +15,35 @@ function getClientIp(req: Request): string { ) } -function withRateLimit(handler: (req: Request) => Promise) { +function withPostRateLimit(handler: (req: Request) => Promise) { return async (req: Request) => { - // Only rate limit POST requests (sign-in, magic link sends) - if (req.method === 'POST') { + const ip = getClientIp(req) + const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS) + + if (!success) { + return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)), + }, + }) + } + + return handler(req) + } +} + +function withGetRateLimit(handler: (req: Request) => Promise) { + return async (req: Request) => { + // Rate-limit the CSRF token endpoint to prevent token farming + const url = new URL(req.url) + if (url.pathname.endsWith('/csrf')) { const ip = getClientIp(req) - const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS) + const { success, resetAt } = checkRateLimit(`csrf:${ip}`, CSRF_RATE_LIMIT, CSRF_RATE_WINDOW_MS) if (!success) { - return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), { + return new Response(JSON.stringify({ error: 'Too many requests' }), { status: 429, headers: { 'Content-Type': 'application/json', @@ -34,5 +57,5 @@ function withRateLimit(handler: (req: Request) => Promise) { } } -export const GET = handlers.GET -export const POST = withRateLimit(handlers.POST as (req: Request) => Promise) +export const GET = withGetRateLimit(handlers.GET as (req: Request) => Promise) +export const POST = withPostRateLimit(handlers.POST as (req: Request) => Promise) diff --git a/src/server/services/webhook-dispatcher.ts b/src/server/services/webhook-dispatcher.ts index 365bb1c..b0b49b8 100644 --- a/src/server/services/webhook-dispatcher.ts +++ b/src/server/services/webhook-dispatcher.ts @@ -2,6 +2,46 @@ import crypto from 'crypto' import { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' +/** + * MOPC Webhook Signature Verification + * ==================================== + * + * Every outbound webhook delivery is signed with HMAC-SHA256 using the + * webhook's shared secret. The signature is sent in the `X-Webhook-Signature` + * header with a `sha256=` prefix. + * + * Additional headers included with each delivery: + * - X-Webhook-Event: the event type (e.g. "evaluation.submitted") + * - X-Webhook-Delivery: unique delivery ID (UUID) + * + * To verify a delivery on the consumer side: + * + * // Node.js example + * const crypto = require('crypto'); + * + * function verifySignature(secret, body, signatureHeader) { + * const expected = 'sha256=' + crypto + * .createHmac('sha256', secret) + * .update(body, 'utf8') // raw request body string + * .digest('hex'); + * return crypto.timingSafeEqual( + * Buffer.from(expected), + * Buffer.from(signatureHeader), + * ); + * } + * + * // In your handler: + * const sig = req.headers['x-webhook-signature']; + * if (!verifySignature(WEBHOOK_SECRET, rawBody, sig)) { + * return res.status(401).send('Invalid signature'); + * } + * + * IMPORTANT: + * - Always verify against the raw request body (before JSON parsing). + * - Use timing-safe comparison to prevent timing attacks. + * - The secret can be regenerated via the admin UI (Settings → Webhooks). + */ + /** * Dispatch a webhook event to all active webhooks subscribed to this event. */