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.tsx

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.tsxBlame427 lines · 1 contributor
582cdacClaude1/**
2 * "Sign in with Google" routes.
3 *
4 * GET /admin/google-oauth — site-admin config page
5 * POST /admin/google-oauth — save Client ID + Secret + toggle
6 * GET /login/google — kick off OAuth (redirect to Google)
7 * GET /login/google/callback — exchange code, sign user in
8 *
9 * Mirrors the structure of `src/routes/github-oauth.tsx`. Reuses the
10 * existing `sso_user_links` table with `subject = "google:<sub>"` so it
11 * sits alongside the enterprise IdP (id='default') and the GitHub
12 * provider (id='github') without colliding.
13 */
14
15import { Hono } from "hono";
16import { getCookie, setCookie, deleteCookie } from "hono/cookie";
17import { Layout } from "../views/layout";
18import { softAuth, requireAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import { isSiteAdmin } from "../lib/admin";
21import { audit } from "../lib/notify";
22import {
23 findOrCreateUserFromGoogle,
24 getGoogleOauthConfig,
9887dd0Claude25 googleOauthConfigFromEnv,
582cdacClaude26 issueSsoSession,
27 randomToken,
28 upsertGoogleOauthConfig,
29 type GoogleProfile,
30} from "../lib/sso";
31import {
32 buildGoogleAuthorizeUrl,
33 exchangeGoogleCode,
34 fetchGoogleUserinfo,
ad3ddf6Claude35 resolveGoogleRedirectUri,
582cdacClaude36} from "../lib/google-oauth";
ad3ddf6Claude37import { config } from "../lib/config";
582cdacClaude38import { sessionCookieOptions } from "../lib/auth";
39
40const googleOauth = new Hono<AuthEnv>();
41googleOauth.use("*", softAuth);
42
ad3ddf6Claude43/**
44 * The absolute callback URI for the current request. Self-heals from the
45 * proxy's forwarded headers so it stays correct (https + public host) even
46 * when APP_BASE_URL isn't configured — see `resolveGoogleRedirectUri`.
47 */
48function googleRedirectUri(c: { req: { header: (n: string) => string | undefined; url: string } }): string {
49 return resolveGoogleRedirectUri({
50 configuredBaseUrl: config.appBaseUrl,
51 forwardedProto: c.req.header("x-forwarded-proto") ?? null,
52 forwardedHost: c.req.header("x-forwarded-host") ?? null,
53 host: c.req.header("host") ?? null,
54 requestUrl: c.req.url,
55 });
56}
57
582cdacClaude58function stateCookieOpts(): {
59 httpOnly: boolean;
60 secure: boolean;
61 sameSite: "Lax";
62 path: string;
63 maxAge: number;
64} {
65 return {
66 httpOnly: true,
67 secure: process.env.NODE_ENV === "production",
68 sameSite: "Lax",
69 path: "/",
70 maxAge: 600, // 10 min to complete the flow
71 };
72}
73
74// ----------------------------------------------------------------------------
75// Admin config page
76// ----------------------------------------------------------------------------
77
78async function adminGate(c: any): Promise<{ user: any } | Response> {
79 const user = c.get("user");
80 if (!user) return c.redirect("/login?next=/admin/google-oauth");
81 if (!(await isSiteAdmin(user.id))) {
82 return c.html(
83 <Layout title="Forbidden" user={user}>
84 <div class="empty-state">
85 <h2>403 — Not a site admin</h2>
86 <p>You don't have permission to configure Google sign-in.</p>
87 </div>
88 </Layout>,
89 403
90 );
91 }
92 return { user };
93}
94
95googleOauth.get("/admin/google-oauth", requireAuth, async (c) => {
96 const g = await adminGate(c);
97 if (g instanceof Response) return g;
98 const { user } = g;
99
100 const cfg = await getGoogleOauthConfig();
101 const success = c.req.query("success");
102 const error = c.req.query("error");
ad3ddf6Claude103 // Self-healing: derived from this very request, so it matches whatever
104 // public host the admin is viewing on — no APP_BASE_URL required.
105 const redirectUri = googleRedirectUri(c);
582cdacClaude106
9887dd0Claude107 // ── Live diagnostic: explain exactly why the /login button is on or off ──
108 const envPresent = !!googleOauthConfigFromEnv();
109 const liveOn = !!cfg?.enabled && !!cfg.clientId && !!cfg.clientSecret;
110 const offReason = !cfg
ad3ddf6Claude111 ? "no Client ID / Secret found anywhere. Paste them below (they save straight to the database — no redeploy)."
9887dd0Claude112 : !cfg.enabled
113 ? "a saved config exists but the “Enable” box is unticked. Tick it and save."
114 : "the saved Client ID or Secret is incomplete.";
ad3ddf6Claude115 // In production this resolves to your public https callback; only local dev
116 // lands on localhost, which Google won't accept for a real client.
9887dd0Claude117 const redirectLooksLocal =
ad3ddf6Claude118 redirectUri.includes("localhost") || redirectUri.includes("127.0.0.1");
9887dd0Claude119
582cdacClaude120 return c.html(
121 <Layout title="Google sign-in — Admin" user={user}>
122 <div class="settings-container" style="max-width:780px">
123 <h2>Sign in with Google</h2>
124 <p style="color:var(--text-muted)">
125 Let any developer sign in to gluecron with their Google account in
126 one click. Create an OAuth 2.0 Client at{" "}
127 <a
128 href="https://console.cloud.google.com/apis/credentials"
129 target="_blank"
130 rel="noreferrer noopener"
131 >
132 console.cloud.google.com/apis/credentials
133 </a>{" "}
134 (set application type = Web), then paste the Client ID + Secret here.
135 </p>
9887dd0Claude136
137 <div class="panel" style="padding:12px;margin-bottom:16px">
138 <div
139 style="font-size:12px;text-transform:uppercase;color:var(--text-muted)"
140 >
141 Current status
142 </div>
143 {liveOn ? (
144 <div class="auth-success" style="margin:8px 0">
145 ✅ Google sign-in is ON — the button is shown on{" "}
146 <a href="/login">/login</a>.
147 </div>
148 ) : (
149 <div class="auth-error" style="margin:8px 0">
150 ⛔ Google sign-in is OFF — {offReason}
151 </div>
152 )}
153 <div style="font-size:13px;color:var(--text-muted)">
154 Environment bootstrap (<code>GOOGLE_OAUTH_CLIENT_ID</code> +{" "}
155 <code>GOOGLE_OAUTH_CLIENT_SECRET</code>):{" "}
156 <strong>{envPresent ? "detected" : "not set"}</strong>
157 </div>
158 {redirectLooksLocal && (
159 <div class="auth-error" style="margin-top:8px">
ad3ddf6Claude160 ⚠ You're on a localhost origin (local dev). In production this
161 callback auto-resolves to your public HTTPS URL from the request
162 — no <code>APP_BASE_URL</code> needed. Google won't accept a
163 localhost callback for a real client.
9887dd0Claude164 </div>
165 )}
166 </div>
167
582cdacClaude168 <div class="panel" style="padding:12px;margin-bottom:16px">
169 <div
170 style="font-size:12px;text-transform:uppercase;color:var(--text-muted)"
171 >
172 Authorised redirect URI — paste this into Google Cloud Console
173 </div>
174 <code id="g-redirect-uri" style="font-size:13px">
175 {redirectUri}
176 </code>
177 <button
178 type="button"
179 class="btn"
180 style="margin-left:8px"
181 onclick={`navigator.clipboard.writeText(${JSON.stringify(redirectUri)});this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1500)`}
182 >
183 Copy
184 </button>
185 </div>
186
187 {success && (
188 <div class="auth-success">{decodeURIComponent(success)}</div>
189 )}
190 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
191
192 <form
193 method="post"
194 action="/admin/google-oauth"
195 class="panel"
196 style="padding:16px"
197 >
198 <label
199 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"
200 >
201 <input
202 type="checkbox"
203 name="enabled"
204 value="1"
205 checked={!!cfg?.enabled}
206 aria-label="Enable Google sign-in on /login"
207 />
208 <span>Enable Google sign-in on /login</span>
209 </label>
210 <div class="form-group">
211 <label for="g_client_id">Client ID</label>
212 <input
213 type="text"
214 id="g_client_id"
215 name="client_id"
216 value={cfg?.clientId || ""}
217 autocomplete="off"
218 placeholder="123456789-xxxxxxxxx.apps.googleusercontent.com"
219 />
220 </div>
221 <div class="form-group">
222 <label for="g_client_secret">Client secret</label>
223 <input
224 type="password"
225 id="g_client_secret"
226 name="client_secret"
227 value={cfg?.clientSecret || ""}
228 autocomplete="off"
229 placeholder={
230 cfg?.clientSecret ? "(stored — leave blank to keep)" : ""
231 }
232 />
233 </div>
234 <div class="form-group">
235 <label for="g_allowed_email_domains">
236 Allowed email domains (comma-separated, empty = any)
237 </label>
238 <input
239 type="text"
240 id="g_allowed_email_domains"
241 name="allowed_email_domains"
242 value={cfg?.allowedEmailDomains || ""}
243 placeholder="example.com, acme.io"
244 />
245 </div>
246 <label
247 style="display:flex;gap:8px;align-items:center;margin:12px 0"
248 >
249 <input
250 type="checkbox"
251 name="auto_create_users"
252 value="1"
253 checked={cfg ? cfg.autoCreateUsers : true}
254 aria-label="Auto-create users on first Google sign-in"
255 />
256 <span>Auto-create local accounts on first Google sign-in</span>
257 </label>
258 <button type="submit" class="btn btn-primary">
259 Save Google settings
260 </button>
261 </form>
262 </div>
263 </Layout>
264 );
265});
266
267googleOauth.post("/admin/google-oauth", requireAuth, async (c) => {
268 const g = await adminGate(c);
269 if (g instanceof Response) return g;
270 const { user } = g;
271
272 const body = await c.req.parseBody();
273 const existing = await getGoogleOauthConfig();
274 const secretSubmitted = String(body.client_secret || "");
275 const result = await upsertGoogleOauthConfig({
276 enabled: String(body.enabled || "") === "1",
277 clientId: String(body.client_id || ""),
278 clientSecret:
279 secretSubmitted.trim().length === 0 && existing?.clientSecret
280 ? existing.clientSecret
281 : secretSubmitted,
282 allowedEmailDomains: String(body.allowed_email_domains || ""),
283 autoCreateUsers: String(body.auto_create_users || "") === "1",
284 });
285
286 if (!result.ok) {
287 return c.redirect(
288 `/admin/google-oauth?error=${encodeURIComponent(result.error)}`
289 );
290 }
291
292 await audit({
293 userId: user.id,
294 action: "admin.google_oauth.configure",
295 metadata: {
296 enabled: String(body.enabled || "") === "1",
297 autoCreateUsers: String(body.auto_create_users || "") === "1",
298 allowedDomains: String(body.allowed_email_domains || "") || null,
299 },
300 });
301
302 return c.redirect(
303 `/admin/google-oauth?success=${encodeURIComponent("Google sign-in settings saved.")}`
304 );
305});
306
307// ----------------------------------------------------------------------------
308// OAuth flow
309// ----------------------------------------------------------------------------
310
311googleOauth.get("/login/google", async (c) => {
312 const cfg = await getGoogleOauthConfig();
9887dd0Claude313 if (!cfg) {
582cdacClaude314 return c.redirect(
9887dd0Claude315 `/login?error=${encodeURIComponent("Google sign-in is not configured")}`
316 );
317 }
318 if (!cfg.enabled) {
319 return c.redirect(
320 `/login?error=${encodeURIComponent("Google sign-in is currently disabled")}`
582cdacClaude321 );
322 }
323 if (!cfg.clientId || !cfg.clientSecret) {
324 return c.redirect(
325 `/login?error=${encodeURIComponent("Google sign-in is not fully configured")}`
326 );
327 }
328 const state = randomToken(16);
329 const nonce = randomToken(16);
ad3ddf6Claude330 const redirectUri = googleRedirectUri(c);
582cdacClaude331 let target: string;
332 try {
333 target = buildGoogleAuthorizeUrl(cfg, state, redirectUri, nonce);
334 } catch (err) {
335 return c.redirect(
336 `/login?error=${encodeURIComponent(
337 err instanceof Error ? err.message : "Google sign-in misconfigured"
338 )}`
339 );
340 }
341 setCookie(c, "g_oauth_state", state, stateCookieOpts());
342 setCookie(c, "g_oauth_nonce", nonce, stateCookieOpts());
343 return c.redirect(target);
344});
345
346googleOauth.get("/login/google/callback", async (c) => {
347 const cfg = await getGoogleOauthConfig();
348 if (!cfg || !cfg.enabled) {
349 return c.redirect(
350 `/login?error=${encodeURIComponent("Google sign-in is not enabled")}`
351 );
352 }
353
354 const code = c.req.query("code");
355 const state = c.req.query("state");
356 const errCode = c.req.query("error");
357 if (errCode) {
358 return c.redirect(
359 `/login?error=${encodeURIComponent(`Google error: ${errCode}`)}`
360 );
361 }
362 if (!code || !state) {
363 return c.redirect(
364 `/login?error=${encodeURIComponent("Missing code or state")}`
365 );
366 }
367
368 const expectedState = getCookie(c, "g_oauth_state");
369 if (!expectedState || expectedState !== state) {
370 return c.redirect(
371 `/login?error=${encodeURIComponent(
372 "Google state mismatch. Please try again."
373 )}`
374 );
375 }
376
377 // One-shot cookies — burn even on failure
378 deleteCookie(c, "g_oauth_state", { path: "/" });
379 deleteCookie(c, "g_oauth_nonce", { path: "/" });
380
381 try {
382 const { accessToken } = await exchangeGoogleCode(
383 cfg,
384 code,
ad3ddf6Claude385 googleRedirectUri(c)
582cdacClaude386 );
387 const userinfo = await fetchGoogleUserinfo(cfg, accessToken);
388
389 const profile: GoogleProfile = {
390 sub: userinfo.sub,
391 email: userinfo.email,
392 emailVerified: userinfo.emailVerified,
393 name: userinfo.name,
394 picture: userinfo.picture,
395 };
396
397 const result = await findOrCreateUserFromGoogle(profile, cfg);
398 if (!result.ok) {
399 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
400 }
401
402 const token = await issueSsoSession(result.user.id);
403 setCookie(c, "session", token, sessionCookieOptions());
404
405 await audit({
406 userId: result.user.id,
407 action: "auth.google.login",
408 metadata: {
409 googleSub: profile.sub,
410 email: profile.email || null,
411 },
412 });
413
414 return c.redirect("/");
415 } catch (err) {
416 console.error("[google-oauth] callback error:", err);
417 return c.redirect(
418 `/login?error=${encodeURIComponent(
419 err instanceof Error
420 ? `Google sign-in failed: ${err.message}`
421 : "Google sign-in failed"
422 )}`
423 );
424 }
425});
426
427export default googleOauth;