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).
Showing the changes to e2e/cloud (2 of 7 files). Show all
Unified · Split
| @@ -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, so | 89 | // 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); |
| @@ -132,7 +132,7 @@ scenario( · 131 unmodified lines | |||
| 132 | ); | 132 | ); |
| 133 | 133 | ||
| 134 | scenario( | 134 | scenario( |
| 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 | ); |