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/__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"); 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}`); diff --git a/canvas/src/lib/auth.ts b/canvas/src/lib/auth.ts index d16006ac..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 @@ -44,8 +57,13 @@ 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)}`; + const authOrigin = getAuthOrigin(); + const dest = `${authOrigin}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`; window.location.href = dest; }