Commit9887dd0unknown_key
fix(auth): stop disabled DB row shadowing Google env bootstrap; pin APP_BASE_URL
fix(auth): stop disabled DB row shadowing Google env bootstrap; pin APP_BASE_URL Google sign-in stayed dark on production even after the GOOGLE_OAUTH_* env bootstrap shipped. Two causes: 1. getGoogleOauthConfig() returned any DB row that carried credentials *regardless of its enabled flag*, so a half-finished /admin/google-oauth save (creds entered, Enable left off) silently shadowed a working env bootstrap — producing a misleading "not enabled". Precedence is now split into a pure, unit-tested resolveGoogleOauthConfig(): an enabled+ credentialed admin row wins; otherwise a complete env bootstrap wins and is never shadowed by a disabled/partial row; otherwise the row as-is. 2. APP_BASE_URL was unset in fly.toml [env], defaulting appBaseUrl to http://localhost:3000 — which makes Google reject the callback with redirect_uri_mismatch the moment sign-in is enabled. Pinned to https://gluecron.com (a Fly secret of the same name still overrides). Also: the /admin/google-oauth page now shows a live status panel (ON/OFF with the precise reason, env-bootstrap detection, and a redirect_uri_mismatch warning when APP_BASE_URL looks local), and /login/google returns specific errors (not configured vs disabled vs incomplete) instead of one vague string. https://claude.ai/code/session_01Gi3ZS4USc7ix2mgMALpz97
4 files changed+174−99887dd06b1b195a607f1c2974ad2587b53ead799
4 changed files+174−9
Modifiedfly.toml+5−0View fileUnifiedSplit
@@ -8,6 +8,11 @@ primary_region = "iad"
88 GIT_REPOS_PATH = "/app/repos"
99 PORT = "3000"
1010 NODE_ENV = "production"
11 # Canonical public origin. Without this, config.appBaseUrl falls back to
12 # http://localhost:3000, which breaks every OAuth redirect (Google rejects
13 # the callback with redirect_uri_mismatch) and points email/webhook links
14 # at localhost. A Fly secret of the same name still overrides this default.
15 APP_BASE_URL = "https://gluecron.com"
1116
1217[http_service]
1318 internal_port = 3000
Modifiedsrc/__tests__/google-oauth-env.test.ts+81−1View fileUnifiedSplit
@@ -1,5 +1,36 @@
11import { describe, expect, test } from "bun:test";
2import { googleOauthConfigFromEnv } from "../lib/sso";
2import {
3 googleOauthConfigFromEnv,
4 resolveGoogleOauthConfig,
5} from "../lib/sso";
6import type { SsoConfig } from "../db/schema";
7
8/** Minimal SsoConfig row builder for precedence tests. */
9function row(overrides: Partial<SsoConfig>): SsoConfig {
10 const now = new Date();
11 return {
12 id: "google",
13 enabled: false,
14 providerName: "Google",
15 issuer: "https://accounts.google.com",
16 authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
17 tokenEndpoint: "https://oauth2.googleapis.com/token",
18 userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
19 clientId: null,
20 clientSecret: null,
21 scopes: "openid email profile",
22 allowedEmailDomains: null,
23 autoCreateUsers: true,
24 createdAt: now,
25 updatedAt: now,
26 ...overrides,
27 } as SsoConfig;
28}
29
30const ENV_CFG = googleOauthConfigFromEnv({
31 GOOGLE_OAUTH_CLIENT_ID: "env-id.apps.googleusercontent.com",
32 GOOGLE_OAUTH_CLIENT_SECRET: "env-secret",
33});
334
435describe("googleOauthConfigFromEnv", () => {
536 test("returns null when credentials are absent", () => {
@@ -51,3 +82,52 @@ describe("googleOauthConfigFromEnv", () => {
5182 expect(cfg!.allowedEmailDomains).toBe("example.com,corp.example.com");
5283 });
5384});
85
86describe("resolveGoogleOauthConfig — precedence", () => {
87 test("an enabled, fully-credentialed admin row wins over env", () => {
88 const dbRow = row({
89 enabled: true,
90 clientId: "db-id",
91 clientSecret: "db-secret",
92 });
93 const live = resolveGoogleOauthConfig(dbRow, ENV_CFG);
94 expect(live).toBe(dbRow);
95 expect(live!.clientId).toBe("db-id");
96 });
97
98 test("a DISABLED credentialed row does NOT shadow the env bootstrap", () => {
99 // Regression: a half-finished /admin/google-oauth save (creds entered,
100 // Enable left off) used to suppress a working GOOGLE_OAUTH_* bootstrap,
101 // leaving "Sign in with Google" dark with a misleading "not enabled".
102 const dbRow = row({
103 enabled: false,
104 clientId: "db-id",
105 clientSecret: "db-secret",
106 });
107 const live = resolveGoogleOauthConfig(dbRow, ENV_CFG);
108 expect(live).toBe(ENV_CFG);
109 expect(live!.enabled).toBe(true);
110 });
111
112 test("a credential-less row never shadows the env bootstrap", () => {
113 const live = resolveGoogleOauthConfig(row({ enabled: true }), ENV_CFG);
114 expect(live).toBe(ENV_CFG);
115 });
116
117 test("env bootstrap alone (no DB row) is live", () => {
118 expect(resolveGoogleOauthConfig(null, ENV_CFG)).toBe(ENV_CFG);
119 });
120
121 test("with no env, a disabled row is returned as-is (caller gates on it)", () => {
122 const dbRow = row({
123 enabled: false,
124 clientId: "db-id",
125 clientSecret: "db-secret",
126 });
127 expect(resolveGoogleOauthConfig(dbRow, null)).toBe(dbRow);
128 });
129
130 test("nothing configured anywhere resolves to null", () => {
131 expect(resolveGoogleOauthConfig(null, null)).toBeNull();
132 });
133});
Modifiedsrc/lib/sso.ts+32−6View fileUnifiedSplit
@@ -709,10 +709,37 @@ export function googleOauthConfigFromEnv(
709709}
710710
711711/**
712 * Resolution order: a DB row saved via /admin/google-oauth that actually
713 * carries credentials wins (it's the admin's explicit choice, including its
714 * enabled flag); otherwise fall back to env-var configuration; otherwise
715 * whatever partial row exists (or null).
712 * Pure precedence resolver between the persisted `id='google'` row and the
713 * env-var bootstrap. Split out from `getGoogleOauthConfig` so the ordering is
714 * unit-testable without a database.
715 *
716 * Order:
717 * 1. An admin row that is BOTH enabled and fully credentialed is the
718 * explicit operator choice — it wins outright (even over different env
719 * credentials).
720 * 2. Otherwise a complete env-var bootstrap (always enabled when present)
721 * is the live config. Critically, it must NOT be shadowed by a
722 * saved-but-disabled or half-filled DB row. That shadowing was a real
723 * foot-gun: an earlier half-finished /admin/google-oauth save (e.g.
724 * credentials entered but the Enable box left unticked) left a disabled
725 * row behind, which then suppressed a perfectly good GOOGLE_OAUTH_*
726 * bootstrap — so "Sign in with Google" stayed dark with a misleading
727 * "not enabled".
728 * 3. Otherwise fall back to whatever row exists (possibly disabled/partial,
729 * possibly null). Callers still gate on `enabled` + credentials.
730 */
731export function resolveGoogleOauthConfig(
732 row: SsoConfig | null,
733 envCfg: SsoConfig | null
734): SsoConfig | null {
735 if (row?.enabled && row.clientId && row.clientSecret) return row;
736 if (envCfg) return envCfg;
737 return row;
738}
739
740/**
741 * The live "Sign in with Google" config, merging the persisted admin row with
742 * the env-var bootstrap via {@link resolveGoogleOauthConfig}.
716743 */
717744export async function getGoogleOauthConfig(): Promise<SsoConfig | null> {
718745 let row: SsoConfig | null = null;
@@ -726,8 +753,7 @@ export async function getGoogleOauthConfig(): Promise<SsoConfig | null> {
726753 } catch {
727754 row = null;
728755 }
729 if (row?.clientId && row?.clientSecret) return row;
730 return googleOauthConfigFromEnv() ?? row;
756 return resolveGoogleOauthConfig(row, googleOauthConfigFromEnv());
731757}
732758
733759export async function upsertGoogleOauthConfig(
Modifiedsrc/routes/google-oauth.tsx+56−2View fileUnifiedSplit
@@ -22,6 +22,7 @@ import { audit } from "../lib/notify";
2222import {
2323 findOrCreateUserFromGoogle,
2424 getGoogleOauthConfig,
25 googleOauthConfigFromEnv,
2526 googleOauthRedirectUri,
2627 issueSsoSession,
2728 randomToken,
@@ -85,6 +86,21 @@ googleOauth.get("/admin/google-oauth", requireAuth, async (c) => {
8586 const error = c.req.query("error");
8687 const redirectUri = googleOauthRedirectUri();
8788
89 // ── 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
88104 return c.html(
89105 <Layout title="Google sign-in — Admin" user={user}>
90106 <div class="settings-container" style="max-width:780px">
@@ -101,6 +117,39 @@ googleOauth.get("/admin/google-oauth", requireAuth, async (c) => {
101117 </a>{" "}
102118 (set application type = Web), then paste the Client ID + Secret here.
103119 </p>
120
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
104153 <div class="panel" style="padding:12px;margin-bottom:16px">
105154 <div
106155 style="font-size:12px;text-transform:uppercase;color:var(--text-muted)"
@@ -246,9 +295,14 @@ googleOauth.post("/admin/google-oauth", requireAuth, async (c) => {
246295
247296googleOauth.get("/login/google", async (c) => {
248297 const cfg = await getGoogleOauthConfig();
249 if (!cfg || !cfg.enabled) {
298 if (!cfg) {
250299 return c.redirect(
251 `/login?error=${encodeURIComponent("Google sign-in is not enabled")}`
300 `/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")}`
252306 );
253307 }
254308 if (!cfg.clientId || !cfg.clientSecret) {
255309