import { data } from "react-router"; import { getOrigin } from "~/lib/config.server"; import type { Route } from "./+types/api.auth.login"; import { startAuthentication, finishAuthentication, createMagicToken, verifyLoginCode } from "~/lib/auth.server"; import { completeAuth } from "~/lib/auth/completion.server"; import { sendMagicLink } from "~/lib/email.server"; import { consumeRateLimit, clientIp } from "~/lib/rate-limit.server"; function tooManyRequests(resetMs: number) { return data( { error: `Too many attempts. Try again in ${Math.ceil(resetMs / 1000)}s.` }, { status: 429, headers: { "Retry-After": String(Math.ceil(resetMs / 1000)) } }, ); } export async function action({ request }: Route.ActionArgs) { const body = await request.json(); const { step, response, challenge, email, code, returnTo } = body; const ip = clientIp(request); // Per-IP cap on every auth attempt regardless of step — defends // against a single attacker fan-out across step types. const ipLimit = consumeRateLimit({ scope: "auth-attempt-ip", key: ip, limit: 30, windowMs: 60_000, }); if (!ipLimit.allowed) return tooManyRequests(ipLimit.resetMs); try { if (step === "start-passkey") { const options = await startAuthentication(); return data({ step: "challenge", options }); } if (step === "finish-passkey") { // Tighter per-IP cap on credential-consuming steps — passkey // assertions don't usefully retry that fast. const verify = consumeRateLimit({ scope: "passkey-verify", key: ip, limit: 10, windowMs: 60_000, }); if (!verify.allowed) return tooManyRequests(verify.resetMs); const userId = await finishAuthentication(response, challenge); return completeAuth({ userId, request, returnTo, mode: "json" }); } if (step === "magic-link") { // Cap magic-link generation per email so an attacker can't spam a // victim's inbox with codes, and per IP so a single client can't // fan out across many addresses. if (typeof email === "string" && email.length > 0) { const perEmail = consumeRateLimit({ scope: "magic-link-email", key: email.toLowerCase(), limit: 3, windowMs: 5 * 60_000, }); if (!perEmail.allowed) return tooManyRequests(perEmail.resetMs); } const perIp = consumeRateLimit({ scope: "magic-link-ip", key: ip, limit: 10, windowMs: 5 * 60_000, }); if (!perIp.allowed) return tooManyRequests(perIp.resetMs); const { token, code: loginCode } = await createMagicToken(email); const origin = getOrigin(); const link = `${origin}/auth/verify?token=${token}`; // In dev, return the link and code directly if (process.env.NODE_ENV !== "production") { console.log(`[Magic Link] ${email}: ${link} (code: ${loginCode})`); return data({ step: "magic-link-sent", devLink: link, code: loginCode }); } await sendMagicLink(email, link, loginCode); return data({ step: "magic-link-sent" }); } if (step === "verify-code") { if (!email || !code) { return data({ error: "Email and code are required" }, { status: 400 }); } // Per-email cap to defeat 6-digit code brute force — 10 attempts // per 15 minutes gives legitimate users plenty of room while making // a brute-force search (~10^6 codes) infeasible before the code // expires. const codeLimit = consumeRateLimit({ scope: "verify-code", key: String(email).toLowerCase(), limit: 10, windowMs: 15 * 60_000, }); if (!codeLimit.allowed) return tooManyRequests(codeLimit.resetMs); const userId = await verifyLoginCode(email, code); return completeAuth({ userId, request, returnTo, mode: "json" }); } return data({ error: "Invalid step" }, { status: 400 }); } catch (e) { return data({ error: (e as Error).message }, { status: 400 }); } }