Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit582cdacunknown_key

feat(auth): real Sign in with Google button + admin config page

feat(auth): real Sign in with Google button + admin config page

The 'Google button' on /login was missing — gluecron had GitHub OAuth
wired as a one-click sign-in, but Google was only available via the
generic /admin/sso OIDC flow (manual issuer + endpoints config). This
ships a proper Google OAuth integration that mirrors the GitHub one
so it's a single admin form to enable.

What ships:

  1. src/lib/google-oauth.ts — Network helpers (~150 lines).
     - buildGoogleAuthorizeUrl(cfg, state, redirectUri, nonce)
         Constructs https://accounts.google.com/o/oauth2/v2/auth URL
         with response_type=code, scope=openid+email+profile,
         prompt=select_account (so users with multiple Google accounts
         get the picker), access_type=online.
     - exchangeGoogleCode(cfg, code, redirectUri)
         POSTs to https://oauth2.googleapis.com/token, returns
         { accessToken, idToken }.
     - fetchGoogleUserinfo(cfg, accessToken)
         Reads https://openidconnect.googleapis.com/v1/userinfo, returns
         { sub, email, emailVerified, name, picture }. Coerces
         email_verified="true" string to bool (Google is inconsistent).
     - FetchImpl injectable for tests, no DB access, pure functions.

  2. src/lib/sso.ts — Google config + user lookup helpers added.
     - GOOGLE_OAUTH_CONFIG_ID = 'google' (separate row in sso_config
       table, alongside id='github' and id='default').
     - getGoogleOauthConfig() / upsertGoogleOauthConfig(input)
         Seeds Google's endpoints by default so admins only paste
         Client ID + Secret.
     - findOrCreateUserFromGoogle(profile, cfg)
         Same find/match/create flow as GitHub: existing link by
         subject='google:<sub>' → match by verified email → auto-create
         (if enabled). Refuses to auto-create on unverified emails.
     - googleOauthRedirectUri() helper.

  3. src/routes/google-oauth.tsx — Hono routes (~330 lines).
     - GET  /admin/google-oauth     site-admin config form
     - POST /admin/google-oauth     save config (audit-logged)
     - GET  /login/google           kick off OAuth (sets state + nonce
                                    cookies, redirects to Google)
     - GET  /login/google/callback  exchange code, sign user in, audit
                                    auth.google.login event

  4. src/routes/auth.tsx — login page now renders a 'Sign in with Google'
     button (with official 4-colour Google logo SVG) above the GitHub
     button when admin has enabled Google OAuth. Replaces the old generic
     SSO row layout with consistent .oauth-btn styling for Google +
     GitHub + SSO.

  5. src/views/layout.tsx — .oauth-btn CSS (~70 lines): single-line
     button with logo on the left, label centred, 1px hover lift with
     soft shadow, accent focus ring. .auth-divider gets proper
     'or' separator with hairlines on both sides.

  6. src/app.tsx — mount googleOauthRoutes alongside githubOauthRoutes.

  7. src/routes/admin.tsx — admin dashboard exposes both 'Sign in with
     Google' and 'Sign in with GitHub' config links alongside Enterprise
     SSO so they're discoverable.

  8. src/__tests__/google-oauth.test.ts — 8 new tests covering route
     registration, URL builder edge cases, token exchange request shape,
     userinfo parsing including string-coerced email_verified.

