File
Blob: src/worker/api/sign-in-social.ts
| 1 | import { isNonEmptyString, readJsonBody, remoteIp } from "@/worker/api/request"; |
| 2 | import type { AppContext } from "@/worker/hono"; |
| 3 | import { HttpError } from "@/worker/http"; |
| 4 | import { enforceRateLimit, rateLimitResponse } from "@/worker/middleware/rate-limit"; |
| 5 | import { verifyTurnstileToken } from "@/worker/services/turnstile"; |
| 6 | import { isSafeLocalPath } from "@/worker/services/url"; |
| 7 | |
| 8 | interface SignInSocialBody { |
| 9 | provider?: unknown; |
| 10 | callbackURL?: unknown; |
| 11 | errorCallbackURL?: unknown; |
| 12 | oauth_query?: unknown; |
| 13 | turnstileToken?: unknown; |
| 14 | } |
| 15 | |
| 16 | const 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. |
| 29 | export 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. |
| 51 | export 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 | }; |