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"; import { isSafeLocalPath } from "@/worker/services/url"; interface SignInSocialBody { provider?: unknown; callbackURL?: unknown; errorCallbackURL?: unknown; oauth_query?: unknown; turnstileToken?: unknown; } const ALLOWED_PROVIDERS = new Set(["github", "google"]); // Synthetic body shape forwarded to Better Auth's /sign-in/social. // Exposed as a standalone helper so the wrapper's contract with // oauth-provider can be unit-tested without driving a full IdP // round-trip. // // Invariant: when a signed `oauth_query` is supplied, it MUST land at // the top level of the upstream body. oauth-provider's before-hook // reads `ctx.body.oauth_query`, verifies the sig/exp, strips them, and // stores the cleaned query under `ctx.body.additionalData.query`. // Forwarding the value via `additionalData.oauth_query` skips the hook // and silently drops the RP-initiated authorize flow. export const buildUpstreamSocialBody = (input: { provider: string; callbackURL?: string; errorCallbackURL?: string; oauth_query?: string; }): Record => { const body: Record = { provider: input.provider }; if (input.callbackURL) body.callbackURL = input.callbackURL; if (input.errorCallbackURL) body.errorCallbackURL = input.errorCallbackURL; if (input.oauth_query) body.oauth_query = input.oauth_query; return body; }; // tessera-owned wrapper for Better Auth's social sign-in initiation. // Enforces Turnstile and the shared `sign-in:ip` rate-limit bucket before // delegating to Better Auth, matching the email wrapper's boundary so // neither path bypasses human-verification or per-IP throttling. // // Forwards via `c.var.auth.handler(new Request(...))` rather than calling // `auth.api.signInSocial` directly because the oauth-provider plugin's // before-hook reads the signed `oauth_query` off a real Request shape to // resume an RP-initiated /authorize flow. export const handleSignInSocial = async (c: AppContext): Promise => { const logger = c.var.log.child({ component: "sign-in-social" }); const ip = remoteIp(c); const body = await readJsonBody(c, "Request body must be JSON."); const provider = isNonEmptyString(body.provider) ? body.provider.trim().toLowerCase() : ""; if (!ALLOWED_PROVIDERS.has(provider)) { throw new HttpError(400, "invalid_body", "provider must be 'github' or 'google'."); } // Both callbacks are forwarded to Better Auth, which redirects to them // after the upstream provider round-trip. The React client normalizes // these to local paths before calling us, but a non-React caller could // smuggle an absolute URL — validate at the worker boundary so the // social flow can't become an open redirector. const callbackURL = isNonEmptyString(body.callbackURL) ? body.callbackURL.trim() : undefined; const errorCallbackURL = isNonEmptyString(body.errorCallbackURL) ? body.errorCallbackURL.trim() : undefined; if (callbackURL !== undefined && !isSafeLocalPath(callbackURL)) { throw new HttpError(400, "invalid_callback_url", "callbackURL must be a local path."); } if (errorCallbackURL !== undefined && !isSafeLocalPath(errorCallbackURL)) { throw new HttpError(400, "invalid_callback_url", "errorCallbackURL must be a local path."); } // IP bucket shared with email sign-in (same `sign-in:ip` key) so an // attacker cannot get a fresh budget by switching surfaces. const ipDecision = await enforceRateLimit(c.var.log, c.env.RL_AUTH, "sign-in:ip", ip); if (!ipDecision.allowed) { logger.warn("sign_in_social_rate_limited", { provider, retryAfterSeconds: ipDecision.retryAfterSeconds }); return rateLimitResponse(ipDecision); } if (!isNonEmptyString(body.turnstileToken)) { throw new HttpError(400, "invalid_body", "turnstileToken is 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_social_turnstile_rejected", { provider, reason: verification.reason, status: verification.status, }); throw new HttpError(verification.status, "turnstile_failed", verification.message, { reason: verification.reason, }); } const upstreamBody = buildUpstreamSocialBody({ provider, callbackURL, errorCallbackURL, oauth_query: isNonEmptyString(body.oauth_query) ? body.oauth_query : undefined, }); 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/social", c.req.url); const response = await c.var.auth.handler( new Request(url, { body: JSON.stringify(upstreamBody), headers, method: "POST", }), ); if (response.ok) { logger.info("sign_in_social_initiated", { provider, status: response.status }); } else { logger.warn("sign_in_social_failed", { provider, status: response.status }); } return response; };