Commitad3ddf6unknown_key
fix(auth): self-healing Google OAuth callback; drop Fly APP_BASE_URL dependency
fix(auth): self-healing Google OAuth callback; drop Fly APP_BASE_URL dependency
Make Google sign-in reliable regardless of server/env config. The callback
URI is now derived from the live request via resolveGoogleRedirectUri():
- An explicitly pinned https:// base URL still wins (deterministic when set
via /admin/integrations or env).
- Otherwise the origin is taken from the request, honouring
X-Forwarded-Proto / X-Forwarded-Host so it resolves to the public https
host behind Fly's TLS-terminating proxy. Any non-localhost host defaults
to https; only loopback stays http. This removes the failure mode where an
unset APP_BASE_URL made the callback http://localhost:3000 and Google
rejected it with redirect_uri_mismatch.
The same resolver feeds all three sites (admin display, /login/google
authorize, /login/google/callback token exchange) so the URI is byte-identical
across the flow. APP_BASE_URL is no longer required for Google sign-in, so the
fly.toml default is dropped — base URL is dashboard-managed (/admin/integrations)
for the features that still use it.
Pure resolver with 7 unit tests (forwarded-proto upgrade, XFH precedence,
comma-list headers, localhost dev, no-input fallback). Full suite green.
https://claude.ai/code/session_01Gi3ZS4USc7ix2mgMALpz975 files changed+178−22ad3ddf60269c34edb5e367d4db7ac03accc6f246
5 changed files+178−22
Modified.env.example+4−2View fileUnifiedSplit
@@ -125,8 +125,10 @@ VALKEY_URL=
125125# ── "Sign in with Google" bootstrap (2026-06-12) ─────────────────────────
126126# Setting this pair enables the Google sign-in button without touching the
127127# /admin/google-oauth page (a DB config saved there takes precedence).
128# Create a Web OAuth client at console.cloud.google.com/apis/credentials and
129# add <APP_BASE_URL>/login/google/callback as an authorised redirect URI.
128# Create a Web OAuth client at console.cloud.google.com/apis/credentials.
129# The callback auto-resolves from the request (honours X-Forwarded-Proto),
130# so register https://<your-public-host>/login/google/callback in the
131# Console — no APP_BASE_URL required for Google sign-in to work.
130132GOOGLE_OAUTH_CLIENT_ID=
131133GOOGLE_OAUTH_CLIENT_SECRET=
132134# Optional: set to 0 to refuse auto-creating accounts on first Google login.
Modifiedfly.toml+0−5View fileUnifiedSplit
@@ -8,11 +8,6 @@ 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"
1611
1712[http_service]
1813 internal_port = 3000
Modifiedsrc/__tests__/google-oauth.test.ts+72−0View fileUnifiedSplit
@@ -19,6 +19,7 @@ import {
1919 buildGoogleAuthorizeUrl,
2020 fetchGoogleUserinfo,
2121 exchangeGoogleCode,
22 resolveGoogleRedirectUri,
2223} from "../lib/google-oauth";
2324
2425describe("google-oauth — route registration", () => {
@@ -105,6 +106,77 @@ describe("google-oauth — buildGoogleAuthorizeUrl", () => {
105106 });
106107});
107108
109describe("google-oauth — resolveGoogleRedirectUri (self-healing)", () => {
110 const PATH = "/login/google/callback";
111
112 it("uses an explicit https base URL verbatim (trailing slash trimmed)", () => {
113 expect(
114 resolveGoogleRedirectUri({ configuredBaseUrl: "https://gluecron.com/" })
115 ).toBe(`https://gluecron.com${PATH}`);
116 });
117
118 it("ignores a localhost base URL and derives from the request", () => {
119 // The production failure mode: APP_BASE_URL unset → config.appBaseUrl
120 // defaults to http://localhost:3000. Behind Fly the edge sets
121 // X-Forwarded-Proto:https and forwards the real host, so we must still
122 // produce the public https callback.
123 expect(
124 resolveGoogleRedirectUri({
125 configuredBaseUrl: "http://localhost:3000",
126 forwardedProto: "https",
127 forwardedHost: "gluecron.com",
128 host: "gluecron.com",
129 requestUrl: "http://gluecron.com/login/google",
130 })
131 ).toBe(`https://gluecron.com${PATH}`);
132 });
133
134 it("upgrades a public host to https when no forwarded proto is present", () => {
135 expect(
136 resolveGoogleRedirectUri({
137 host: "gluecron.com",
138 requestUrl: "http://gluecron.com/login/google",
139 })
140 ).toBe(`https://gluecron.com${PATH}`);
141 });
142
143 it("honours X-Forwarded-Host over the raw Host header", () => {
144 expect(
145 resolveGoogleRedirectUri({
146 forwardedProto: "https",
147 forwardedHost: "gluecron.com",
148 host: "internal.fly.dev",
149 requestUrl: "http://internal.fly.dev/login/google",
150 })
151 ).toBe(`https://gluecron.com${PATH}`);
152 });
153
154 it("takes only the first value of a comma-listed forwarded header", () => {
155 expect(
156 resolveGoogleRedirectUri({
157 forwardedProto: "https, http",
158 forwardedHost: "gluecron.com, proxy.internal",
159 requestUrl: "http://x/login/google",
160 })
161 ).toBe(`https://gluecron.com${PATH}`);
162 });
163
164 it("keeps localhost on its request scheme for local dev", () => {
165 expect(
166 resolveGoogleRedirectUri({
167 host: "localhost:3000",
168 requestUrl: "http://localhost:3000/login/google",
169 })
170 ).toBe(`http://localhost:3000${PATH}`);
171 });
172
173 it("falls back to localhost when there is nothing to derive from", () => {
174 expect(resolveGoogleRedirectUri({})).toBe(
175 `http://localhost:3000${PATH}`
176 );
177 });
178});
179
108180describe("google-oauth — exchangeGoogleCode + fetchGoogleUserinfo", () => {
109181 it("exchangeGoogleCode posts urlencoded body and returns access_token", async () => {
110182 const captured: { url?: string; body?: string } = {};
Modifiedsrc/lib/google-oauth.ts+72−0View fileUnifiedSplit
@@ -154,3 +154,75 @@ export async function fetchGoogleUserinfo(
154154 picture: raw.picture ?? null,
155155 };
156156}
157
158/**
159 * Resolve the absolute "Sign in with Google" callback URI for a request.
160 *
161 * Reliability is the whole point here. The URI we send at /login/google must
162 * be byte-identical to the one we send at the token exchange AND must match a
163 * URI registered in the Google Cloud Console — and it has to keep working even
164 * when nobody has set APP_BASE_URL. Resolution order:
165 *
166 * 1. If an operator pinned an absolute `https://` base URL (via
167 * /admin/integrations or an env var), trust it verbatim. It's the
168 * canonical choice and avoids ambiguity when the app answers on several
169 * hostnames.
170 * 2. Otherwise derive the origin from the actual request. Behind a TLS-
171 * terminating proxy (Fly) the internal hop is plain http, so we honour
172 * `X-Forwarded-Proto` / `X-Forwarded-Host`; any non-localhost host
173 * defaults to https. This self-heals the classic failure where
174 * APP_BASE_URL is unset and the callback would otherwise resolve to
175 * `http://localhost:3000/...` and trip `redirect_uri_mismatch`.
176 *
177 * Pure: every input is passed in, so it is fully unit-testable.
178 */
179export function resolveGoogleRedirectUri(opts: {
180 configuredBaseUrl?: string | null;
181 forwardedProto?: string | null;
182 forwardedHost?: string | null;
183 host?: string | null;
184 requestUrl?: string | null;
185}): string {
186 const PATH = "/login/google/callback";
187 const firstValue = (v: string | null | undefined): string =>
188 (v || "").split(",")[0].trim();
189
190 // 1. Explicit, production-grade base URL wins.
191 const configured = (opts.configuredBaseUrl || "").trim().replace(/\/+$/, "");
192 if (
193 configured.startsWith("https://") &&
194 !configured.includes("localhost") &&
195 !configured.includes("127.0.0.1")
196 ) {
197 return `${configured}${PATH}`;
198 }
199
200 // 2. Derive the origin from the request (forwarded headers → Host → URL).
201 let host = firstValue(opts.forwardedHost) || firstValue(opts.host);
202 let urlProto = "";
203 if (opts.requestUrl) {
204 try {
205 const u = new URL(opts.requestUrl);
206 if (!host) host = u.host;
207 urlProto = u.protocol.replace(":", "");
208 } catch {
209 /* ignore an unparseable request URL */
210 }
211 }
212
213 if (!host) {
214 // Nothing to derive from — last-resort dev fallback.
215 const base = configured || "http://localhost:3000";
216 return `${base.replace(/\/+$/, "")}${PATH}`;
217 }
218
219 const isLocal =
220 host.startsWith("localhost") || host.startsWith("127.0.0.1");
221 // A proxy-supplied scheme always wins; otherwise any public host is https
222 // (Fly terminates TLS) and only loopback stays on its request scheme.
223 let proto = firstValue(opts.forwardedProto);
224 if (!proto) proto = isLocal ? urlProto || "http" : "https";
225
226 return `${proto}://${host}${PATH}`;
227}
228
Modifiedsrc/routes/google-oauth.tsx+30−15View fileUnifiedSplit
@@ -23,7 +23,6 @@ import {
2323 findOrCreateUserFromGoogle,
2424 getGoogleOauthConfig,
2525 googleOauthConfigFromEnv,
26 googleOauthRedirectUri,
2726 issueSsoSession,
2827 randomToken,
2928 upsertGoogleOauthConfig,
@@ -33,12 +32,29 @@ import {
3332 buildGoogleAuthorizeUrl,
3433 exchangeGoogleCode,
3534 fetchGoogleUserinfo,
35 resolveGoogleRedirectUri,
3636} from "../lib/google-oauth";
37import { config } from "../lib/config";
3738import { sessionCookieOptions } from "../lib/auth";
3839
3940const googleOauth = new Hono<AuthEnv>();
4041googleOauth.use("*", softAuth);
4142
43/**
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
4258function stateCookieOpts(): {
4359 httpOnly: boolean;
4460 secure: boolean;
@@ -84,22 +100,22 @@ googleOauth.get("/admin/google-oauth", requireAuth, async (c) => {
84100 const cfg = await getGoogleOauthConfig();
85101 const success = c.req.query("success");
86102 const error = c.req.query("error");
87 const redirectUri = googleOauthRedirectUri();
103 // 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);
88106
89107 // ── Live diagnostic: explain exactly why the /login button is on or off ──
90108 const envPresent = !!googleOauthConfigFromEnv();
91109 const liveOn = !!cfg?.enabled && !!cfg.clientId && !!cfg.clientSecret;
92110 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."
111 ? "no Client ID / Secret found anywhere. Paste them below (they save straight to the database — no redeploy)."
94112 : !cfg.enabled
95113 ? "a saved config exists but the “Enable” box is unticked. Tick it and save."
96114 : "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.
115 // 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.
99117 const redirectLooksLocal =
100 redirectUri.startsWith("http://") ||
101 redirectUri.includes("localhost") ||
102 redirectUri.includes("127.0.0.1");
118 redirectUri.includes("localhost") || redirectUri.includes("127.0.0.1");
103119
104120 return c.html(
105121 <Layout title="Google sign-in — Admin" user={user}>
@@ -141,11 +157,10 @@ googleOauth.get("/admin/google-oauth", requireAuth, async (c) => {
141157 </div>
142158 {redirectLooksLocal && (
143159 <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.
160 ⚠ 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.
149164 </div>
150165 )}
151166 </div>
@@ -312,7 +327,7 @@ googleOauth.get("/login/google", async (c) => {
312327 }
313328 const state = randomToken(16);
314329 const nonce = randomToken(16);
315 const redirectUri = googleOauthRedirectUri();
330 const redirectUri = googleRedirectUri(c);
316331 let target: string;
317332 try {
318333 target = buildGoogleAuthorizeUrl(cfg, state, redirectUri, nonce);
@@ -367,7 +382,7 @@ googleOauth.get("/login/google/callback", async (c) => {
367382 const { accessToken } = await exchangeGoogleCode(
368383 cfg,
369384 code,
370 googleOauthRedirectUri()
385 googleRedirectUri(c)
371386 );
372387 const userinfo = await fetchGoogleUserinfo(cfg, accessToken);
373388
374389