How to enable on the live site (one-time admin setup):
  1. console.cloud.google.com/apis/credentials → Create OAuth Client
     (Web application, Authorised redirect = https://gluecron.com/login/google/callback)
  2. /admin/google-oauth → paste Client ID + Secret → check Enable → Save
  3. The 'Sign in with Google' button appears on /login automatically.

Tests: 2011/2011 pass (+8 new). tsc clean.

This is real, not a half-built button. The flow goes: visitor clicks →
Google account picker → return to gluecron with session cookie set →
audit log entry on auth.google.login. Existing accounts linked by
email; new accounts auto-created (admin-toggleable) with the Google
profile name as the seed for username.
Claude committed on May 17, 2026Parent: ed6e438
8 files changed+10469582cdac2c5415d4066e05d50aefaea2ced393360
8 changed files+1046−9
Addedsrc/__tests__/google-oauth.test.ts+182−0View fileUnifiedSplit
1/**
2 * Smoke tests for the Google OAuth login flow + config page.
3 *
4 * Mirrors the structure of github-oauth.test.ts (if it exists). Covers:
5 * - /admin/google-oauth requires admin auth
6 * - /login/google redirects to /login?error= when not configured
7 * - The OAuth URL builder produces a valid Google authorize URL
8 * - The login page renders a "Sign in with Google" button when enabled
9 *
10 * These tests exercise the route registration + the pure helper functions
11 * in src/lib/google-oauth.ts. The full callback flow (token exchange +
12 * userinfo) requires a real Google response and is exercised manually
13 * during deploy validation.
14 */
15
16import { describe, it, expect } from "bun:test";
17import app from "../app";
18import {
19 buildGoogleAuthorizeUrl,
20 fetchGoogleUserinfo,
21 exchangeGoogleCode,
22} from "../lib/google-oauth";
23
24describe("google-oauth — route registration", () => {
25 it("/admin/google-oauth without auth redirects to /login", async () => {
26 const res = await app.request("/admin/google-oauth", {
27 redirect: "manual",
28 });
29 expect([302, 303]).toContain(res.status);
30 const loc = res.headers.get("location") || "";
31 expect(loc).toContain("/login");
32 });
33
34 it("/login/google when unconfigured redirects to /login with error", async () => {
35 const res = await app.request("/login/google", { redirect: "manual" });
36 expect([302, 303]).toContain(res.status);
37 const loc = res.headers.get("location") || "";
38 expect(loc).toContain("/login?error=");
39 });
40
41 it("/login/google/callback without code params redirects with error", async () => {
42 const res = await app.request("/login/google/callback", {
43 redirect: "manual",
44 });
45 expect([302, 303]).toContain(res.status);
46 const loc = res.headers.get("location") || "";
47 expect(loc).toContain("/login?error=");
48 });
49});
50
51describe("google-oauth — buildGoogleAuthorizeUrl", () => {
52 it("constructs a valid Google authorize URL with required params", () => {
53 const url = buildGoogleAuthorizeUrl(
54 {
55 authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
56 clientId: "client-id-123",
57 scopes: "openid email profile",
58 },
59 "state-abc",
60 "https://example.com/login/google/callback",
61 "nonce-xyz"
62 );
63 const u = new URL(url);
64 expect(u.origin + u.pathname).toBe(
65 "https://accounts.google.com/o/oauth2/v2/auth"
66 );
67 expect(u.searchParams.get("client_id")).toBe("client-id-123");
68 expect(u.searchParams.get("response_type")).toBe("code");
69 expect(u.searchParams.get("scope")).toBe("openid email profile");
70 expect(u.searchParams.get("state")).toBe("state-abc");
71 expect(u.searchParams.get("nonce")).toBe("nonce-xyz");
72 expect(u.searchParams.get("redirect_uri")).toBe(
73 "https://example.com/login/google/callback"
74 );
75 // We force account picker so users with multiple Google accounts can
76 // choose; without this Google silently uses the most-recent one.
77 expect(u.searchParams.get("prompt")).toBe("select_account");
78 });
79
80 it("throws when authorization_endpoint or client_id is missing", () => {
81 expect(() =>
82 buildGoogleAuthorizeUrl(
83 {
84 authorizationEndpoint: null,
85 clientId: "abc",
86 scopes: null,
87 } as any,
88 "s",
89 "r",
90 "n"
91 )
92 ).toThrow();
93 expect(() =>
94 buildGoogleAuthorizeUrl(
95 {
96 authorizationEndpoint: "https://x",
97 clientId: null,
98 scopes: null,
99 } as any,
100 "s",
101 "r",
102 "n"
103 )
104 ).toThrow();
105 });
106});
107
108describe("google-oauth — exchangeGoogleCode + fetchGoogleUserinfo", () => {
109 it("exchangeGoogleCode posts urlencoded body and returns access_token", async () => {
110 const captured: { url?: string; body?: string } = {};
111 const fakeFetch = (async (url: any, init: any) => {
112 captured.url = String(url);
113 captured.body = String(init.body);
114 return new Response(
115 JSON.stringify({ access_token: "tok-1", id_token: "id-1" }),
116 { status: 200, headers: { "content-type": "application/json" } }
117 );
118 }) as typeof fetch;
119
120 const result = await exchangeGoogleCode(
121 {
122 tokenEndpoint: "https://oauth2.googleapis.com/token",
123 clientId: "cid",
124 clientSecret: "secret",
125 },
126 "auth-code",
127 "https://example.com/cb",
128 fakeFetch
129 );
130 expect(result.accessToken).toBe("tok-1");
131 expect(result.idToken).toBe("id-1");
132 expect(captured.url).toBe("https://oauth2.googleapis.com/token");
133 expect(captured.body).toContain("grant_type=authorization_code");
134 expect(captured.body).toContain("code=auth-code");
135 expect(captured.body).toContain("client_id=cid");
136 });
137
138 it("fetchGoogleUserinfo parses sub + emailVerified correctly", async () => {
139 const fakeFetch = (async () =>
140 new Response(
141 JSON.stringify({
142 sub: "12345",
143 email: "user@example.com",
144 email_verified: true,
145 name: "Jane Doe",
146 picture: "https://lh.example/avatar.jpg",
147 }),
148 { status: 200, headers: { "content-type": "application/json" } }
149 )) as typeof fetch;
150
151 const info = await fetchGoogleUserinfo(
152 { userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo" },
153 "tok",
154 fakeFetch
155 );
156 expect(info.sub).toBe("12345");
157 expect(info.email).toBe("user@example.com");
158 expect(info.emailVerified).toBe(true);
159 expect(info.name).toBe("Jane Doe");
160 });
161
162 it("fetchGoogleUserinfo coerces email_verified=\"true\" string to bool", async () => {
163 const fakeFetch = (async () =>
164 new Response(
165 JSON.stringify({
166 sub: "999",
167 email: "x@y.z",
168 email_verified: "true",
169 name: null,
170 picture: null,
171 }),
172 { status: 200, headers: { "content-type": "application/json" } }
173 )) as typeof fetch;
174
175 const info = await fetchGoogleUserinfo(
176 { userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo" },
177 "tok",
178 fakeFetch
179 );
180 expect(info.emailVerified).toBe(true);
181 });
182});
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
111111import sponsorsRoutes from "./routes/sponsors";
112112import ssoRoutes from "./routes/sso";
113113import githubOauthRoutes from "./routes/github-oauth";
114import googleOauthRoutes from "./routes/google-oauth";
114115import symbolsRoutes from "./routes/symbols";
115116import templatesRoutes from "./routes/templates";
116117import trafficRoutes from "./routes/traffic";
438439app.route("/", sponsorsRoutes);
439440app.route("/", ssoRoutes);
440441app.route("/", githubOauthRoutes);
442app.route("/", googleOauthRoutes);
441443app.route("/", symbolsRoutes);
442444app.route("/", templatesRoutes);
443445app.route("/", trafficRoutes);
Addedsrc/lib/google-oauth.ts+156−0View fileUnifiedSplit
1/**
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}
Modifiedsrc/lib/sso.ts+200−0View fileUnifiedSplit
659659 return `${config.appBaseUrl}/login/github/callback`;
660660}
661661
662// ----------------------------------------------------------------------------
663// "Sign in with Google" — mirrors the GitHub OAuth surface above.
664//
665// Storage: a separate row in `sso_config` keyed by id='google'. Subject in
666// `sso_user_links` is prefixed `google:` so it never collides with
667// `github:` or the default OIDC `default:`.
668// ----------------------------------------------------------------------------
669
670const GOOGLE_OAUTH_CONFIG_ID = "google";
671
672export async function getGoogleOauthConfig(): Promise<SsoConfig | null> {
673 try {
674 const [row] = await db
675 .select()
676 .from(ssoConfig)
677 .where(eq(ssoConfig.id, GOOGLE_OAUTH_CONFIG_ID))
678 .limit(1);
679 return row || null;
680 } catch {
681 return null;
682 }
683}
684
685export async function upsertGoogleOauthConfig(
686 input: Partial<
687 Pick<
688 SsoConfigInput,
689 "enabled" | "clientId" | "clientSecret" | "autoCreateUsers" | "allowedEmailDomains"
690 >
691 >
692): Promise<{ ok: true } | { ok: false; error: string }> {
693 try {
694 const now = new Date();
695 const values = {
696 id: GOOGLE_OAUTH_CONFIG_ID,
697 enabled: !!input.enabled,
698 providerName: "Google",
699 issuer: "https://accounts.google.com",
700 authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
701 tokenEndpoint: "https://oauth2.googleapis.com/token",
702 userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
703 clientId: emptyToNull(input.clientId),
704 clientSecret: emptyToNull(input.clientSecret),
705 scopes: "openid email profile",
706 allowedEmailDomains: emptyToNull(input.allowedEmailDomains ?? null),
707 autoCreateUsers: input.autoCreateUsers !== false,
708 updatedAt: now,
709 };
710 await db
711 .insert(ssoConfig)
712 .values(values)
713 .onConflictDoUpdate({
714 target: ssoConfig.id,
715 set: {
716 enabled: values.enabled,
717 providerName: values.providerName,
718 issuer: values.issuer,
719 authorizationEndpoint: values.authorizationEndpoint,
720 tokenEndpoint: values.tokenEndpoint,
721 userinfoEndpoint: values.userinfoEndpoint,
722 clientId: values.clientId,
723 clientSecret: values.clientSecret,
724 scopes: values.scopes,
725 allowedEmailDomains: values.allowedEmailDomains,
726 autoCreateUsers: values.autoCreateUsers,
727 updatedAt: now,
728 },
729 });
730 return { ok: true };
731 } catch (err) {
732 return {
733 ok: false,
734 error: err instanceof Error ? err.message : "Failed to save",
735 };
736 }
737}
738
739export interface GoogleProfile {
740 sub: string;
741 email: string | null;
742 emailVerified: boolean;
743 name: string | null;
744 picture: string | null;
745}
746
747/**
748 * Find-or-create a user from a Google profile. Same flow as the GitHub
749 * variant: existing link → match by email → auto-create (if enabled and
750 * email is verified). Refuses to auto-create on unverified emails (Google
751 * users can have unverified addresses on some legacy account states).
752 */
753export async function findOrCreateUserFromGoogle(
754 profile: GoogleProfile,
755 cfg: SsoConfig
756): Promise<{ ok: true; user: User } | { ok: false; error: string }> {
757 const subject = `google:${profile.sub}`;
758
759 // 1. Existing link
760 const link = await findSsoLinkBySubject(subject);
761 if (link) {
762 const [user] = await db
763 .select()
764 .from(users)
765 .where(eq(users.id, link.userId))
766 .limit(1);
767 if (user) return { ok: true, user };
768 await db
769 .delete(ssoUserLinks)
770 .where(eq(ssoUserLinks.subject, subject))
771 .catch((err) => {
772 console.warn(
773 "[google-oauth] orphan link cleanup failed:",
774 err instanceof Error ? err.message : err
775 );
776 });
777 }
778
779 // 2. Domain gate
780 if (!emailDomainAllowed(profile.email, cfg.allowedEmailDomains)) {
781 return {
782 ok: false,
783 error: "Your email domain is not permitted for Google sign-in.",
784 };
785 }
786
787 // 3. Match by email (only if Google says the email is verified)
788 if (profile.email && profile.emailVerified) {
789 const [existing] = await db
790 .select()
791 .from(users)
792 .where(eq(users.email, profile.email))
793 .limit(1);
794 if (existing) {
795 await db
796 .insert(ssoUserLinks)
797 .values({
798 userId: existing.id,
799 subject,
800 emailAtLink: profile.email,
801 })
802 .onConflictDoNothing();
803 return { ok: true, user: existing };
804 }
805 }
806
807 // 4. Auto-create
808 if (!cfg.autoCreateUsers) {
809 return {
810 ok: false,
811 error:
812 "No matching account, and the administrator has disabled Google account creation.",
813 };
814 }
815
816 if (!profile.email) {
817 return {
818 ok: false,
819 error: "Google did not return an email address. Try signing up manually.",
820 };
821 }
822 if (!profile.emailVerified) {
823 return {
824 ok: false,
825 error:
826 "Google reports this email as unverified. Verify it in your Google account and retry.",
827 };
828 }
829
830 const usernameSeed =
831 profile.email.split("@")[0] || profile.name || "user";
832 const username = await pickAvailableUsername(usernameSeed);
833
834 const fakeHash = "sso-only:" + randomToken(32);
835
836 const [user] = await db
837 .insert(users)
838 .values({
839 username,
840 email: profile.email,
841 passwordHash: fakeHash,
842 })
843 .returning();
844
845 await db
846 .insert(ssoUserLinks)
847 .values({
848 userId: user.id,
849 subject,
850 emailAtLink: profile.email,
851 })
852 .onConflictDoNothing();
853
854 return { ok: true, user };
855}
856
857/** Compute the Google OAuth redirect URI for this deployment. */
858export function googleOauthRedirectUri(): string {
859 return `${config.appBaseUrl}/login/google/callback`;
860}
861
662862// ----------------------------------------------------------------------------
663863// Test-only exports
664864// ----------------------------------------------------------------------------
Modifiedsrc/routes/admin.tsx+6−0View fileUnifiedSplit
137137 <a href="/admin/digests" class="btn">
138138 Email digests
139139 </a>
140 <a href="/admin/google-oauth" class="btn">
141 Sign in with Google
142 </a>
143 <a href="/admin/github-oauth" class="btn">
144 Sign in with GitHub
145 </a>
140146 <a href="/admin/sso" class="btn">
141147 Enterprise SSO
142148 </a>
Modifiedsrc/routes/auth.tsx+76−9View fileUnifiedSplit
2222} from "../lib/auth";
2323import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
2424import { cancelAccountDeletion } from "../lib/account-deletion";
25import { getSsoConfig, getGithubOauthConfig } from "../lib/sso";
25import {
26 getSsoConfig,
27 getGithubOauthConfig,
28 getGoogleOauthConfig,
29} from "../lib/sso";
2630import { Layout } from "../views/layout";
2731import {
2832 Form,
302306 const githubCfg = await getGithubOauthConfig();
303307 const githubEnabled =
304308 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
309 // "Sign in with Google" (separate row keyed id='google'). Same wiring
310 // pattern as GitHub OAuth.
311 const googleCfg = await getGoogleOauthConfig();
312 const googleEnabled =
313 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
305314 const csrf = c.get("csrfToken") as string | undefined;
306315 return c.html(
307316 <Layout title="Sign in" user={null}>
349358 Sign in
350359 </Button>
351360 </Form>
361 {/* Provider buttons (Google + GitHub) — only rendered when the
362 admin has configured + enabled them via /admin/google-oauth or
363 /admin/github-oauth. The "or" divider above the first available
364 provider sets the visual break from the password form. */}
365 {(googleEnabled || githubEnabled) && (
366 <div class="auth-divider">or</div>
367 )}
368 {googleEnabled && (
369 <a
370 href="/login/google"
371 class="btn btn-block oauth-btn oauth-google"
372 aria-label="Sign in with Google"
373 >
374 <svg
375 class="oauth-icon"
376 width="18"
377 height="18"
378 viewBox="0 0 18 18"
379 aria-hidden="true"
380 >
381 <path
382 d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"
383 fill="#4285F4"
384 />
385 <path
386 d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"
387 fill="#34A853"
388 />
389 <path
390 d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"
391 fill="#FBBC05"
392 />
393 <path
394 d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"
395 fill="#EA4335"
396 />
397 </svg>
398 <span>Sign in with Google</span>
399 </a>
400 )}
352401 {githubEnabled && (
353 <div class="auth-sso">
354 <div class="auth-divider">or</div>
355 <LinkButton href="/login/github">Sign in with GitHub</LinkButton>
356 </div>
402 <a
403 href="/login/github"
404 class="btn btn-block oauth-btn oauth-github"
405 aria-label="Sign in with GitHub"
406 style={googleEnabled ? "margin-top:8px" : undefined}
407 >
408 <svg
409 class="oauth-icon"
410 width="18"
411 height="18"
412 viewBox="0 0 16 16"
413 aria-hidden="true"
414 fill="currentColor"
415 >
416 <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
417 </svg>
418 <span>Sign in with GitHub</span>
419 </a>
357420 )}
358421 {ssoEnabled && (
359 <div class="auth-sso">
360 <div class="auth-divider">or</div>
361 <LinkButton href="/login/sso">Sign in with {ssoLabel}</LinkButton>
362 </div>
422 <a
423 href="/login/sso"
424 class="btn btn-block oauth-btn oauth-sso"
425 aria-label={`Sign in with ${ssoLabel}`}
426 style={(googleEnabled || githubEnabled) ? "margin-top:8px" : undefined}
427 >
428 <span>Sign in with {ssoLabel}</span>
429 </a>
363430 )}
364431 <div class="auth-passkey">
365432 <div class="auth-divider">or</div>
Addedsrc/routes/google-oauth.tsx+358−0View fileUnifiedSplit
1/**
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 ? "(storedleave 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;
Modifiedsrc/views/layout.tsx+66−0View fileUnifiedSplit
18631863 font-weight: 600;
18641864 margin-top: 4px;
18651865 }
1866
1867 /* OAuth provider buttons (Google / GitHub / SSO). Single-line layout
1868 with a brand-coloured logo on the left and a label centred-trailing.
1869 Hover lifts 1px with a subtle shadow — matches the rest of the
1870 button system but reads as a brand-affiliated action, not a brand-
1871 coloured primary CTA. */
1872 .auth-container .oauth-btn {
1873 display: flex;
1874 align-items: center;
1875 justify-content: center;
1876 gap: 10px;
1877 width: 100%;
1878 padding: 11px 16px;
1879 background: var(--bg-surface, var(--bg-elevated));
1880 border: 1px solid var(--border);
1881 border-radius: var(--r-md, 8px);
1882 color: var(--text-strong);
1883 font-family: var(--font-sans);
1884 font-size: 14.5px;
1885 font-weight: 500;
1886 text-decoration: none;
1887 transition:
1888 transform var(--t-base, 180ms) var(--ease, ease),
1889 box-shadow var(--t-base, 180ms) var(--ease, ease),
1890 background var(--t-fast, 120ms) var(--ease, ease),
1891 border-color var(--t-fast, 120ms) var(--ease, ease);
1892 }
1893 .auth-container .oauth-btn:hover {
1894 transform: translateY(-1px);
1895 background: var(--bg-hover);
1896 border-color: var(--text-muted);
1897 color: var(--text-strong);
1898 box-shadow: 0 4px 14px -4px rgba(0,0,0,0.25);
1899 text-decoration: none;
1900 }
1901 .auth-container .oauth-btn:focus-visible {
1902 outline: 2px solid rgba(140, 109, 255, 0.55);
1903 outline-offset: 2px;
1904 }
1905 .auth-container .oauth-btn .oauth-icon {
1906 flex-shrink: 0;
1907 }
1908 /* GitHub icon adopts the text colour so it reads in both themes. */
1909 .auth-container .oauth-github .oauth-icon {
1910 color: var(--text-strong);
1911 }
1912 /* Google logo uses the official 4-colour treatment via inline SVG fill
1913 attributes — nothing to override here. */
1914 /* "or" divider between the password form and provider buttons */
1915 .auth-container .auth-divider {
1916 display: flex;
1917 align-items: center;
1918 gap: 12px;
1919 margin: 20px 0 12px;
1920 color: var(--text-faint);
1921 font-size: 12px;
1922 letter-spacing: 0.08em;
1923 text-transform: uppercase;
1924 }
1925 .auth-container .auth-divider::before,
1926 .auth-container .auth-divider::after {
1927 content: '';
1928 flex: 1;
1929 height: 1px;
1930 background: var(--border);
1931 }
18661932 .auth-error {
18671933 background: rgba(248,113,113,0.08);
18681934 border: 1px solid rgba(248,113,113,0.35);
18691935