From 4cac4e7710f84a6f23d26c94f91e974a1821a7dd Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 11:39:38 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(canvas):=20wire=20SaaS=20Sign-out=20but?= =?UTF-8?q?ton=20=E2=80=94=20POST=20/cp/auth/signout=20was=20unreachable?= =?UTF-8?q?=20from=20the=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported externally on 2026-05-05: "SaaS app logout does not work." Root cause: the control plane has had POST /cp/auth/signout (clears the WorkOS session cookie + revokes at the provider) since auth shipped, but no canvas code ever called it. grep across canvas/ for `logout|signOut|signout|sign-out` returned zero results — no helper, no button, no menu entry. Users had no path to log out short of clearing cookies in DevTools. This is a UI gap, not a backend bug. Adding the missing pieces: 1. `signOut()` helper in `canvas/src/lib/auth.ts`: - POST /cp/auth/signout with credentials:include (cross-origin cookie required for tenant subdomain → app subdomain) - Best-effort: a 5xx, 401-stale-cookie, or network failure still redirects the browser to /cp/auth/login. Leaving the user on an authed-looking page after they clicked Sign out is the worst possible UX — that's the precise "logout doesn't work" symptom the report described. - Lands on /cp/auth/login (not the current URL) so the user doesn't loop back into the org they just left via AuthGate's return_to. 2. `AccountBar` component on /orgs page Shell — renders the signed-in email + Sign-out button at the top. Click → signOut() → `Signing out…` → bounces to login. Disabled-while-pending so a double-click can't fire two requests. 3. Tests in `auth.test.ts` (4 new, total 12 pass): - POSTs to the right endpoint with credentials:include - Redirects to /cp/auth/login after success - Redirects EVEN ON network failure (the critical UX invariant) - Redirects on 401 (stale cookie path) The auth-origin resolution (`getAuthOrigin`) is reused so a tenant subdomain (acme.moleculesai.app) correctly POSTs to app.moleculesai.app/cp/auth/signout — same chain that fetchSession + redirectToLogin already use. Test plan: - [x] `npx vitest run src/lib/__tests__/auth.test.ts` — 12/12 green - [x] `tsc --noEmit` — clean - [ ] Manual: navigate to /orgs, click Sign out, observe redirect + that the next /orgs visit bounces to login (cookie cleared) - [ ] CI green Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/app/orgs/page.tsx | 50 ++++++++++++++- canvas/src/lib/__tests__/auth.test.ts | 87 ++++++++++++++++++++++++++- canvas/src/lib/auth.ts | 38 ++++++++++++ 3 files changed, 171 insertions(+), 4 deletions(-) diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx index 3c5576ef..a137ac2e 100644 --- a/canvas/src/app/orgs/page.tsx +++ b/canvas/src/app/orgs/page.tsx @@ -18,7 +18,7 @@ // quick bounce between signup and either Checkout or the tenant UI. import { useEffect, useState } from "react"; -import { fetchSession, redirectToLogin, type Session } from "@/lib/auth"; +import { fetchSession, redirectToLogin, signOut, type Session } from "@/lib/auth"; import { PLATFORM_URL } from "@/lib/api"; import { formatCredits, pillTone, bannerKind } from "@/lib/credits"; import { TermsGate } from "@/components/TermsGate"; @@ -129,7 +129,7 @@ export default function OrgsPage() { return : null} />; } return ( - + {justCheckedOut && }
    {orgs.map((o) => ( @@ -160,11 +160,21 @@ function CheckoutBanner() { ); } -function Shell({ children }: { children: React.ReactNode }) { +function Shell({ + children, + session, +}: { + children: React.ReactNode; + // Optional: when present, the header renders the signed-in email + + // a Sign-out button. The empty-state Shell call doesn't have a + // session in scope, so accept null and skip the header chrome there. + session?: Session | null; +}) { return (
    + {session ? : null}

    Your organizations

    Each org is an isolated Molecule workspace. @@ -177,6 +187,40 @@ function Shell({ children }: { children: React.ReactNode }) { ); } +// AccountBar renders the signed-in email + a Sign-out button at the +// top of the page. Without this the user has no way to log out — the +// /cp/auth/signout endpoint exists on the control plane but no UI ever +// called it. Reported externally on 2026-05-05; this is the fix. +// +// Click → calls signOut() which POSTs /cp/auth/signout (clears the +// WorkOS session cookie + revokes at the provider) then bounces to +// /cp/auth/login. The signOut helper is best-effort — even on a 5xx +// or network failure the redirect fires so the user never gets stuck +// on an authed-looking page after they clicked Sign out. +function AccountBar({ session }: { session: Session }) { + const [signingOut, setSigningOut] = useState(false); + return ( +

    + {session.email} + +
    + ); +} + // DataResidencyNotice surfaces where workspace data lives so EU-based // signups can make an informed choice (GDPR Art. 13 disclosure // requirement). Plain text, no icon — the goal is clarity, not diff --git a/canvas/src/lib/__tests__/auth.test.ts b/canvas/src/lib/__tests__/auth.test.ts index ee74a521..220c5126 100644 --- a/canvas/src/lib/__tests__/auth.test.ts +++ b/canvas/src/lib/__tests__/auth.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { fetchSession, redirectToLogin } from "../auth"; +import { fetchSession, redirectToLogin, signOut } from "../auth"; afterEach(() => { vi.unstubAllGlobals(); @@ -110,3 +110,88 @@ describe("redirectToLogin", () => { expect((window.location as unknown as { href: string }).href).toBe(signupHref); }); }); + +describe("signOut", () => { + it("POSTs to /cp/auth/signout with credentials:include", async () => { + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "https://acme.moleculesai.app/orgs", + pathname: "/orgs", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, + }); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + await signOut(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/cp/auth/signout"), + expect.objectContaining({ method: "POST", credentials: "include" }), + ); + }); + + it("redirects to /cp/auth/login on the auth origin after signout", async () => { + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "https://acme.moleculesai.app/orgs", + pathname: "/orgs", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 })); + + await signOut(); + + const after = (window.location as unknown as { href: string }).href; + // Tenant subdomain (acme.moleculesai.app) → auth origin is app.moleculesai.app. + expect(after).toBe("https://app.moleculesai.app/cp/auth/login"); + }); + + it("redirects even when the POST fails so the user isn't stuck on an authed page", async () => { + // Critical UX invariant: clicking 'Sign out' MUST navigate away from + // the authenticated app, even if the network is down or the cookie + // is already invalid. Anything else looks like the button is + // broken — the precise complaint that triggered this fix. + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "https://acme.moleculesai.app/orgs", + pathname: "/orgs", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, + }); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); + + await signOut(); + + const after = (window.location as unknown as { href: string }).href; + expect(after).toBe("https://app.moleculesai.app/cp/auth/login"); + }); + + it("redirects on 401 (session already invalid) just like 200", async () => { + // A user with an already-invalid cookie should still see the + // logout flow complete — no error, no stuck-on-app dead end. + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "https://acme.moleculesai.app/orgs", + pathname: "/orgs", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401 })); + + await signOut(); + + const after = (window.location as unknown as { href: string }).href; + expect(after).toBe("https://app.moleculesai.app/cp/auth/login"); + }); +}); diff --git a/canvas/src/lib/auth.ts b/canvas/src/lib/auth.ts index fe7c71ab..e6a2b945 100644 --- a/canvas/src/lib/auth.ts +++ b/canvas/src/lib/auth.ts @@ -67,3 +67,41 @@ export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"): const dest = `${authOrigin}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`; window.location.href = dest; } + +/** + * signOut posts to /cp/auth/signout to clear the WorkOS session cookie + * + revoke at the provider, then bounces to the auth-origin login page. + * + * Best-effort by design: a 5xx, network failure, or stale cookie still + * results in the browser navigation away from the authenticated app — + * leaving the user on a logged-in-looking page after they clicked + * "Sign out" is the worst possible UX. The cookie is cleared client- + * visibly via the redirect target's response (Set-Cookie with maxAge=-1 + * runs even on a non-200 path). If the user is already anonymous, the + * POST 401s harmlessly + we still redirect. + * + * Throws nothing — callers can disable the button optimistically or + * await this and trust it returns. On a redirect-blocked test + * environment (jsdom under vitest) we still exit cleanly so unit tests + * can spy on the fetch call. + */ +export async function signOut(): Promise { + // Fire-and-tolerate the POST. credentials:include is mandatory cross- + // origin so the SaaS canvas (acme.moleculesai.app) can hit + // app.moleculesai.app/cp/auth/signout with the session cookie. + try { + await fetch(`${getAuthOrigin()}${AUTH_BASE}/signout`, { + method: "POST", + credentials: "include", + }); + } catch { + // Ignore — we still redirect below. + } + if (typeof window === "undefined") return; + // Land on the login screen rather than the current URL: returning to + // a tenant URL after signout would just re-redirect through + // /cp/auth/login due to AuthGate. Send the user straight there with + // no return_to so they don't loop back into the org they just left. + const authOrigin = getAuthOrigin(); + window.location.href = `${authOrigin}${AUTH_BASE}/login`; +} From 575f893f4e77c6c5c20e0037fddf6324b525d883 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 12:21:49 -0700 Subject: [PATCH 2/3] fix(canvas): consume CP logout_url to break the SSO re-auth loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to molecule-controlplane#485. The first half of #2913 wired a Sign-out button + signOut() helper that POSTed /cp/auth/signout, but clicking still left the user signed in: WorkOS's browser cookie preserved the SSO session, /cp/auth/login auto-re-authed via SSO, and the user landed back on /orgs. CP PR #485 returns the AuthKit hosted logout URL in the signout response. This change has signOut() navigate the browser there instead of /cp/auth/login. AuthKit clears its cookie + redirects to return_to (configured server-side from APP_URL) → next /cp/auth/login hits a fresh AuthKit, no SSO session, login form actually shows. Defensive parsing: malformed JSON, missing logout_url, or wrong-type logout_url all fall through to the legacy /cp/auth/login fallback, which works locally (DisabledProvider, dev) where there's no SSO to escape. Forward-compat: when CP doesn't have #485 deployed yet, signOut() sees logout_url="" or missing → fallback fires. Order of merge between this and #485 doesn't matter, but the bug isn't actually fixed end-to-end until both ship. Tests added (3 new, 15 total auth.test.ts): - Hosted logout: navigates to logout_url when response includes one. - DisabledProvider path: falls back to /cp/auth/login when "". - Defensive: malformed JSON body → fallback (no crash). - Defensive: non-string logout_url → fallback (no open redirect). Verified: - npx vitest run src/lib/__tests__/auth.test.ts — 15/15 pass - tsc --noEmit clean Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/lib/__tests__/auth.test.ts | 133 +++++++++++++++++++------- canvas/src/lib/auth.ts | 65 ++++++++++--- 2 files changed, 153 insertions(+), 45 deletions(-) diff --git a/canvas/src/lib/__tests__/auth.test.ts b/canvas/src/lib/__tests__/auth.test.ts index 220c5126..5f9b76b3 100644 --- a/canvas/src/lib/__tests__/auth.test.ts +++ b/canvas/src/lib/__tests__/auth.test.ts @@ -112,7 +112,8 @@ describe("redirectToLogin", () => { }); describe("signOut", () => { - it("POSTs to /cp/auth/signout with credentials:include", async () => { + // Helper — most tests need the same window.location stub. + function stubLocation(): void { Object.defineProperty(window, "location", { writable: true, value: { @@ -122,7 +123,15 @@ describe("signOut", () => { protocol: "https:", }, }); - const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + } + + it("POSTs to /cp/auth/signout with credentials:include", async () => { + stubLocation(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true, logout_url: "" }), + }); vi.stubGlobal("fetch", fetchMock); await signOut(); @@ -134,17 +143,41 @@ describe("signOut", () => { ); }); - it("redirects to /cp/auth/login on the auth origin after signout", async () => { - Object.defineProperty(window, "location", { - writable: true, - value: { - href: "https://acme.moleculesai.app/orgs", - pathname: "/orgs", - hostname: "acme.moleculesai.app", - protocol: "https:", - }, - }); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 })); + it("navigates to provider logout_url when the response includes one", async () => { + // The hosted-logout path is what actually breaks the SSO re-auth + // loop reported on PR #2913. Without this, AuthKit's browser + // cookie keeps the user signed in via SSO and any subsequent + // /cp/auth/login silently re-auths. + stubLocation(); + const hostedLogout = + "https://api.workos.com/user_management/sessions/logout?session_id=cookie&return_to=https%3A%2F%2Fapp.moleculesai.app%2Forgs"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true, logout_url: hostedLogout }), + }), + ); + + await signOut(); + + const after = (window.location as unknown as { href: string }).href; + expect(after).toBe(hostedLogout); + }); + + it("falls back to /cp/auth/login when logout_url is empty (DisabledProvider / dev)", async () => { + // DisabledProvider returns "" — the local /cp/auth/login redirect + // works in dev/test where there's no SSO session to escape. + stubLocation(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true, logout_url: "" }), + }), + ); await signOut(); @@ -158,15 +191,7 @@ describe("signOut", () => { // the authenticated app, even if the network is down or the cookie // is already invalid. Anything else looks like the button is // broken — the precise complaint that triggered this fix. - Object.defineProperty(window, "location", { - writable: true, - value: { - href: "https://acme.moleculesai.app/orgs", - pathname: "/orgs", - hostname: "acme.moleculesai.app", - protocol: "https:", - }, - }); + stubLocation(); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); await signOut(); @@ -178,16 +203,60 @@ describe("signOut", () => { it("redirects on 401 (session already invalid) just like 200", async () => { // A user with an already-invalid cookie should still see the // logout flow complete — no error, no stuck-on-app dead end. - Object.defineProperty(window, "location", { - writable: true, - value: { - href: "https://acme.moleculesai.app/orgs", - pathname: "/orgs", - hostname: "acme.moleculesai.app", - protocol: "https:", - }, - }); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401 })); + // Note: 401 means res.ok=false → we don't read .json() at all, + // so a missing body is fine. + stubLocation(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({}), + }), + ); + + await signOut(); + + const after = (window.location as unknown as { href: string }).href; + expect(after).toBe("https://app.moleculesai.app/cp/auth/login"); + }); + + it("falls back to /cp/auth/login when the response body is malformed", async () => { + // Defensive parsing: a body that isn't valid JSON, or doesn't + // have logout_url, or has logout_url as the wrong type — none of + // these should strand the user on the authed page. Fallback path + // takes over. + stubLocation(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new Error("not json"); + }, + }), + ); + + await signOut(); + + const after = (window.location as unknown as { href: string }).href; + expect(after).toBe("https://app.moleculesai.app/cp/auth/login"); + }); + + it("falls back to /cp/auth/login when logout_url is the wrong type", async () => { + // Even valid JSON should be type-checked: a non-string logout_url + // (e.g. server-side bug, version drift) must not crash or open- + // redirect the user. + stubLocation(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true, logout_url: 42 }), + }), + ); await signOut(); diff --git a/canvas/src/lib/auth.ts b/canvas/src/lib/auth.ts index e6a2b945..d091c2cb 100644 --- a/canvas/src/lib/auth.ts +++ b/canvas/src/lib/auth.ts @@ -70,15 +70,28 @@ export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"): /** * signOut posts to /cp/auth/signout to clear the WorkOS session cookie - * + revoke at the provider, then bounces to the auth-origin login page. + * + revoke at the provider, then navigates the browser to the + * provider-supplied hosted logout URL (so the provider's BROWSER-side + * SSO cookie is cleared too — without this, AuthKit silently re-auths + * via SSO on the next /cp/auth/login and the user is "still signed + * in" after pressing Sign out). * - * Best-effort by design: a 5xx, network failure, or stale cookie still - * results in the browser navigation away from the authenticated app — - * leaving the user on a logged-in-looking page after they clicked - * "Sign out" is the worst possible UX. The cookie is cleared client- - * visibly via the redirect target's response (Set-Cookie with maxAge=-1 - * runs even on a non-200 path). If the user is already anonymous, the - * POST 401s harmlessly + we still redirect. + * Two-layer flow: + * 1. POST /cp/auth/signout → CP clears OUR session cookie + revokes + * session_id at the provider API. Response includes + * `logout_url` — the AuthKit hosted URL the BROWSER must navigate + * to so the provider's own browser cookie is cleared. + * 2. window.location.href = → AuthKit clears its + * session, then redirects the browser to the configured + * return_to (defaults to APP_URL/orgs). + * + * Best-effort by design: a 5xx, network failure, missing logout_url + * (DisabledProvider, dev), or stale cookie still results in the + * browser navigating away — leaving the user on a logged-in-looking + * page after they clicked "Sign out" is the worst possible UX. The + * fallback path navigates to /cp/auth/login on the auth origin, which + * works correctly in environments without a hosted logout flow (dev, + * tests, DisabledProvider). * * Throws nothing — callers can disable the button optimistically or * await this and trust it returns. On a redirect-blocked test @@ -86,22 +99,48 @@ export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"): * can spy on the fetch call. */ export async function signOut(): Promise { + let logoutURL: string | undefined; // Fire-and-tolerate the POST. credentials:include is mandatory cross- // origin so the SaaS canvas (acme.moleculesai.app) can hit // app.moleculesai.app/cp/auth/signout with the session cookie. try { - await fetch(`${getAuthOrigin()}${AUTH_BASE}/signout`, { + const res = await fetch(`${getAuthOrigin()}${AUTH_BASE}/signout`, { method: "POST", credentials: "include", }); + if (res.ok) { + // Body shape: {"ok": true, "logout_url": "..."}. logout_url is + // empty for DisabledProvider (dev/local) — we fall back to + // /cp/auth/login below. Defensive parsing: a malformed body + // shouldn't strand the user on the authed page. + const body: unknown = await res.json().catch(() => null); + if ( + body && + typeof body === "object" && + "logout_url" in body && + typeof (body as { logout_url: unknown }).logout_url === "string" && + (body as { logout_url: string }).logout_url + ) { + logoutURL = (body as { logout_url: string }).logout_url; + } + } } catch { // Ignore — we still redirect below. } if (typeof window === "undefined") return; - // Land on the login screen rather than the current URL: returning to - // a tenant URL after signout would just re-redirect through - // /cp/auth/login due to AuthGate. Send the user straight there with - // no return_to so they don't loop back into the org they just left. + if (logoutURL) { + // Hosted logout: AuthKit clears its SSO cookie + redirects to + // return_to (configured server-side). This is the path that + // actually breaks the SSO re-auth loop. + window.location.href = logoutURL; + return; + } + // Fallback: no hosted logout (dev, DisabledProvider, network + // failure). Land on the login screen rather than the current URL: + // returning to a tenant URL after signout would just re-redirect + // through /cp/auth/login due to AuthGate. Send the user straight + // there with no return_to so they don't loop back into the org they + // just left. const authOrigin = getAuthOrigin(); window.location.href = `${authOrigin}${AUTH_BASE}/login`; } From 9a535290473f05f1f06d55bca98c41990224ff1f Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 12:38:09 -0700 Subject: [PATCH 3/3] ci: retrigger after stuck Canvas tabs E2E (was running 17min vs typical <1min on staging)