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..5f9b76b3 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,157 @@ describe("redirectToLogin", () => {
expect((window.location as unknown as { href: string }).href).toBe(signupHref);
});
});
+
+describe("signOut", () => {
+ // Helper — most tests need the same window.location stub.
+ function stubLocation(): void {
+ Object.defineProperty(window, "location", {
+ writable: true,
+ value: {
+ href: "https://acme.moleculesai.app/orgs",
+ pathname: "/orgs",
+ hostname: "acme.moleculesai.app",
+ protocol: "https:",
+ },
+ });
+ }
+
+ 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();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/cp/auth/signout"),
+ expect.objectContaining({ method: "POST", credentials: "include" }),
+ );
+ });
+
+ 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();
+
+ 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.
+ stubLocation();
+ 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.
+ // 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();
+
+ 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..d091c2cb 100644
--- a/canvas/src/lib/auth.ts
+++ b/canvas/src/lib/auth.ts
@@ -67,3 +67,80 @@ 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 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).
+ *
+ * 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
+ * environment (jsdom under vitest) we still exit cleanly so unit tests
+ * 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 {
+ 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;
+ 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`;
+}