From a56798c6227c754da7c1a4f01ad9480b1252ab91 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Wed, 1 Jul 2026 10:47:21 +0200 Subject: [PATCH] fix: redirect direct visits to /profile instead of OAuth authorize flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When visiting auth.codebar.io directly (not via the planner's OAuth initiation), the default callback URL built by getCallbackURL() was an OAuth authorize URL pointing at allowed_redirects[0]. In production, PLANNER_REDIRECT_URIS had localhost first, so the user was sent to localhost:3000 with a PKCE error (the planner client is public and requires PKCE, but no PKCE params were generated). A direct visit can't initiate a planner session anyway — the planner holds the PKCE verifier and state in its own session. So sending these users through the OAuth flow is a dead end. Now direct visits get base_url/profile (from appConfig.base_url), which resolves to the right scheme behind Heroku/Cloudflare. The OAuth params branch is unchanged — the planner flow still works when coming via the authorize endpoint. --- src/app/utils/callback-url.js | 25 ++++++++++-------- test/unit/callback-url.test.js | 47 ++++++++-------------------------- 2 files changed, 25 insertions(+), 47 deletions(-) diff --git a/src/app/utils/callback-url.js b/src/app/utils/callback-url.js index 749d948..20276d3 100644 --- a/src/app/utils/callback-url.js +++ b/src/app/utils/callback-url.js @@ -1,18 +1,26 @@ import appConfig from "../../config.js"; /** - * Build the OAuth authorize callback URL from the current request. + * Build the post-auth callback URL from the current request. * * If the request carries OAuth 2.1 authorize params (response_type, client_id, - * etc.), they are forwarded to the auth app. Otherwise a default authorize - * URL for the planner client is returned. + * etc.), the user came from the planner — forward them to the authorize + * endpoint so the OAuth flow completes. + * + * Otherwise the user came directly to the auth app — redirect to the auth + * app's own /profile page. The planner session can only be started from + * the planner (it holds the PKCE verifier + state), so there's no point + * trying to initiate an OAuth flow here. * * @param {import("hono").Context} c - Hono context - * @returns {string} Authorize URL + * @returns {string} Redirect URL */ export function getCallbackURL(c) { const url = new URL(c.req.url); const search = new URLSearchParams(url.search); + // Use the configured base URL rather than request origin — behind + // Heroku/Cloudflare TLS terminates at the edge, so origin would be + // http:// not https://, which Better Auth rejects in trustedOrigins. const base = appConfig.base_url; if (search.has("response_type")) { @@ -31,11 +39,6 @@ export function getCallbackURL(c) { return `${base}/api/auth/oauth2/authorize?${params.toString()}`; } - const params = new URLSearchParams({ - client_id: "planner", - redirect_uri: appConfig.allowed_redirects[0], - response_type: "code", - scope: "openid profile email", - }); - return `${base}/api/auth/oauth2/authorize?${params.toString()}`; + // Direct visit — no OAuth flow to resume, send to profile + return `${base}/profile`; } diff --git a/test/unit/callback-url.test.js b/test/unit/callback-url.test.js index b1c07a1..1e273e7 100644 --- a/test/unit/callback-url.test.js +++ b/test/unit/callback-url.test.js @@ -1,9 +1,11 @@ import { test } from "tap"; import { getCallbackURL } from "../../src/app/utils/callback-url.js"; -import appConfig, { PLANNER_DEFAULT_PORT } from "../../src/config.js"; +import appConfig, { + AUTH_DEFAULT_PORT, + PLANNER_DEFAULT_PORT, +} from "../../src/config.js"; -const BASE = appConfig.base_url; -const REDIRECT = appConfig.allowed_redirects[0]; +const BASE = `http://localhost:${AUTH_DEFAULT_PORT}`; const REDIRECT_URI = `http://localhost:${PLANNER_DEFAULT_PORT}/auth/codebar/callback`; function mockCtx(url) { @@ -36,51 +38,24 @@ test("preserves only present OAuth params", async (t) => { ); }); -test("returns default authorize URL when no OAuth params", async (t) => { +test("returns /profile when no OAuth params", async (t) => { const url = `${BASE}/login`; const result = getCallbackURL(mockCtx(url)); - const expected = - `${BASE}/api/auth/oauth2/authorize?` + - `client_id=planner&redirect_uri=${encodeURIComponent(REDIRECT)}` + - `&response_type=code&scope=openid+profile+email`; - t.equal(result, expected); + t.equal(result, `${BASE}/profile`); }); -test("returns default authorize URL with empty search", async (t) => { +test("returns /profile with empty search", async (t) => { const url = `${BASE}/login?`; const result = getCallbackURL(mockCtx(url)); - const expected = - `${BASE}/api/auth/oauth2/authorize?` + - `client_id=planner&redirect_uri=${encodeURIComponent(REDIRECT)}` + - `&response_type=code&scope=openid+profile+email`; - t.equal(result, expected); + t.equal(result, `${BASE}/profile`); }); -test("uses base_url from config", async (t) => { - const original = appConfig.base_url; - appConfig.base_url = "https://auth.codebar.io"; - t.after(() => { - appConfig.base_url = original; - }); - - const result = getCallbackURL(mockCtx("https://auth.codebar.io/login")); - - t.ok(result.startsWith("https://auth.codebar.io/")); - t.ok(result.includes("/api/auth/oauth2/authorize")); -}); - -test("uses allowed_redirects[0] for default redirect_uri", async (t) => { - const original = appConfig.allowed_redirects; - appConfig.allowed_redirects = ["https://planner.test/callback"]; - t.after(() => { - appConfig.allowed_redirects = original; - }); - +test("uses base_url for /profile redirect", async (t) => { const result = getCallbackURL(mockCtx(`${BASE}/login`)); - t.ok(result.includes(encodeURIComponent("https://planner.test/callback"))); + t.equal(result, `${appConfig.base_url}/profile`); });