Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

google-oauth.tsBlame228 lines · 1 contributor
582cdacClaude1/**
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
13import type { SsoConfig } from "../db/schema";
14
15/** Override for tests — defaults to the global fetch. */
16export type FetchImpl = typeof fetch;
17
18/** Build the Google authorize URL the browser should be redirected to. */
19export 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 */
50export 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. */
103export 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. */
117export 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}
ad3ddf6Claude157
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 */
179export 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