Skip to content
File

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

typescript128 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";
6import { isSafeLocalPath } from "@/worker/services/url";
7 
8interface SignInSocialBody {
9 provider?: unknown;
10 callbackURL?: unknown;
11 errorCallbackURL?: unknown;
12 oauth_query?: unknown;
13 turnstileToken?: unknown;
14}
15 
16const ALLOWED_PROVIDERS = new Set(["github", "google"]);
17 
18// Synthetic body shape forwarded to Better Auth's /sign-in/social.
19// Exposed as a standalone helper so the wrapper's contract with
20// oauth-provider can be unit-tested without driving a full IdP
21// round-trip.
22//
23// Invariant: when a signed `oauth_query` is supplied, it MUST land at
24// the top level of the upstream body. oauth-provider's before-hook
25// reads `ctx.body.oauth_query`, verifies the sig/exp, strips them, and
26// stores the cleaned query under `ctx.body.additionalData.query`.
27// Forwarding the value via `additionalData.oauth_query` skips the hook
28// and silently drops the RP-initiated authorize flow.
29export const buildUpstreamSocialBody = (input: {
30 provider: string;
31 callbackURL?: string;
32 errorCallbackURL?: string;
33 oauth_query?: string;
34}): Record<string, unknown> => {
35 const body: Record<string, unknown> = { provider: input.provider };
36 if (input.callbackURL) body.callbackURL = input.callbackURL;
37 if (input.errorCallbackURL) body.errorCallbackURL = input.errorCallbackURL;
38 if (input.oauth_query) body.oauth_query = input.oauth_query;
39 return body;
40};
41 
42// tessera-owned wrapper for Better Auth's social sign-in initiation.
43// Enforces Turnstile and the shared `sign-in:ip` rate-limit bucket before
44// delegating to Better Auth, matching the email wrapper's boundary so
45// neither path bypasses human-verification or per-IP throttling.
46//
47// Forwards via `c.var.auth.handler(new Request(...))` rather than calling
48// `auth.api.signInSocial` directly because the oauth-provider plugin's
49// before-hook reads the signed `oauth_query` off a real Request shape to
50// resume an RP-initiated /authorize flow.
51export const handleSignInSocial = async (c: AppContext): Promise<Response> => {
52 const logger = c.var.log.child({ component: "sign-in-social" });
53 const ip = remoteIp(c);
54 const body = await readJsonBody<SignInSocialBody>(c, "Request body must be JSON.");
55 const provider = isNonEmptyString(body.provider) ? body.provider.trim().toLowerCase() : "";
56 if (!ALLOWED_PROVIDERS.has(provider)) {
57 throw new HttpError(400, "invalid_body", "provider must be 'github' or 'google'.");
58 }
59 
60 // Both callbacks are forwarded to Better Auth, which redirects to them
61 // after the upstream provider round-trip. The React client normalizes
62 // these to local paths before calling us, but a non-React caller could
63 // smuggle an absolute URL — validate at the worker boundary so the
64 // social flow can't become an open redirector.
65 const callbackURL = isNonEmptyString(body.callbackURL) ? body.callbackURL.trim() : undefined;
66 const errorCallbackURL = isNonEmptyString(body.errorCallbackURL) ? body.errorCallbackURL.trim() : undefined;
67 if (callbackURL !== undefined && !isSafeLocalPath(callbackURL)) {
68 throw new HttpError(400, "invalid_callback_url", "callbackURL must be a local path.");
69 }
70 if (errorCallbackURL !== undefined && !isSafeLocalPath(errorCallbackURL)) {
71 throw new HttpError(400, "invalid_callback_url", "errorCallbackURL must be a local path.");
72 }
73 
74 // IP bucket shared with email sign-in (same `sign-in:ip` key) so an
75 // attacker cannot get a fresh budget by switching surfaces.
76 const ipDecision = await enforceRateLimit(c.var.log, c.env.RL_AUTH, "sign-in:ip", ip);
77 if (!ipDecision.allowed) {
78 logger.warn("sign_in_social_rate_limited", { provider, retryAfterSeconds: ipDecision.retryAfterSeconds });
79 return rateLimitResponse(ipDecision);
80 }
81 
82 if (!isNonEmptyString(body.turnstileToken)) {
83 throw new HttpError(400, "invalid_body", "turnstileToken is required.");
84 }
85 
86 const verification = await verifyTurnstileToken(c.env, {
87 expectedAction: "sign-in",
88 remoteIp: ip,
89 requestUrl: c.req.url,
90 token: body.turnstileToken,
91 });
92 if (!verification.ok) {
93 logger.warn("sign_in_social_turnstile_rejected", {
94 provider,
95 reason: verification.reason,
96 status: verification.status,
97 });
98 throw new HttpError(verification.status, "turnstile_failed", verification.message, {
99 reason: verification.reason,
100 });
101 }
102 
103 const upstreamBody = buildUpstreamSocialBody({
104 provider,
105 callbackURL,
106 errorCallbackURL,
107 oauth_query: isNonEmptyString(body.oauth_query) ? body.oauth_query : undefined,
108 });
109 
110 const headers = new Headers(c.req.raw.headers);
111 headers.set("content-type", "application/json");
112 headers.delete("content-length");
113 const url = new URL("/api/auth/sign-in/social", c.req.url);
114 const response = await c.var.auth.handler(
115 new Request(url, {
116 body: JSON.stringify(upstreamBody),
117 headers,
118 method: "POST",
119 }),
120 );
121 if (response.ok) {
122 logger.info("sign_in_social_initiated", { provider, status: response.status });
123 } else {
124 logger.warn("sign_in_social_failed", { provider, status: response.status });
125 }
126 return response;
127};