Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame412 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 googleOauthRedirectUri,
27 issueSsoSession,
28 randomToken,
29 upsertGoogleOauthConfig,
30 type GoogleProfile,
31} from "../lib/sso";
32import {
33 buildGoogleAuthorizeUrl,
34 exchangeGoogleCode,
35 fetchGoogleUserinfo,
36} from "../lib/google-oauth";
37import { sessionCookieOptions } from "../lib/auth";
38
39const googleOauth = new Hono<AuthEnv>();
40googleOauth.use("*", softAuth);
41
42function stateCookieOpts(): {
43 httpOnly: boolean;
44 secure: boolean;
45 sameSite: "Lax";
46 path: string;
47 maxAge: number;
48} {
49 return {
50 httpOnly: true,
51 secure: process.env.NODE_ENV === "production",
52 sameSite: "Lax",
53 path: "/",
54 maxAge: 600, // 10 min to complete the flow
55 };
56}
57
58// ----------------------------------------------------------------------------
59// Admin config page
60// ----------------------------------------------------------------------------
61
62async function adminGate(c: any): Promise<{ user: any } | Response> {
63 const user = c.get("user");
64 if (!user) return c.redirect("/login?next=/admin/google-oauth");
65 if (!(await isSiteAdmin(user.id))) {
66 return c.html(
67 <Layout title="Forbidden" user={user}>
68 <div class="empty-state">
69 <h2>403 — Not a site admin</h2>
70 <p>You don't have permission to configure Google sign-in.</p>
71 </div>
72 </Layout>,
73 403
74 );
75 }
76 return { user };
77}
78
79googleOauth.get("/admin/google-oauth", requireAuth, async (c) => {
80 const g = await adminGate(c);
81 if (g instanceof Response) return g;
82 const { user } = g;
83
84 const cfg = await getGoogleOauthConfig();
85 const success = c.req.query("success");
86 const error = c.req.query("error");
87 const redirectUri = googleOauthRedirectUri();
88
9887dd0Claude89 // ── Live diagnostic: explain exactly why the /login button is on or off ──
90 const envPresent = !!googleOauthConfigFromEnv();
91 const liveOn = !!cfg?.enabled && !!cfg.clientId && !!cfg.clientSecret;
92 const offReason = !cfg
93 ? "no Client ID / Secret found anywhere. Paste them below, or set the GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET environment variables."
94 : !cfg.enabled
95 ? "a saved config exists but the “Enable” box is unticked. Tick it and save."
96 : "the saved Client ID or Secret is incomplete.";
97 // Google refuses any non-HTTPS or localhost redirect URI for a real client,
98 // so flag it before the operator wastes a round-trip to the consent screen.
99 const redirectLooksLocal =
100 redirectUri.startsWith("http://") ||
101 redirectUri.includes("localhost") ||
102 redirectUri.includes("127.0.0.1");
103
582cdacClaude104 return c.html(
105 <Layout title="Google sign-in — Admin" user={user}>
106 <div class="settings-container" style="max-width:780px">
107 <h2>Sign in with Google</h2>
108 <p style="color:var(--text-muted)">
109 Let any developer sign in to gluecron with their Google account in
110 one click. Create an OAuth 2.0 Client at{" "}
111 <a
112 href="https://console.cloud.google.com/apis/credentials"
113 target="_blank"
114 rel="noreferrer noopener"
115 >
116 console.cloud.google.com/apis/credentials
117 </a>{" "}
118 (set application type = Web), then paste the Client ID + Secret here.
119 </p>
9887dd0Claude120
121 <div class="panel" style="padding:12px;margin-bottom:16px">
122 <div
123 style="font-size:12px;text-transform:uppercase;color:var(--text-muted)"
124 >
125 Current status
126 </div>
127 {liveOn ? (
128 <div class="auth-success" style="margin:8px 0">
129 ✅ Google sign-in is ON — the button is shown on{" "}
130 <a href="/login">/login</a>.
131 </div>
132 ) : (
133 <div class="auth-error" style="margin:8px 0">
134 ⛔ Google sign-in is OFF — {offReason}
135 </div>
136 )}
137 <div style="font-size:13px;color:var(--text-muted)">
138 Environment bootstrap (<code>GOOGLE_OAUTH_CLIENT_ID</code> +{" "}
139 <code>GOOGLE_OAUTH_CLIENT_SECRET</code>):{" "}
140 <strong>{envPresent ? "detected" : "not set"}</strong>
141 </div>
142 {redirectLooksLocal && (
143 <div class="auth-error" style="margin-top:8px">
144 ⚠ The redirect URI below points at a non-HTTPS / localhost
145 address. Google rejects sign-in with{" "}
146 <code>redirect_uri_mismatch</code> unless your public HTTPS URL
147 is used. Set <code>APP_BASE_URL=https://gluecron.com</code> (env
148 or Fly secret) and redeploy.
149 </div>
150 )}
151 </div>
152
582cdacClaude153 <div class="panel" style="padding:12px;margin-bottom:16px">
154 <div
155 style="font-size:12px;text-transform:uppercase;color:var(--text-muted)"
156 >
157 Authorised redirect URI — paste this into Google Cloud Console
158 </div>
159 <code id="g-redirect-uri" style="font-size:13px">
160 {redirectUri}
161 </code>
162 <button
163 type="button"
164 class="btn"
165 style="margin-left:8px"
166 onclick={`navigator.clipboard.writeText(${JSON.stringify(redirectUri)});this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1500)`}
167 >
168 Copy
169 </button>
170 </div>
171
172 {success && (
173 <div class="auth-success">{decodeURIComponent(success)}</div>
174 )}
175 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
176
177 <form
178 method="post"
179 action="/admin/google-oauth"
180 class="panel"
181 style="padding:16px"
182 >
183 <label
184 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"
185 >
186 <input
187 type="checkbox"
188 name="enabled"
189 value="1"
190 checked={!!cfg?.enabled}
191 aria-label="Enable Google sign-in on /login"
192 />
193 <span>Enable Google sign-in on /login</span>
194 </label>
195 <div class="form-group">
196 <label for="g_client_id">Client ID</label>
197 <input
198 type="text"
199 id="g_client_id"
200 name="client_id"
201 value={cfg?.clientId || ""}
202 autocomplete="off"
203 placeholder="123456789-xxxxxxxxx.apps.googleusercontent.com"
204 />
205 </div>
206 <div class="form-group">
207 <label for="g_client_secret">Client secret</label>
208 <input
209 type="password"
210 id="g_client_secret"
211 name="client_secret"
212 value={cfg?.clientSecret || ""}
213 autocomplete="off"
214 placeholder={
215 cfg?.clientSecret ? "(stored — leave blank to keep)" : ""
216 }
217 />
218 </div>
219 <div class="form-group">
220 <label for="g_allowed_email_domains">
221 Allowed email domains (comma-separated, empty = any)
222 </label>
223 <input
224 type="text"
225 id="g_allowed_email_domains"
226 name="allowed_email_domains"
227 value={cfg?.allowedEmailDomains || ""}
228 placeholder="example.com, acme.io"
229 />
230 </div>
231 <label
232 style="display:flex;gap:8px;align-items:center;margin:12px 0"
233 >
234 <input
235 type="checkbox"
236 name="auto_create_users"
237 value="1"
238 checked={cfg ? cfg.autoCreateUsers : true}
239 aria-label="Auto-create users on first Google sign-in"
240 />
241 <span>Auto-create local accounts on first Google sign-in</span>
242 </label>
243 <button type="submit" class="btn btn-primary">
244 Save Google settings
245 </button>
246 </form>
247 </div>
248 </Layout>
249 );
250});
251
252googleOauth.post("/admin/google-oauth", requireAuth, async (c) => {
253 const g = await adminGate(c);
254 if (g instanceof Response) return g;
255 const { user } = g;
256
257 const body = await c.req.parseBody();
258 const existing = await getGoogleOauthConfig();
259 const secretSubmitted = String(body.client_secret || "");
260 const result = await upsertGoogleOauthConfig({
261 enabled: String(body.enabled || "") === "1",
262 clientId: String(body.client_id || ""),
263 clientSecret:
264 secretSubmitted.trim().length === 0 && existing?.clientSecret
265 ? existing.clientSecret
266 : secretSubmitted,
267 allowedEmailDomains: String(body.allowed_email_domains || ""),
268 autoCreateUsers: String(body.auto_create_users || "") === "1",
269 });
270
271 if (!result.ok) {
272 return c.redirect(
273 `/admin/google-oauth?error=${encodeURIComponent(result.error)}`
274 );
275 }
276
277 await audit({
278 userId: user.id,
279 action: "admin.google_oauth.configure",
280 metadata: {
281 enabled: String(body.enabled || "") === "1",
282 autoCreateUsers: String(body.auto_create_users || "") === "1",
283 allowedDomains: String(body.allowed_email_domains || "") || null,
284 },
285 });
286
287 return c.redirect(
288 `/admin/google-oauth?success=${encodeURIComponent("Google sign-in settings saved.")}`
289 );
290});
291
292// ----------------------------------------------------------------------------
293// OAuth flow
294// ----------------------------------------------------------------------------
295
296googleOauth.get("/login/google", async (c) => {
297 const cfg = await getGoogleOauthConfig();
9887dd0Claude298 if (!cfg) {
582cdacClaude299 return c.redirect(
9887dd0Claude300 `/login?error=${encodeURIComponent("Google sign-in is not configured")}`
301 );
302 }
303 if (!cfg.enabled) {
304 return c.redirect(
305 `/login?error=${encodeURIComponent("Google sign-in is currently disabled")}`
582cdacClaude306 );
307 }
308 if (!cfg.clientId || !cfg.clientSecret) {
309 return c.redirect(
310 `/login?error=${encodeURIComponent("Google sign-in is not fully configured")}`
311 );
312 }
313 const state = randomToken(16);
314 const nonce = randomToken(16);
315 const redirectUri = googleOauthRedirectUri();
316 let target: string;
317 try {
318 target = buildGoogleAuthorizeUrl(cfg, state, redirectUri, nonce);
319 } catch (err) {
320 return c.redirect(
321 `/login?error=${encodeURIComponent(
322 err instanceof Error ? err.message : "Google sign-in misconfigured"
323 )}`
324 );
325 }
326 setCookie(c, "g_oauth_state", state, stateCookieOpts());
327 setCookie(c, "g_oauth_nonce", nonce, stateCookieOpts());
328 return c.redirect(target);
329});
330
331googleOauth.get("/login/google/callback", async (c) => {
332 const cfg = await getGoogleOauthConfig();
333 if (!cfg || !cfg.enabled) {
334 return c.redirect(
335 `/login?error=${encodeURIComponent("Google sign-in is not enabled")}`
336 );
337 }
338
339 const code = c.req.query("code");
340 const state = c.req.query("state");
341 const errCode = c.req.query("error");
342 if (errCode) {
343 return c.redirect(
344 `/login?error=${encodeURIComponent(`Google error: ${errCode}`)}`
345 );
346 }
347 if (!code || !state) {
348 return c.redirect(
349 `/login?error=${encodeURIComponent("Missing code or state")}`
350 );
351 }
352
353 const expectedState = getCookie(c, "g_oauth_state");
354 if (!expectedState || expectedState !== state) {
355 return c.redirect(
356 `/login?error=${encodeURIComponent(
357 "Google state mismatch. Please try again."
358 )}`
359 );
360 }
361
362 // One-shot cookies — burn even on failure
363 deleteCookie(c, "g_oauth_state", { path: "/" });
364 deleteCookie(c, "g_oauth_nonce", { path: "/" });
365
366 try {
367 const { accessToken } = await exchangeGoogleCode(
368 cfg,
369 code,
370 googleOauthRedirectUri()
371 );
372 const userinfo = await fetchGoogleUserinfo(cfg, accessToken);
373
374 const profile: GoogleProfile = {
375 sub: userinfo.sub,
376 email: userinfo.email,
377 emailVerified: userinfo.emailVerified,
378 name: userinfo.name,
379 picture: userinfo.picture,
380 };
381
382 const result = await findOrCreateUserFromGoogle(profile, cfg);
383 if (!result.ok) {
384 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
385 }
386
387 const token = await issueSsoSession(result.user.id);
388 setCookie(c, "session", token, sessionCookieOptions());
389
390 await audit({
391 userId: result.user.id,
392 action: "auth.google.login",
393 metadata: {
394 googleSub: profile.sub,
395 email: profile.email || null,
396 },
397 });
398
399 return c.redirect("/");
400 } catch (err) {
401 console.error("[google-oauth] callback error:", err);
402 return c.redirect(
403 `/login?error=${encodeURIComponent(
404 err instanceof Error
405 ? `Google sign-in failed: ${err.message}`
406 : "Google sign-in failed"
407 )}`
408 );
409 }
410});
411
412export default googleOauth;