From edc42b28935ae392d6be9549c8125aaf0551b6a3 Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Tue, 21 Apr 2026 19:54:17 -0700 Subject: [PATCH 1/4] fix(auth): break infinite redirect loop on /cp/auth/login AuthGate redirected anonymous users to /cp/auth/login?return_to=, but the login page itself triggered AuthGate, which redirected again with double-encoded return_to. Each redirect added another encoding layer until the URL exceeded 431 (Request Header Fields Too Large). Two guards: 1. redirectToLogin() returns early if already on /cp/auth/* path 2. AuthGate skips redirect check entirely for /cp/auth/* paths [Molecule-Platform-Evolvement-Manager] Co-Authored-By: Claude Opus 4.6 (1M context) --- canvas/src/components/AuthGate.tsx | 5 +++++ canvas/src/lib/auth.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/canvas/src/components/AuthGate.tsx b/canvas/src/components/AuthGate.tsx index be371429..e06eae0a 100644 --- a/canvas/src/components/AuthGate.tsx +++ b/canvas/src/components/AuthGate.tsx @@ -29,6 +29,11 @@ export function AuthGate({ children }: { children: ReactNode }) { setState({ kind: "anonymous", skipRedirect: true }); return; } + // Never gate /cp/auth/* paths — these ARE the login pages. + if (typeof window !== "undefined" && window.location.pathname.startsWith("/cp/auth/")) { + setState({ kind: "anonymous", skipRedirect: true }); + return; + } let cancelled = false; fetchSession() .then((s) => { diff --git a/canvas/src/lib/auth.ts b/canvas/src/lib/auth.ts index d16006ac..8514260d 100644 --- a/canvas/src/lib/auth.ts +++ b/canvas/src/lib/auth.ts @@ -44,6 +44,10 @@ export async function fetchSession(): Promise { */ export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"): void { if (typeof window === "undefined") return; + // Guard against infinite redirect loop: if we're already on the login + // page, don't redirect again (each redirect double-encodes return_to + // until the URL exceeds header limits → 431). + if (window.location.pathname.startsWith("/cp/auth/")) return; const returnTo = window.location.href; const path = screenHint === "sign-up" ? "signup" : "login"; const dest = `${PLATFORM_URL}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`; From 6730c7713d0f2daa5f0a243aabda92bcc794de7f Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Tue, 21 Apr 2026 19:58:44 -0700 Subject: [PATCH 2/4] fix(auth): redirect to login on 401 from any API call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When session credentials expire mid-use, ALL API calls return 401. Previously this threw a generic error that crashed the UI with no recovery path. Now the API client intercepts 401 and redirects to login once (via redirectToLogin which already guards against loops). Combined with the AuthGate /cp/auth/* path guard, this gives the correct behavior: credentials lost → redirect to login → user logs in → return_to sends them back. [Molecule-Platform-Evolvement-Manager] Co-Authored-By: Claude Opus 4.6 (1M context) --- canvas/src/lib/api.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/canvas/src/lib/api.ts b/canvas/src/lib/api.ts index bcf3a0ba..0d1938b3 100644 --- a/canvas/src/lib/api.ts +++ b/canvas/src/lib/api.ts @@ -38,6 +38,13 @@ async function request( credentials: "include", signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), }); + if (res.status === 401) { + // Session expired or credentials lost — redirect to login once. + // Import dynamically to avoid circular dependency with auth.ts. + const { redirectToLogin } = await import("./auth"); + redirectToLogin("sign-in"); + throw new Error("Session expired — redirecting to login"); + } if (!res.ok) { const text = await res.text(); throw new Error(`API ${method} ${path}: ${res.status} ${text}`); From b360a4353fe0066938d497c5294f0fd989797a12 Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Tue, 21 Apr 2026 20:09:20 -0700 Subject: [PATCH 3/4] fix(auth): redirect to app.moleculesai.app for login, not tenant subdomain Tenant subdomains (hongmingwang.moleculesai.app) proxy to EC2 platform which has no /cp/auth/* routes. Auth UI lives on app.moleculesai.app. Added getAuthOrigin() that detects SaaS tenant hosts and redirects to the app subdomain for login/signup. Non-SaaS hosts (localhost, dev) fall back to PLATFORM_URL as before. [Molecule-Platform-Evolvement-Manager] Co-Authored-By: Claude Opus 4.6 (1M context) --- canvas/src/lib/auth.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/canvas/src/lib/auth.ts b/canvas/src/lib/auth.ts index 8514260d..fe7c71ab 100644 --- a/canvas/src/lib/auth.ts +++ b/canvas/src/lib/auth.ts @@ -7,6 +7,7 @@ * can surface them. */ import { PLATFORM_URL } from "./api"; +import { SaaSHostSuffix } from "./tenant"; export interface Session { user_id: string; @@ -17,6 +18,18 @@ export interface Session { // Base path prefix for auth endpoints on the control plane. const AUTH_BASE = "/cp/auth"; +// Auth UI lives on the "app" subdomain (app.moleculesai.app), NOT on +// tenant subdomains (hongmingwang.moleculesai.app). Tenant subdomains +// proxy to EC2 platform which has no auth routes. +function getAuthOrigin(): string { + if (typeof window === "undefined") return PLATFORM_URL; + const host = window.location.hostname; + if (host.endsWith(SaaSHostSuffix)) { + return `${window.location.protocol}//app${SaaSHostSuffix}`; + } + return PLATFORM_URL; +} + /** * fetchSession probes /cp/auth/me with the session cookie (credentials: * include mandatory cross-origin). Returns the Session on 200, null on @@ -50,6 +63,7 @@ export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"): if (window.location.pathname.startsWith("/cp/auth/")) return; const returnTo = window.location.href; const path = screenHint === "sign-up" ? "signup" : "login"; - const dest = `${PLATFORM_URL}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`; + const authOrigin = getAuthOrigin(); + const dest = `${authOrigin}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`; window.location.href = dest; } From 2c3eccf9d6f7937b8ff8908c651d68b5cd691f3c Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 23 Apr 2026 10:29:53 -0700 Subject: [PATCH 4/4] test(auth): provide window.location.pathname in redirectToLogin mocks The pathname.startsWith() loop-break added to redirectToLogin needs pathname on the mock Location object; tests were supplying only href. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/lib/__tests__/auth.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/canvas/src/lib/__tests__/auth.test.ts b/canvas/src/lib/__tests__/auth.test.ts index f1cd3b52..8188ddf2 100644 --- a/canvas/src/lib/__tests__/auth.test.ts +++ b/canvas/src/lib/__tests__/auth.test.ts @@ -47,7 +47,12 @@ describe("redirectToLogin", () => { const href = "https://acme.moleculesai.app/dashboard"; Object.defineProperty(window, "location", { writable: true, - value: { href }, + value: { + href, + pathname: "/dashboard", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, }); redirectToLogin("sign-in"); // href now holds the redirect target. encodeURIComponent(href) must @@ -61,7 +66,12 @@ describe("redirectToLogin", () => { it("uses signup path for sign-up screenHint", () => { Object.defineProperty(window, "location", { writable: true, - value: { href: "https://acme.moleculesai.app/" }, + value: { + href: "https://acme.moleculesai.app/", + pathname: "/", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, }); redirectToLogin("sign-up"); expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/signup");