import { isNonEmptyString, readJsonBody, remoteIp } from "@/worker/api/request"; import type { AppContext } from "@/worker/hono"; import { HttpError } from "@/worker/http"; import { enforceRateLimit, rateLimitResponse } from "@/worker/middleware/rate-limit"; import { verifyTurnstileToken } from "@/worker/services/turnstile"; interface SignInBody { email?: unknown; oauth_query?: unknown; password?: unknown; turnstileToken?: unknown; } export const handleSignIn = async (c: AppContext): Promise => { const logger = c.var.log.child({ component: "sign-in" }); const ip = remoteIp(c); const body = await readJsonBody(c, "Request body must be JSON."); const email = isNonEmptyString(body.email) ? body.email.trim().toLowerCase() : ""; if (!email) { throw new HttpError(400, "invalid_body", "email, password, and turnstileToken are required."); } // IP bucket is the documented primary limiter (README/phase-1-design): // it stops a single IP from spraying many target emails. Run it before // Turnstile so we don't burn an external siteverify call on a clearly // abusive source. const ipDecision = await enforceRateLimit(c.var.log, c.env.RL_AUTH, "sign-in:ip", ip); if (!ipDecision.allowed) { logger.warn("sign_in_rate_limited", { bucket: "ip", retryAfterSeconds: ipDecision.retryAfterSeconds }); return rateLimitResponse(ipDecision); } if (!isNonEmptyString(body.password) || !isNonEmptyString(body.turnstileToken)) { throw new HttpError(400, "invalid_body", "email, password, and turnstileToken are required."); } const verification = await verifyTurnstileToken(c.env, { expectedAction: "sign-in", remoteIp: ip, requestUrl: c.req.url, token: body.turnstileToken, }); if (!verification.ok) { logger.warn("sign_in_turnstile_rejected", { reason: verification.reason, status: verification.status, }); throw new HttpError(verification.status, "turnstile_failed", verification.message, { reason: verification.reason, }); } // Per-email bucket caps targeted brute-force on a single account. Runs // AFTER Turnstile so an attacker can't deny a victim's sign-in by // burning their bucket without solving the challenge. const emailDecision = await enforceRateLimit(c.var.log, c.env.RL_AUTH, "sign-in:email", email); if (!emailDecision.allowed) { logger.warn("sign_in_rate_limited", { bucket: "email", retryAfterSeconds: emailDecision.retryAfterSeconds }); return rateLimitResponse(emailDecision); } // Better Auth's raw email endpoint validates the credential, creates a // session, and returns a Response carrying the session Set-Cookie header. // Use the handler rather than auth.api.signInEmail so the oauth-provider // post-login hook has a real Request when it resumes a signed /authorize // flow from oauth_query. const signInBody = { email, password: body.password }; if (isNonEmptyString(body.oauth_query)) { (signInBody as Record).oauth_query = body.oauth_query; } const headers = new Headers(c.req.raw.headers); headers.set("content-type", "application/json"); headers.delete("content-length"); const url = new URL("/api/auth/sign-in/email", c.req.url); const response = await c.var.auth.handler( new Request(url, { body: JSON.stringify(signInBody), headers, method: "POST", }), ); const fields = { status: response.status }; if (response.ok) { logger.info("sign_in_completed", fields); } else { logger.warn("sign_in_failed", fields); } return response; };