CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
google-oauth.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 582cdac | 1 | /** |
| 2 | * "Sign in with Google" network helpers. | |
| 3 | * | |
| 4 | * Mirrors the structure of `src/lib/github-oauth.ts`. Google is OIDC-compliant | |
| 5 | * (unlike GitHub's plain OAuth 2.0) but we use it as plain OAuth here for | |
| 6 | * symmetry with the existing GitHub flow. The `userinfo_endpoint` returns | |
| 7 | * a stable subject (`sub`) which we use as the link key. | |
| 8 | * | |
| 9 | * Every function is pure: no database access, no global mutable state. | |
| 10 | * `fetchImpl` is injectable so tests don't hit the real Google API. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { SsoConfig } from "../db/schema"; | |
| 14 | ||
| 15 | /** Override for tests — defaults to the global fetch. */ | |
| 16 | export type FetchImpl = typeof fetch; | |
| 17 | ||
| 18 | /** Build the Google authorize URL the browser should be redirected to. */ | |
| 19 | export function buildGoogleAuthorizeUrl( | |
| 20 | cfg: Pick<SsoConfig, "authorizationEndpoint" | "clientId" | "scopes">, | |
| 21 | state: string, | |
| 22 | redirectUri: string, | |
| 23 | nonce: string | |
| 24 | ): string { | |
| 25 | if (!cfg.authorizationEndpoint || !cfg.clientId) { | |
| 26 | throw new Error( | |
| 27 | "Google OAuth config missing authorization_endpoint or client_id" | |
| 28 | ); | |
| 29 | } | |
| 30 | const u = new URL(cfg.authorizationEndpoint); | |
| 31 | u.searchParams.set("client_id", cfg.clientId); | |
| 32 | u.searchParams.set("redirect_uri", redirectUri); | |
| 33 | u.searchParams.set("response_type", "code"); | |
| 34 | u.searchParams.set("scope", cfg.scopes || "openid email profile"); | |
| 35 | u.searchParams.set("state", state); | |
| 36 | u.searchParams.set("nonce", nonce); | |
| 37 | // `access_type=online` — we only need a one-shot sign-in, no offline refresh. | |
| 38 | u.searchParams.set("access_type", "online"); | |
| 39 | // `prompt=select_account` — show Google's account picker so users with | |
| 40 | // multiple Google accounts can choose. Without this Google sometimes | |
| 41 | // silently uses the most-recent account, which surprises users. | |
| 42 | u.searchParams.set("prompt", "select_account"); | |
| 43 | return u.toString(); | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Exchange the authorization code for an access_token. Google returns | |
| 48 | * JSON natively, so this is simpler than GitHub's exchange. | |
| 49 | */ | |
| 50 | export async function exchangeGoogleCode( | |
| 51 | cfg: Pick<SsoConfig, "tokenEndpoint" | "clientId" | "clientSecret">, | |
| 52 | code: string, | |
| 53 | redirectUri: string, | |
| 54 | fetchImpl: FetchImpl = fetch | |
| 55 | ): Promise<{ accessToken: string; idToken: string | null }> { | |
| 56 | if (!cfg.tokenEndpoint || !cfg.clientId || !cfg.clientSecret) { | |
| 57 | throw new Error( | |
| 58 | "Google OAuth config missing token_endpoint or client credentials" | |
| 59 | ); | |
| 60 | } | |
| 61 | const body = new URLSearchParams({ | |
| 62 | grant_type: "authorization_code", | |
| 63 | code, | |
| 64 | redirect_uri: redirectUri, | |
| 65 | client_id: cfg.clientId, | |
| 66 | client_secret: cfg.clientSecret, | |
| 67 | }); | |
| 68 | const res = await fetchImpl(cfg.tokenEndpoint, { | |
| 69 | method: "POST", | |
| 70 | headers: { | |
| 71 | "content-type": "application/x-www-form-urlencoded", | |
| 72 | accept: "application/json", | |
| 73 | }, | |
| 74 | body: body.toString(), | |
| 75 | }); | |
| 76 | if (!res.ok) { | |
| 77 | const text = await res.text().catch(() => ""); | |
| 78 | throw new Error( | |
| 79 | `google token endpoint ${res.status}: ${text.slice(0, 200) || "no body"}` | |
| 80 | ); | |
| 81 | } | |
| 82 | const json = (await res.json()) as { | |
| 83 | access_token?: string; | |
| 84 | id_token?: string; | |
| 85 | error?: string; | |
| 86 | error_description?: string; | |
| 87 | }; | |
| 88 | if (json.error) { | |
| 89 | throw new Error( | |
| 90 | `google token endpoint: ${json.error}${json.error_description ? ` — ${json.error_description}` : ""}` | |
| 91 | ); | |
| 92 | } | |
| 93 | if (!json.access_token) { | |
| 94 | throw new Error("google token endpoint response missing access_token"); | |
| 95 | } | |
| 96 | return { | |
| 97 | accessToken: json.access_token, | |
| 98 | idToken: typeof json.id_token === "string" ? json.id_token : null, | |
| 99 | }; | |
| 100 | } | |
| 101 | ||
| 102 | /** The minimal subset of Google userinfo we read. */ | |
| 103 | export interface GoogleUserinfo { | |
| 104 | /** Stable Google account id (the `sub` claim). */ | |
| 105 | sub: string; | |
| 106 | /** Verified email address; Google always returns this for `email` scope. */ | |
| 107 | email: string | null; | |
| 108 | /** Whether Google considers the email verified. We refuse to auto-create on false. */ | |
| 109 | emailVerified: boolean; | |
| 110 | /** Full name from the Google profile. */ | |
| 111 | name: string | null; | |
| 112 | /** Avatar URL. */ | |
| 113 | picture: string | null; | |
| 114 | } | |
| 115 | ||
| 116 | /** Fetch the Google userinfo profile using a Bearer access token. */ | |
| 117 | export async function fetchGoogleUserinfo( | |
| 118 | cfg: Pick<SsoConfig, "userinfoEndpoint">, | |
| 119 | accessToken: string, | |
| 120 | fetchImpl: FetchImpl = fetch | |
| 121 | ): Promise<GoogleUserinfo> { | |
| 122 | if (!cfg.userinfoEndpoint) { | |
| 123 | throw new Error("Google OAuth config missing userinfo_endpoint"); | |
| 124 | } | |
| 125 | const res = await fetchImpl(cfg.userinfoEndpoint, { | |
| 126 | headers: { | |
| 127 | authorization: `Bearer ${accessToken}`, | |
| 128 | accept: "application/json", | |
| 129 | }, | |
| 130 | }); | |
| 131 | if (!res.ok) { | |
| 132 | const text = await res.text().catch(() => ""); | |
| 133 | throw new Error( | |
| 134 | `google /userinfo ${res.status}: ${text.slice(0, 200) || "no body"}` | |
| 135 | ); | |
| 136 | } | |
| 137 | const raw = (await res.json()) as { | |
| 138 | sub?: string; | |
| 139 | email?: string | null; | |
| 140 | email_verified?: boolean | string; | |
| 141 | name?: string | null; | |
| 142 | picture?: string | null; | |
| 143 | }; | |
| 144 | if (typeof raw.sub !== "string" || !raw.sub) { | |
| 145 | throw new Error("google /userinfo response missing sub"); | |
| 146 | } | |
| 147 | return { | |
| 148 | sub: raw.sub, | |
| 149 | email: raw.email ?? null, | |
| 150 | // Google sometimes serialises email_verified as a string "true"/"false". | |
| 151 | emailVerified: | |
| 152 | raw.email_verified === true || raw.email_verified === "true", | |
| 153 | name: raw.name ?? null, | |
| 154 | picture: raw.picture ?? null, | |
| 155 | }; | |
| 156 | } | |
| ad3ddf6 | 157 | |
| 158 | /** | |
| 159 | * Resolve the absolute "Sign in with Google" callback URI for a request. | |
| 160 | * | |
| 161 | * Reliability is the whole point here. The URI we send at /login/google must | |
| 162 | * be byte-identical to the one we send at the token exchange AND must match a | |
| 163 | * URI registered in the Google Cloud Console — and it has to keep working even | |
| 164 | * when nobody has set APP_BASE_URL. Resolution order: | |
| 165 | * | |
| 166 | * 1. If an operator pinned an absolute `https://` base URL (via | |
| 167 | * /admin/integrations or an env var), trust it verbatim. It's the | |
| 168 | * canonical choice and avoids ambiguity when the app answers on several | |
| 169 | * hostnames. | |
| 170 | * 2. Otherwise derive the origin from the actual request. Behind a TLS- | |
| 171 | * terminating proxy (Fly) the internal hop is plain http, so we honour | |
| 172 | * `X-Forwarded-Proto` / `X-Forwarded-Host`; any non-localhost host | |
| 173 | * defaults to https. This self-heals the classic failure where | |
| 174 | * APP_BASE_URL is unset and the callback would otherwise resolve to | |
| 175 | * `http://localhost:3000/...` and trip `redirect_uri_mismatch`. | |
| 176 | * | |
| 177 | * Pure: every input is passed in, so it is fully unit-testable. | |
| 178 | */ | |
| 179 | export function resolveGoogleRedirectUri(opts: { | |
| 180 | configuredBaseUrl?: string | null; | |
| 181 | forwardedProto?: string | null; | |
| 182 | forwardedHost?: string | null; | |
| 183 | host?: string | null; | |
| 184 | requestUrl?: string | null; | |
| 185 | }): string { | |
| 186 | const PATH = "/login/google/callback"; | |
| 187 | const firstValue = (v: string | null | undefined): string => | |
| 188 | (v || "").split(",")[0].trim(); | |
| 189 | ||
| 190 | // 1. Explicit, production-grade base URL wins. | |
| 191 | const configured = (opts.configuredBaseUrl || "").trim().replace(/\/+$/, ""); | |
| 192 | if ( | |
| 193 | configured.startsWith("https://") && | |
| 194 | !configured.includes("localhost") && | |
| 195 | !configured.includes("127.0.0.1") | |
| 196 | ) { | |
| 197 | return `${configured}${PATH}`; | |
| 198 | } | |
| 199 | ||
| 200 | // 2. Derive the origin from the request (forwarded headers → Host → URL). | |
| 201 | let host = firstValue(opts.forwardedHost) || firstValue(opts.host); | |
| 202 | let urlProto = ""; | |
| 203 | if (opts.requestUrl) { | |
| 204 | try { | |
| 205 | const u = new URL(opts.requestUrl); | |
| 206 | if (!host) host = u.host; | |
| 207 | urlProto = u.protocol.replace(":", ""); | |
| 208 | } catch { | |
| 209 | /* ignore an unparseable request URL */ | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | if (!host) { | |
| 214 | // Nothing to derive from — last-resort dev fallback. | |
| 215 | const base = configured || "http://localhost:3000"; | |
| 216 | return `${base.replace(/\/+$/, "")}${PATH}`; | |
| 217 | } | |
| 218 | ||
| 219 | const isLocal = | |
| 220 | host.startsWith("localhost") || host.startsWith("127.0.0.1"); | |
| 221 | // A proxy-supplied scheme always wins; otherwise any public host is https | |
| 222 | // (Fly terminates TLS) and only loopback stays on its request scheme. | |
| 223 | let proto = firstValue(opts.forwardedProto); | |
| 224 | if (!proto) proto = isLocal ? urlProto || "http" : "https"; | |
| 225 | ||
| 226 | return `${proto}://${host}${PATH}`; | |
| 227 | } | |
| 228 |