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.tsxBlame358 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,
25 googleOauthRedirectUri,
26 issueSsoSession,
27 randomToken,
28 upsertGoogleOauthConfig,
29 type GoogleProfile,
30} from "../lib/sso";
31import {
32 buildGoogleAuthorizeUrl,
33 exchangeGoogleCode,
34 fetchGoogleUserinfo,
35} from "../lib/google-oauth";
36import { sessionCookieOptions } from "../lib/auth";
37
38const googleOauth = new Hono<AuthEnv>();
39googleOauth.use("*", softAuth);
40
41function stateCookieOpts(): {
42 httpOnly: boolean;
43 secure: boolean;
44 sameSite: "Lax";
45 path: string;
46 maxAge: number;
47} {
48 return {
49 httpOnly: true,
50 secure: process.env.NODE_ENV === "production",
51 sameSite: "Lax",
52 path: "/",
53 maxAge: 600, // 10 min to complete the flow
54 };
55}
56
57// ----------------------------------------------------------------------------
58// Admin config page
59// ----------------------------------------------------------------------------
60
61async function adminGate(c: any): Promise<{ user: any } | Response> {
62 const user = c.get("user");
63 if (!user) return c.redirect("/login?next=/admin/google-oauth");
64 if (!(await isSiteAdmin(user.id))) {
65 return c.html(
66 <Layout title="Forbidden" user={user}>
67 <div class="empty-state">
68 <h2>403 — Not a site admin</h2>
69 <p>You don't have permission to configure Google sign-in.</p>
70 </div>
71 </Layout>,
72 403
73 );
74 }
75 return { user };
76}
77
78googleOauth.get("/admin/google-oauth", requireAuth, async (c) => {
79 const g = await adminGate(c);
80 if (g instanceof Response) return g;
81 const { user } = g;
82
83 const cfg = await getGoogleOauthConfig();
84 const success = c.req.query("success");
85 const error = c.req.query("error");
86 const redirectUri = googleOauthRedirectUri();
87
88 return c.html(
89 <Layout title="Google sign-in — Admin" user={user}>
90 <div class="settings-container" style="max-width:780px">
91 <h2>Sign in with Google</h2>
92 <p style="color:var(--text-muted)">
93 Let any developer sign in to gluecron with their Google account in
94 one click. Create an OAuth 2.0 Client at{" "}
95 <a
96 href="https://console.cloud.google.com/apis/credentials"
97 target="_blank"
98 rel="noreferrer noopener"
99 >
100 console.cloud.google.com/apis/credentials
101 </a>{" "}
102 (set application type = Web), then paste the Client ID + Secret here.
103 </p>
104 <div class="panel" style="padding:12px;margin-bottom:16px">
105 <div
106 style="font-size:12px;text-transform:uppercase;color:var(--text-muted)"
107 >
108 Authorised redirect URI — paste this into Google Cloud Console
109 </div>
110 <code id="g-redirect-uri" style="font-size:13px">
111 {redirectUri}
112 </code>
113 <button
114 type="button"
115 class="btn"
116 style="margin-left:8px"
117 onclick={`navigator.clipboard.writeText(${JSON.stringify(redirectUri)});this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1500)`}
118 >
119 Copy
120 </button>
121 </div>
122
123 {success && (
124 <div class="auth-success">{decodeURIComponent(success)}</div>
125 )}
126 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
127
128 <form
129 method="post"
130 action="/admin/google-oauth"
131 class="panel"
132 style="padding:16px"
133 >
134 <label
135 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"
136 >
137 <input
138 type="checkbox"
139 name="enabled"
140 value="1"
141 checked={!!cfg?.enabled}
142 aria-label="Enable Google sign-in on /login"
143 />
144 <span>Enable Google sign-in on /login</span>
145 </label>
146 <div class="form-group">
147 <label for="g_client_id">Client ID</label>
148 <input
149 type="text"
150 id="g_client_id"
151 name="client_id"
152 value={cfg?.clientId || ""}
153 autocomplete="off"
154 placeholder="123456789-xxxxxxxxx.apps.googleusercontent.com"
155 />
156 </div>
157 <div class="form-group">
158 <label for="g_client_secret">Client secret</label>
159 <input
160 type="password"
161 id="g_client_secret"
162 name="client_secret"
163 value={cfg?.clientSecret || ""}
164 autocomplete="off"
165 placeholder={
166 cfg?.clientSecret ? "(stored — leave blank to keep)" : ""
167 }
168 />
169 </div>
170 <div class="form-group">
171 <label for="g_allowed_email_domains">
172 Allowed email domains (comma-separated, empty = any)
173 </label>
174 <input
175 type="text"
176 id="g_allowed_email_domains"
177 name="allowed_email_domains"
178 value={cfg?.allowedEmailDomains || ""}
179 placeholder="example.com, acme.io"
180 />
181 </div>
182 <label
183 style="display:flex;gap:8px;align-items:center;margin:12px 0"
184 >
185 <input
186 type="checkbox"
187 name="auto_create_users"
188 value="1"
189 checked={cfg ? cfg.autoCreateUsers : true}
190 aria-label="Auto-create users on first Google sign-in"
191 />
192 <span>Auto-create local accounts on first Google sign-in</span>
193 </label>
194 <button type="submit" class="btn btn-primary">
195 Save Google settings
196 </button>
197 </form>
198 </div>
199 </Layout>
200 );
201});
202
203googleOauth.post("/admin/google-oauth", requireAuth, async (c) => {
204 const g = await adminGate(c);
205 if (g instanceof Response) return g;
206 const { user } = g;
207
208 const body = await c.req.parseBody();
209 const existing = await getGoogleOauthConfig();
210 const secretSubmitted = String(body.client_secret || "");
211 const result = await upsertGoogleOauthConfig({
212 enabled: String(body.enabled || "") === "1",
213 clientId: String(body.client_id || ""),
214 clientSecret:
215 secretSubmitted.trim().length === 0 && existing?.clientSecret
216 ? existing.clientSecret
217 : secretSubmitted,
218 allowedEmailDomains: String(body.allowed_email_domains || ""),
219 autoCreateUsers: String(body.auto_create_users || "") === "1",
220 });
221
222 if (!result.ok) {
223 return c.redirect(
224 `/admin/google-oauth?error=${encodeURIComponent(result.error)}`
225 );
226 }
227
228 await audit({
229 userId: user.id,
230 action: "admin.google_oauth.configure",
231 metadata: {
232 enabled: String(body.enabled || "") === "1",
233 autoCreateUsers: String(body.auto_create_users || "") === "1",
234 allowedDomains: String(body.allowed_email_domains || "") || null,
235 },
236 });
237
238 return c.redirect(
239 `/admin/google-oauth?success=${encodeURIComponent("Google sign-in settings saved.")}`
240 );
241});
242
243// ----------------------------------------------------------------------------
244// OAuth flow
245// ----------------------------------------------------------------------------
246
247googleOauth.get("/login/google", async (c) => {
248 const cfg = await getGoogleOauthConfig();
249 if (!cfg || !cfg.enabled) {
250 return c.redirect(
251 `/login?error=${encodeURIComponent("Google sign-in is not enabled")}`
252 );
253 }
254 if (!cfg.clientId || !cfg.clientSecret) {
255 return c.redirect(
256 `/login?error=${encodeURIComponent("Google sign-in is not fully configured")}`
257 );
258 }
259 const state = randomToken(16);
260 const nonce = randomToken(16);
261 const redirectUri = googleOauthRedirectUri();
262 let target: string;
263 try {
264 target = buildGoogleAuthorizeUrl(cfg, state, redirectUri, nonce);
265 } catch (err) {
266 return c.redirect(
267 `/login?error=${encodeURIComponent(
268 err instanceof Error ? err.message : "Google sign-in misconfigured"
269 )}`
270 );
271 }
272 setCookie(c, "g_oauth_state", state, stateCookieOpts());
273 setCookie(c, "g_oauth_nonce", nonce, stateCookieOpts());
274 return c.redirect(target);
275});
276
277googleOauth.get("/login/google/callback", async (c) => {
278 const cfg = await getGoogleOauthConfig();
279 if (!cfg || !cfg.enabled) {
280 return c.redirect(
281 `/login?error=${encodeURIComponent("Google sign-in is not enabled")}`
282 );
283 }
284
285 const code = c.req.query("code");
286 const state = c.req.query("state");
287 const errCode = c.req.query("error");
288 if (errCode) {
289 return c.redirect(
290 `/login?error=${encodeURIComponent(`Google error: ${errCode}`)}`
291 );
292 }
293 if (!code || !state) {
294 return c.redirect(
295 `/login?error=${encodeURIComponent("Missing code or state")}`
296 );
297 }
298
299 const expectedState = getCookie(c, "g_oauth_state");
300 if (!expectedState || expectedState !== state) {
301 return c.redirect(
302 `/login?error=${encodeURIComponent(
303 "Google state mismatch. Please try again."
304 )}`
305 );
306 }
307
308 // One-shot cookies — burn even on failure
309 deleteCookie(c, "g_oauth_state", { path: "/" });
310 deleteCookie(c, "g_oauth_nonce", { path: "/" });
311
312 try {
313 const { accessToken } = await exchangeGoogleCode(
314 cfg,
315 code,
316 googleOauthRedirectUri()
317 );
318 const userinfo = await fetchGoogleUserinfo(cfg, accessToken);
319
320 const profile: GoogleProfile = {
321 sub: userinfo.sub,
322 email: userinfo.email,
323 emailVerified: userinfo.emailVerified,
324 name: userinfo.name,
325 picture: userinfo.picture,
326 };
327
328 const result = await findOrCreateUserFromGoogle(profile, cfg);
329 if (!result.ok) {
330 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
331 }
332
333 const token = await issueSsoSession(result.user.id);
334 setCookie(c, "session", token, sessionCookieOptions());
335
336 await audit({
337 userId: result.user.id,
338 action: "auth.google.login",
339 metadata: {
340 googleSub: profile.sub,
341 email: profile.email || null,
342 },
343 });
344
345 return c.redirect("/");
346 } catch (err) {
347 console.error("[google-oauth] callback error:", err);
348 return c.redirect(
349 `/login?error=${encodeURIComponent(
350 err instanceof Error
351 ? `Google sign-in failed: ${err.message}`
352 : "Google sign-in failed"
353 )}`
354 );
355 }
356});
357
358export default googleOauth;