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:*",