From 00a150210a3af245ca4e78cd698201e6218723bb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:01:46 -0700 Subject: [PATCH] cloud: end the WorkOS session on sign out Logout only cleared the local sealed-session cookie; the hosted AuthKit session survived, so the next Sign in silently re-authenticated without credentials (#1445). Follow WorkOS's documented sign-out: extract the session id from the sealed cookie locally (plain JWT decode, no verification, so an expired token still signs out), 302 through the WorkOS logout endpoint to end the session upstream, and land back on the public homepage via return_to. If the cookie will not unseal, fall back to the old redirect-to-/ so local sign-out always completes. The shell submits sign-out as a top-level form POST instead of fetch so the browser navigation carries WorkOS's cookies through the hop. Needs @executor-js/emulate 0.13.6 (the emulator's session-end route). --- apps/cloud/src/auth/handlers.ts | 28 ++++++++++++++++++----- apps/cloud/src/auth/workos.ts | 40 +++++++++++++++++++++++++++++++++ apps/cloud/src/web/shell.tsx | 15 +++++++++---- bun.lock | 4 ++-- e2e/cloud/auth-hint.test.ts | 24 +++++++++++++++++--- e2e/cloud/auth-session.test.ts | 24 +++++++++++++++++--- e2e/package.json | 2 +- 7 files changed, 119 insertions(+), 18 deletions(-) diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index db249f3e5..cd198c58f 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -305,15 +305,33 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( }), ) .handleRaw("logout", () => - Effect.succeed( + Effect.gen(function* () { + const workos = yield* WorkOSClient; + const session = yield* SessionContext; + + // WorkOS's documented sign-out: send the browser through the WorkOS + // logout endpoint, which ends the AuthKit session upstream and then + // redirects to the registered sign-out URL. Without this hop, the + // hosted session survives and the next "Sign in" silently + // re-authenticates (issue #1445). Fail-open when the cookie won't + // unseal: local sign-out must still complete, so fall back to "/". + const origin = env.VITE_PUBLIC_SITE_URL ?? ""; + const logoutUrl = yield* workos.logoutUrl( + session.sealedSession, + origin ? `${origin}/` : undefined, + ); + // The auth-hint travels with the session: leaving it behind would // make the next page load optimistically paint the app shell for a // signed-out browser. - deleteResponseCookie( - deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"), + return deleteResponseCookie( + deleteResponseCookie( + HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }), + "wos-session", + ), AUTH_HINT_COOKIE, - ), - ), + ); + }), ) .handle("organizations", () => Effect.gen(function* () { diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index c9b0809cb..18fb74bae 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -455,6 +455,46 @@ const make = Effect.gen(function* () { return refreshed.sealedSession ?? null; }), + /** + * The WorkOS logout URL for a sealed session, or null when the cookie + * doesn't unseal or carries no session id (fail-open: the caller clears + * local cookies and redirects home instead). The access token is decoded + * WITHOUT signature verification, deliberately — WorkOS's documented + * logout flow reads `sid` from a possibly-expired token, and logout must + * still work for a stale session. (The SDK's `session.getLogoutUrl()` is + * deliberately NOT used: it re-authenticates first and throws on an + * expired token.) Pure URL construction, no network call; hitting the + * URL is the browser's navigation, which is what ends the AuthKit + * session upstream. + */ + logoutUrl: (sessionData: string, returnTo?: string) => + Effect.gen(function* () { + const unsealed = yield* Effect.tryPromise({ + try: () => unsealWorkOSSession(sessionData, cookiePassword), + catch: (cause) => new LocalSessionCookieError({ cause }), + }).pipe( + Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)), + ); + if (!unsealed) return null; + + const session = Option.getOrNull(decodeSealedSessionPayload(unsealed)); + if (!session) return null; + + const claims = yield* Effect.try({ + try: () => decodeJwt(session.accessToken), + catch: (cause) => new LocalSessionCookieError({ cause }), + }).pipe( + Effect.map((jwt) => Option.getOrNull(decodeJwtClaims(jwt))), + Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null)), + ); + if (!claims) return null; + + return workos.userManagement.getLogoutUrl({ + sessionId: claims.sid, + ...(returnTo ? { returnTo } : {}), + }); + }), + /** * Authenticate a sealed session string. Returns the user info plus * any refreshed session that needs to be set on the response. diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index f796c5865..d13613166 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -9,7 +9,7 @@ import { SupportSlot } from "./components/support-slot"; // --------------------------------------------------------------------------- // Cloud shell — the SHARED multiplayer shell, identical to self-host, with // cloud-only bits injected through its slots: -// - sign-out POST cloud's WorkOS logout, then redirect home +// - sign-out navigate through cloud's WorkOS logout, land home // - nav items defaults + Organization + Billing (cloud-only sections) // - org menu slot multi-org switcher + create-org dialog (cloud-only) // - support slot the "Get support" dialog button (cloud-only) @@ -25,10 +25,17 @@ const navItems = [ { to: "/billing", label: "Billing" }, ]; -const signOut = async () => { - await fetch(AUTH_PATHS.logout, { method: "POST" }); +// A top-level form POST, not fetch: the logout response 302s through the +// WorkOS logout endpoint (ending the hosted AuthKit session) before landing +// back home. A fetch would follow that chain invisibly inside the XHR and +// leave the page where it was; the browser navigation is the logout. +const signOut = () => { trackEvent("signed_out"); - window.location.href = "/"; + const form = document.createElement("form"); + form.method = "POST"; + form.action = AUTH_PATHS.logout; + document.body.appendChild(form); + form.submit(); }; export function Shell(props: { readonly content?: React.ReactNode }) { diff --git a/bun.lock b/bun.lock index 54b2f55c2..bc5ca40ea 100644 --- a/bun.lock +++ b/bun.lock @@ -343,7 +343,7 @@ "version": "0.0.32", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.3", + "@executor-js/emulate": "^0.13.6", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -1741,7 +1741,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.13.3", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-2d2Ww1FVLY4q66ZHxzanKVVOA9oJSjk+pzUJqrw6AWa/34P+HxODXor1hqWmxEER2WNs3fcrJT2c0JGDyM9kaQ=="], + "@executor-js/emulate": ["@executor-js/emulate@0.13.6", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-FwR2RvO5DnwYgGO2w3JKPCpUx8o+AzSPJeei/oYMIrb4PYg2bQceucxiw52698t9SDEDwAOHkdC/Vv1Tv1+MJQ=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], diff --git a/e2e/cloud/auth-hint.test.ts b/e2e/cloud/auth-hint.test.ts index a16317448..a04502c85 100644 --- a/e2e/cloud/auth-hint.test.ts +++ b/e2e/cloud/auth-hint.test.ts @@ -86,9 +86,27 @@ scenario( }); await step("Sign out through the product flow", async () => { - // The shell's sign-out POSTs the logout endpoint from the page, so - // the response's Set-Cookie clears apply to this browser context. - await page.evaluate(() => fetch("/api/auth/logout", { method: "POST" })); + // The shell's sign-out is a top-level navigation: POST the logout + // endpoint, ride its redirect through WorkOS's session-end page, and + // land back on the public homepage. (A fetch can't make this trip — + // the WorkOS hop is cross-origin.) + // Bounded retry: a click can land while the shell is still hydrating + // and the radix menu never opens (same idiom as org-switcher). + for (let attempt = 1; ; attempt++) { + try { + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: /Test User/ }).click(); + await page.getByRole("menuitem", { name: "Sign out" }).click({ timeout: 5_000 }); + break; + } catch (error) { + if (attempt >= 3) throw error; + } + } + // The return URL is "/", which the SSR gate bounces to /login for a + // signed-out browser — either is proof the round trip landed home. + await page.waitForURL((url) => url.pathname === "/" || url.pathname === "/login", { + timeout: 15_000, + }); }); const names = (await page.context().cookies()).map((cookie) => cookie.name); diff --git a/e2e/cloud/auth-session.test.ts b/e2e/cloud/auth-session.test.ts index 7d5e96be9..272517b5f 100644 --- a/e2e/cloud/auth-session.test.ts +++ b/e2e/cloud/auth-session.test.ts @@ -132,7 +132,7 @@ scenario( ); scenario( - "Auth · logout sends the user home and tells the browser to drop the session", + "Auth · logout ends the AuthKit session upstream and tells the browser to drop the cookie", {}, Effect.gen(function* () { // Gate: the REST API plane is mounted on this target. @@ -148,10 +148,28 @@ scenario( headers: identity.headers ?? {}, }), ); - expect(response.status, "logout redirects back to the landing page").toBe(302); - expect(response.headers.get("location"), "the destination is home").toBe("/"); + expect(response.status, "logout hands the browser to WorkOS").toBe(302); const cleared = setCookieFor(response, "wos-session"); expect(cleared, "the session cookie is expired immediately").toContain("Max-Age=0"); expect(cleared, "the cookie value is wiped").toContain("wos-session=;"); + + // The destination is WorkOS's session-end endpoint carrying this + // session's id — without this hop the hosted AuthKit session survives + // and the next sign-in silently re-authenticates. + const logoutUrl = new URL(response.headers.get("location") ?? ""); + expect(logoutUrl.pathname, "the destination ends the WorkOS session").toBe( + "/user_management/sessions/logout", + ); + expect( + logoutUrl.searchParams.get("session_id"), + "the WorkOS session being ended is this identity's", + ).toMatch(/^session_/); + + const upstream = yield* Effect.promise(() => fetch(logoutUrl, { redirect: "manual" })); + expect(upstream.status, "WorkOS ends the session and sends the user onward").toBe(302); + expect( + upstream.headers.get("location"), + "the sign-out return URL lands back on this deployment", + ).toBe(new URL("/", target.baseUrl).toString()); }), ); diff --git a/e2e/package.json b/e2e/package.json index bef93102c..ca937fc19 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.3", + "@executor-js/emulate": "^0.13.6", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*",