Skip to content
File

Blob: src/worker/api/sign-in.ts

typescript91 lines
1import { isNonEmptyString, readJsonBody, remoteIp } from "@/worker/api/request";
2import type { AppContext } from "@/worker/hono";
3import { HttpError } from "@/worker/http";
4import { enforceRateLimit, rateLimitResponse } from "@/worker/middleware/rate-limit";
5import { verifyTurnstileToken } from "@/worker/services/turnstile";
6 
7interface SignInBody {
8 email?: unknown;
9 oauth_query?: unknown;
10 password?: unknown;
11 turnstileToken?: unknown;
12}
13 
14export const handleSignIn = async (c: AppContext): Promise<Response> => {
15 const logger = c.var.log.child({ component: "sign-in" });
16 const ip = remoteIp(c);
17 const body = await readJsonBody<SignInBody>(c, "Request body must be JSON.");
18 const email = isNonEmptyString(body.email) ? body.email.trim().toLowerCase() : "";
19 
20 if (!email) {
21 throw new HttpError(400, "invalid_body", "email, password, and turnstileToken are required.");
22 }
23 
24 // IP bucket is the documented primary limiter (README/phase-1-design):
25 // it stops a single IP from spraying many target emails. Run it before
26 // Turnstile so we don't burn an external siteverify call on a clearly
27 // abusive source.
28 const ipDecision = await enforceRateLimit(c.var.log, c.env.RL_AUTH, "sign-in:ip", ip);
29 if (!ipDecision.allowed) {
30 logger.warn("sign_in_rate_limited", { bucket: "ip", retryAfterSeconds: ipDecision.retryAfterSeconds });
31 return rateLimitResponse(ipDecision);
32 }
33 
34 if (!isNonEmptyString(body.password) || !isNonEmptyString(body.turnstileToken)) {
35 throw new HttpError(400, "invalid_body", "email, password, and turnstileToken are required.");
36 }
37 
38 const verification = await verifyTurnstileToken(c.env, {
39 expectedAction: "sign-in",
40 remoteIp: ip,
41 requestUrl: c.req.url,
42 token: body.turnstileToken,
43 });
44 if (!verification.ok) {
45 logger.warn("sign_in_turnstile_rejected", {
46 reason: verification.reason,
47 status: verification.status,
48 });
49 throw new HttpError(verification.status, "turnstile_failed", verification.message, {
50 reason: verification.reason,
51 });
52 }
53 
54 // Per-email bucket caps targeted brute-force on a single account. Runs
55 // AFTER Turnstile so an attacker can't deny a victim's sign-in by
56 // burning their bucket without solving the challenge.
57 const emailDecision = await enforceRateLimit(c.var.log, c.env.RL_AUTH, "sign-in:email", email);
58 if (!emailDecision.allowed) {
59 logger.warn("sign_in_rate_limited", { bucket: "email", retryAfterSeconds: emailDecision.retryAfterSeconds });
60 return rateLimitResponse(emailDecision);
61 }
62 
63 // Better Auth's raw email endpoint validates the credential, creates a
64 // session, and returns a Response carrying the session Set-Cookie header.
65 // Use the handler rather than auth.api.signInEmail so the oauth-provider
66 // post-login hook has a real Request when it resumes a signed /authorize
67 // flow from oauth_query.
68 const signInBody = { email, password: body.password };
69 if (isNonEmptyString(body.oauth_query)) {
70 (signInBody as Record<string, unknown>).oauth_query = body.oauth_query;
71 }
72 const headers = new Headers(c.req.raw.headers);
73 headers.set("content-type", "application/json");
74 headers.delete("content-length");
75 const url = new URL("/api/auth/sign-in/email", c.req.url);
76 const response = await c.var.auth.handler(
77 new Request(url, {
78 body: JSON.stringify(signInBody),
79 headers,
80 method: "POST",
81 }),
82 );
83 const fields = { status: response.status };
84 if (response.ok) {
85 logger.info("sign_in_completed", fields);
86 } else {
87 logger.warn("sign_in_failed", fields);
88 }
89 return response;
90};