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.tsBlame156 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}