History

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).

Rhys Sullivan committed 3 months ago

Commit 00a15021 parent 7d6fcea7 files · +119 −18Browse files

Unified · Split

7 files
  1. apps/cloud/src/auth/handlers.ts +23 −5
  2. apps/cloud/src/auth/workos.ts +40 −0
  3. apps/cloud/src/web/shell.tsx +11 −4
  4. bun.lock +2 −2
  5. e2e/cloud/auth-hint.test.ts +21 −3
  6. e2e/cloud/auth-session.test.ts +21 −3
  7. e2e/package.json +1 −1
apps/cloud/src/auth/handlers.ts+23−5
@@ -305,15 +305,33 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 304 unmodified lines
305 }),305 }),
306 )306 )
307 .handleRaw("logout", () =>307 .handleRaw("logout", () =>
308 Effect.succeed(308 Effect.gen(function* () {
309 const workos = yield* WorkOSClient;
310 const session = yield* SessionContext;
311
312 // WorkOS's documented sign-out: send the browser through the WorkOS
313 // logout endpoint, which ends the AuthKit session upstream and then
314 // redirects to the registered sign-out URL. Without this hop, the
315 // hosted session survives and the next "Sign in" silently
316 // re-authenticates (issue #1445). Fail-open when the cookie won't
317 // unseal: local sign-out must still complete, so fall back to "/".
318 const origin = env.VITE_PUBLIC_SITE_URL ?? "";
319 const logoutUrl = yield* workos.logoutUrl(
320 session.sealedSession,
321 origin ? `${origin}/` : undefined,
322 );
323
309 // The auth-hint travels with the session: leaving it behind would324 // The auth-hint travels with the session: leaving it behind would
310 // make the next page load optimistically paint the app shell for a325 // make the next page load optimistically paint the app shell for a
311 // signed-out browser.326 // signed-out browser.
312 deleteResponseCookie(327 return deleteResponseCookie(
313 deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"),328 deleteResponseCookie(
329 HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }),
330 "wos-session",
331 ),
314 AUTH_HINT_COOKIE,332 AUTH_HINT_COOKIE,
315 ),333 );
316 ),334 }),
317 )335 )
318 .handle("organizations", () =>336 .handle("organizations", () =>
319 Effect.gen(function* () {337 Effect.gen(function* () {
apps/cloud/src/auth/workos.ts+40−0
@@ -455,6 +455,46 @@ const make = Effect.gen(function* () { · 454 unmodified lines
455 return refreshed.sealedSession ?? null;455 return refreshed.sealedSession ?? null;
456 }),456 }),
457 457
458 /**
459 * The WorkOS logout URL for a sealed session, or null when the cookie
460 * doesn't unseal or carries no session id (fail-open: the caller clears
461 * local cookies and redirects home instead). The access token is decoded
462 * WITHOUT signature verification, deliberately — WorkOS's documented
463 * logout flow reads `sid` from a possibly-expired token, and logout must
464 * still work for a stale session. (The SDK's `session.getLogoutUrl()` is
465 * deliberately NOT used: it re-authenticates first and throws on an
466 * expired token.) Pure URL construction, no network call; hitting the
467 * URL is the browser's navigation, which is what ends the AuthKit
468 * session upstream.
469 */
470 logoutUrl: (sessionData: string, returnTo?: string) =>
471 Effect.gen(function* () {
472 const unsealed = yield* Effect.tryPromise({
473 try: () => unsealWorkOSSession(sessionData, cookiePassword),
474 catch: (cause) => new LocalSessionCookieError({ cause }),
475 }).pipe(
476 Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)),
477 );
478 if (!unsealed) return null;
479
480 const session = Option.getOrNull(decodeSealedSessionPayload(unsealed));
481 if (!session) return null;
482
483 const claims = yield* Effect.try({
484 try: () => decodeJwt(session.accessToken),
485 catch: (cause) => new LocalSessionCookieError({ cause }),
486 }).pipe(
487 Effect.map((jwt) => Option.getOrNull(decodeJwtClaims(jwt))),
488 Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null)),
489 );
490 if (!claims) return null;
491
492 return workos.userManagement.getLogoutUrl({
493 sessionId: claims.sid,
494 ...(returnTo ? { returnTo } : {}),
495 });
496 }),
497
458 /**498 /**
459 * Authenticate a sealed session string. Returns the user info plus499 * Authenticate a sealed session string. Returns the user info plus
460 * any refreshed session that needs to be set on the response.500 * any refreshed session that needs to be set on the response.
apps/cloud/src/web/shell.tsx+11−4
@@ -9,7 +9,7 @@ import { SupportSlot } from "./components/support-slot"; · 8 unmodified lines
9// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
10// Cloud shell — the SHARED multiplayer shell, identical to self-host, with10// Cloud shell — the SHARED multiplayer shell, identical to self-host, with
11// cloud-only bits injected through its slots:11// cloud-only bits injected through its slots:
12// - sign-out POST cloud's WorkOS logout, then redirect home12// - sign-out navigate through cloud's WorkOS logout, land home
13// - nav items defaults + Organization + Billing (cloud-only sections)13// - nav items defaults + Organization + Billing (cloud-only sections)
14// - org menu slot multi-org switcher + create-org dialog (cloud-only)14// - org menu slot multi-org switcher + create-org dialog (cloud-only)
15// - support slot the "Get support" dialog button (cloud-only)15// - support slot the "Get support" dialog button (cloud-only)
@@ -25,10 +25,17 @@ const navItems = [ · 9 unmodified lines
25 { to: "/billing", label: "Billing" },25 { to: "/billing", label: "Billing" },
26];26];
27 27
28const signOut = async () => {28// A top-level form POST, not fetch: the logout response 302s through the
29 await fetch(AUTH_PATHS.logout, { method: "POST" });29// WorkOS logout endpoint (ending the hosted AuthKit session) before landing
30// back home. A fetch would follow that chain invisibly inside the XHR and
31// leave the page where it was; the browser navigation is the logout.
32const signOut = () => {
30 trackEvent("signed_out");33 trackEvent("signed_out");
31 window.location.href = "/";34 const form = document.createElement("form");
35 form.method = "POST";
36 form.action = AUTH_PATHS.logout;
37 document.body.appendChild(form);
38 form.submit();
32};39};
33 40
34export function Shell(props: { readonly content?: React.ReactNode }) {41export function Shell(props: { readonly content?: React.ReactNode }) {
bun.lock+2−2
@@ -343,7 +343,7 @@ · 342 unmodified lines
343 "version": "0.0.32",343 "version": "0.0.32",
344 "dependencies": {344 "dependencies": {
345 "@executor-js/api": "workspace:*",345 "@executor-js/api": "workspace:*",
346 "@executor-js/emulate": "^0.13.3",346 "@executor-js/emulate": "^0.13.6",
347 "@executor-js/mcporter": "^0.11.4",347 "@executor-js/mcporter": "^0.11.4",
348 "@executor-js/plugin-graphql": "workspace:*",348 "@executor-js/plugin-graphql": "workspace:*",
349 "@executor-js/plugin-mcp": "workspace:*",349 "@executor-js/plugin-mcp": "workspace:*",
@@ -1741,7 +1741,7 @@ · 1391 unmodified lines
1741 1741
1742 "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"],1742 "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"],
1743 1743
1744 "@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=="],1744 "@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=="],
1745 1745
1746 "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"],1746 "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"],
1747 1747
e2e/cloud/auth-hint.test.ts+21−3
@@ -86,9 +86,27 @@ scenario( · 85 unmodified lines
86 });86 });
87 87
88 await step("Sign out through the product flow", async () => {88 await step("Sign out through the product flow", async () => {
89 // The shell's sign-out POSTs the logout endpoint from the page, so89 // The shell's sign-out is a top-level navigation: POST the logout
90 // the response's Set-Cookie clears apply to this browser context.90 // endpoint, ride its redirect through WorkOS's session-end page, and
91 await page.evaluate(() => fetch("/api/auth/logout", { method: "POST" }));91 // land back on the public homepage. (A fetch can't make this trip —
92 // the WorkOS hop is cross-origin.)
93 // Bounded retry: a click can land while the shell is still hydrating
94 // and the radix menu never opens (same idiom as org-switcher).
95 for (let attempt = 1; ; attempt++) {
96 try {
97 await page.keyboard.press("Escape");
98 await page.getByRole("button", { name: /Test User/ }).click();
99 await page.getByRole("menuitem", { name: "Sign out" }).click({ timeout: 5_000 });
100 break;
101 } catch (error) {
102 if (attempt >= 3) throw error;
103 }
104 }
105 // The return URL is "/", which the SSR gate bounces to /login for a
106 // signed-out browser — either is proof the round trip landed home.
107 await page.waitForURL((url) => url.pathname === "/" || url.pathname === "/login", {
108 timeout: 15_000,
109 });
92 });110 });
93 111
94 const names = (await page.context().cookies()).map((cookie) => cookie.name);112 const names = (await page.context().cookies()).map((cookie) => cookie.name);
e2e/cloud/auth-session.test.ts+21−3
@@ -132,7 +132,7 @@ scenario( · 131 unmodified lines
132);132);
133 133
134scenario(134scenario(
135 "Auth · logout sends the user home and tells the browser to drop the session",135 "Auth · logout ends the AuthKit session upstream and tells the browser to drop the cookie",
136 {},136 {},
137 Effect.gen(function* () {137 Effect.gen(function* () {
138 // Gate: the REST API plane is mounted on this target.138 // Gate: the REST API plane is mounted on this target.
@@ -148,10 +148,28 @@ scenario( · 9 unmodified lines
148 headers: identity.headers ?? {},148 headers: identity.headers ?? {},
149 }),149 }),
150 );150 );
151 expect(response.status, "logout redirects back to the landing page").toBe(302);151 expect(response.status, "logout hands the browser to WorkOS").toBe(302);
152 expect(response.headers.get("location"), "the destination is home").toBe("/");
153 const cleared = setCookieFor(response, "wos-session");152 const cleared = setCookieFor(response, "wos-session");
154 expect(cleared, "the session cookie is expired immediately").toContain("Max-Age=0");153 expect(cleared, "the session cookie is expired immediately").toContain("Max-Age=0");
155 expect(cleared, "the cookie value is wiped").toContain("wos-session=;");154 expect(cleared, "the cookie value is wiped").toContain("wos-session=;");
155
156 // The destination is WorkOS's session-end endpoint carrying this
157 // session's id — without this hop the hosted AuthKit session survives
158 // and the next sign-in silently re-authenticates.
159 const logoutUrl = new URL(response.headers.get("location") ?? "");
160 expect(logoutUrl.pathname, "the destination ends the WorkOS session").toBe(
161 "/user_management/sessions/logout",
162 );
163 expect(
164 logoutUrl.searchParams.get("session_id"),
165 "the WorkOS session being ended is this identity's",
166 ).toMatch(/^session_/);
167
168 const upstream = yield* Effect.promise(() => fetch(logoutUrl, { redirect: "manual" }));
169 expect(upstream.status, "WorkOS ends the session and sends the user onward").toBe(302);
170 expect(
171 upstream.headers.get("location"),
172 "the sign-out return URL lands back on this deployment",
173 ).toBe(new URL("/", target.baseUrl).toString());
156 }),174 }),
157);175);
e2e/package.json+1−1
@@ -22,7 +22,7 @@ · 21 unmodified lines
22 },22 },
23 "dependencies": {23 "dependencies": {
24 "@executor-js/api": "workspace:*",24 "@executor-js/api": "workspace:*",
25 "@executor-js/emulate": "^0.13.3",25 "@executor-js/emulate": "^0.13.6",
26 "@executor-js/mcporter": "^0.11.4",26 "@executor-js/mcporter": "^0.11.4",
27 "@executor-js/plugin-graphql": "workspace:*",27 "@executor-js/plugin-graphql": "workspace:*",
28 "@executor-js/plugin-mcp": "workspace:*",28 "@executor-js/plugin-mcp": "workspace:*",