Sign-out was declared on the session-authenticated API group, so a browser whose sealed session no longer authenticated at click time got a 401 from SessionAuth instead of being signed out. The console posts sign-out as a top-level form navigation, so that error body was rendered as the page: a screenful of {"_tag":"Unauthorized"}. Move the endpoint to the public group and read the sealed session off the request cookie. Nothing here needs authorizing — the request can only end the session whose cookie it presents — and the handler already fell back to "/" when the cookie would not unseal. Cookies are dropped only when the request actually presented one, so the now-public endpoint cannot be used cross-site to sign a user out.
Unified · Split
| @@ -181,6 +181,15 @@ const McpApprovalErrors = [ · 180 unmodified lines | ||
| 181 | 181 | /** Public auth endpoints — no authentication required */ |
| 182 | 182 | export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") |
| 183 | 183 | .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch })) |
| 184 | + // Sign-out is PUBLIC on purpose. The console posts it as a top-level form | |
| 185 | + // navigation (the WorkOS hop is cross-origin, so it can't be a fetch), which | |
| 186 | + // means an error response is rendered as the page — and a browser whose | |
| 187 | + // session has already ended is exactly the browser most likely to click it | |
| 188 | + // (a second tab, a re-submit from history, an expired or revoked session). | |
| 189 | + // Behind SessionAuth all of those got a raw `{"_tag":"Unauthorized"}` screen | |
| 190 | + // instead of being signed out. There is nothing to authorize here anyway: | |
| 191 | + // the request can only end the session whose cookie it presents. | |
| 192 | + .add(HttpApiEndpoint.post("logout", "/auth/logout")) | |
| 184 | 193 | .add( |
| 185 | 194 | HttpApiEndpoint.get("callback", "/auth/callback", { |
| 186 | 195 | query: AuthCallbackSearch, |
| @@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 14 unmodified lines | ||
| 201 | 210 | error: AuthErrors, |
| 202 | 211 | }), |
| 203 | 212 | ) |
| 204 | − .add(HttpApiEndpoint.post("logout", "/auth/logout")) | |
| 205 | 213 | .add( |
| 206 | 214 | HttpApiEndpoint.get("organizations", "/auth/organizations", { |
| 207 | 215 | success: AuthOrganizationsResponse, |
| @@ -144,8 +144,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n · 143 unmodified lines | ||
| 144 | 144 | HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); |
| 145 | 145 | |
| 146 | 146 | // --------------------------------------------------------------------------- |
| 147 | −// Single non-protected API surface — public (login/callback) + session | |
| 148 | −// (me/logout/organizations/switch-organization). The session group has SessionAuth on it. | |
| 147 | +// Single non-protected API surface — public (login/callback/logout) + session | |
| 148 | +// (me/organizations/switch-organization). The session group has SessionAuth on it. | |
| 149 | 149 | // --------------------------------------------------------------------------- |
| 150 | 150 | |
| 151 | 151 | export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); |
| @@ -274,6 +274,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 122 unmodified lines | ||
| 274 | 274 | ); |
| 275 | 275 | }), |
| 276 | 276 | ) |
| 277 | + .handleRaw("logout", ({ request }) => | |
| 278 | + Effect.gen(function* () { | |
| 279 | + const workos = yield* WorkOSClient; | |
| 280 | + // The session this browser presents, NOT one the middleware vouched | |
| 281 | + // for — signing out of a session that has already ended must still | |
| 282 | + // sign the browser out (see the group declaration in ./api.ts). | |
| 283 | + const sealedSession = request.cookies["wos-session"] ?? ""; | |
| 284 | + | |
| 285 | + // WorkOS's documented sign-out: send the browser through the WorkOS | |
| 286 | + // logout endpoint, which ends the AuthKit session upstream and then | |
| 287 | + // redirects to the registered sign-out URL. Without this hop, the | |
| 288 | + // hosted session survives and the next "Sign in" silently | |
| 289 | + // re-authenticates (issue #1445). Fail-open when the cookie won't | |
| 290 | + // unseal — there is then nothing to end upstream, and local sign-out | |
| 291 | + // must still complete, so fall back to "/". | |
| 292 | + const origin = env.VITE_PUBLIC_SITE_URL ?? ""; | |
| 293 | + const logoutUrl = sealedSession | |
| 294 | + ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined) | |
| 295 | + : null; | |
| 296 | + | |
| 297 | + const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }); | |
| 298 | + | |
| 299 | + // Drop only what this browser actually presented. Both cookies are | |
| 300 | + // SameSite=Lax, so a cross-site form POST carries neither — it gets | |
| 301 | + // the bare redirect and cannot be used to sign anyone out. | |
| 302 | + if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response; | |
| 303 | + | |
| 304 | + // The auth-hint travels with the session: leaving it behind would | |
| 305 | + // make the next page load optimistically paint the app shell for a | |
| 306 | + // signed-out browser. | |
| 307 | + return deleteResponseCookie( | |
| 308 | + deleteResponseCookie(response, "wos-session"), | |
| 309 | + AUTH_HINT_COOKIE, | |
| 310 | + ); | |
| 311 | + }), | |
| 312 | + ) | |
| 277 | 313 | // CLI device-login discovery. The WorkOS device endpoints live on the |
| 278 | 314 | // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, |
| 279 | 315 | // the SAME base the SDK uses, so e2e points the CLI at the emulator with |
| @@ -319,35 +355,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 39 unmodified lines | ||
| 319 | 355 | }; |
| 320 | 356 | }), |
| 321 | 357 | ) |
| 322 | − .handleRaw("logout", () => | |
| 323 | − Effect.gen(function* () { | |
| 324 | − const workos = yield* WorkOSClient; | |
| 325 | − const session = yield* SessionContext; | |
| 326 | − | |
| 327 | − // WorkOS's documented sign-out: send the browser through the WorkOS | |
| 328 | − // logout endpoint, which ends the AuthKit session upstream and then | |
| 329 | − // redirects to the registered sign-out URL. Without this hop, the | |
| 330 | − // hosted session survives and the next "Sign in" silently | |
| 331 | − // re-authenticates (issue #1445). Fail-open when the cookie won't | |
| 332 | − // unseal: local sign-out must still complete, so fall back to "/". | |
| 333 | − const origin = env.VITE_PUBLIC_SITE_URL ?? ""; | |
| 334 | − const logoutUrl = yield* workos.logoutUrl( | |
| 335 | − session.sealedSession, | |
| 336 | − origin ? `${origin}/` : undefined, | |
| 337 | − ); | |
| 338 | − | |
| 339 | − // The auth-hint travels with the session: leaving it behind would | |
| 340 | − // make the next page load optimistically paint the app shell for a | |
| 341 | − // signed-out browser. | |
| 342 | − return deleteResponseCookie( | |
| 343 | − deleteResponseCookie( | |
| 344 | − HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }), | |
| 345 | − "wos-session", | |
| 346 | − ), | |
| 347 | − AUTH_HINT_COOKIE, | |
| 348 | − ); | |
| 349 | − }), | |
| 350 | − ) | |
| 351 | 358 | .handle("organizations", () => |
| 352 | 359 | Effect.gen(function* () { |
| 353 | 360 | const workos = yield* WorkOSClient; |
| @@ -5,14 +5,14 @@ · 4 unmodified lines | ||
| 5 | 5 | // it is a screenful of raw API JSON where the homepage should be. Reported |
| 6 | 6 | // from the field as `{"_tag":"Unauthorized"}`. |
| 7 | 7 | // |
| 8 | −// The endpoint sits behind SessionAuth, so a browser whose sealed session no | |
| 9 | −// longer authenticates AT THE MOMENT OF THE CLICK gets the middleware's 401 | |
| 10 | −// instead of being signed out. Reaching that state needs nothing exotic: a | |
| 11 | −// second open tab does it (sign out in one, the other's shell is still painted | |
| 12 | −// but its cookie is gone), and an expired or upstream-revoked session lands on | |
| 13 | −// the same branch. Signing out of a session that is already over is the one | |
| 14 | −// request that must never fail — the user is asking for the state they are | |
| 15 | −// already in. | |
| 8 | +// The endpoint used to sit behind SessionAuth, so a browser whose sealed | |
| 9 | +// session no longer authenticated AT THE MOMENT OF THE CLICK got the | |
| 10 | +// middleware's 401 instead of being signed out. Reaching that state needs | |
| 11 | +// nothing exotic: a second open tab does it (sign out in one, the other's | |
| 12 | +// shell is still painted but its cookie is gone), and an expired or | |
| 13 | +// upstream-revoked session lands on the same branch. Signing out of a session | |
| 14 | +// that is already over is the one request that must never fail — the user is | |
| 15 | +// asking for the state they are already in. | |
| 16 | 16 | import { expect } from "@effect/vitest"; |
| 17 | 17 | import { Effect } from "effect"; |
| 18 | 18 | import type { Page } from "playwright"; |
| @@ -20,6 +20,9 @@ import type { Page } from "playwright"; · 1 unmodified line | ||
| 20 | 20 | import { scenario } from "../src/scenario"; |
| 21 | 21 | import { Api, Browser, Target } from "../src/services"; |
| 22 | 22 | |
| 23 | +/** The display-only identity cookie the SSR gate mints (non-HttpOnly). */ | |
| 24 | +const HINT_COOKIE = "executor-auth-hint"; | |
| 25 | + | |
| 23 | 26 | /** First `Set-Cookie` header for `name`, as the raw header string. */ |
| 24 | 27 | const setCookieFor = (response: Response, name: string): string => { |
| 25 | 28 | for (const header of response.headers.getSetCookie()) { |
| @@ -52,10 +55,6 @@ scenario( · 26 unmodified lines | ||
| 52 | 55 | yield* Api; |
| 53 | 56 | const target = yield* Target; |
| 54 | 57 | |
| 55 | − // Two ways a real browser arrives here holding no live session: it never | |
| 56 | − // had one (the cookie was cleared, or sign-out is re-submitted from | |
| 57 | − // history), and it holds one that no longer authenticates (expired, or | |
| 58 | − // revoked upstream). Both POST the same form the shell posts. | |
| 59 | 58 | const signOut = (cookie?: string) => |
| 60 | 59 | Effect.promise(() => |
| 61 | 60 | fetch(new URL("/api/auth/logout", target.baseUrl), { |
| @@ -65,30 +64,48 @@ scenario( · 3 unmodified lines | ||
| 65 | 64 | }), |
| 66 | 65 | ); |
| 67 | 66 | |
| 68 | − for (const [label, cookie] of [ | |
| 69 | − ["no session cookie at all", undefined], | |
| 70 | − ["a session cookie that no longer authenticates", "wos-session=stale-sealed-session"], | |
| 71 | − ] as const) { | |
| 72 | − const response = yield* signOut(cookie); | |
| 73 | − const body = yield* Effect.promise(() => response.text()); | |
| 67 | + // A session cookie that no longer authenticates — expired, or revoked | |
| 68 | + // upstream while the tab sat open. There is nothing to end at WorkOS, but | |
| 69 | + // this browser must still leave signed out: a stale session cookie left in | |
| 70 | + // place keeps routing / into the app instead of marketing. | |
| 71 | + const stale = yield* signOut("wos-session=stale-sealed-session"); | |
| 72 | + const staleBody = yield* Effect.promise(() => stale.text()); | |
| 74 | 73 | |
| 75 | − // The response IS the page the user reads. This is the reported symptom. | |
| 76 | − expect(body, `sign-out with ${label} never renders an API error envelope`).not.toContain( | |
| 77 | − "_tag", | |
| 78 | − ); | |
| 79 | − expect(response.status, `sign-out with ${label} sends the browser onward`).toBe(302); | |
| 80 | − expect( | |
| 81 | − new URL(response.headers.get("location") ?? "", target.baseUrl).pathname, | |
| 82 | − `sign-out with ${label} lands the browser home`, | |
| 83 | − ).toBe("/"); | |
| 84 | − // Nothing to end upstream without a session, but the browser must still | |
| 85 | − // leave with its cookies dropped — a stale session cookie left in place | |
| 86 | − // keeps routing / into the app instead of marketing. | |
| 87 | − expect( | |
| 88 | − setCookieFor(response, "wos-session"), | |
| 89 | − `sign-out with ${label} still clears the session cookie`, | |
| 90 | − ).toContain("Max-Age=0"); | |
| 91 | − } | |
| 74 | + // The response IS the page the user reads. This is the reported symptom. | |
| 75 | + expect(staleBody, "sign-out never renders an API error envelope").not.toContain("_tag"); | |
| 76 | + expect(stale.status, "sign-out sends the browser onward").toBe(302); | |
| 77 | + expect( | |
| 78 | + new URL(stale.headers.get("location") ?? "", target.baseUrl).pathname, | |
| 79 | + "sign-out lands the browser home", | |
| 80 | + ).toBe("/"); | |
| 81 | + expect(setCookieFor(stale, "wos-session"), "the dead session cookie is dropped").toContain( | |
| 82 | + "Max-Age=0", | |
| 83 | + ); | |
| 84 | + expect( | |
| 85 | + setCookieFor(stale, HINT_COOKIE), | |
| 86 | + "the auth hint never outlives the session it describes", | |
| 87 | + ).toContain("Max-Age=0"); | |
| 88 | + | |
| 89 | + // A POST carrying no cookies at all is what a CROSS-SITE form reaching | |
| 90 | + // this endpoint looks like: both cookies are SameSite=Lax, so another | |
| 91 | + // origin's POST arrives bare. Signing out is public, so it must answer | |
| 92 | + // such a request without touching this browser's session — otherwise any | |
| 93 | + // site could sign our users out. | |
| 94 | + const bare = yield* signOut(); | |
| 95 | + const bareBody = yield* Effect.promise(() => bare.text()); | |
| 96 | + | |
| 97 | + expect(bareBody, "a session-less sign-out renders no error envelope either").not.toContain( | |
| 98 | + "_tag", | |
| 99 | + ); | |
| 100 | + expect(bare.status, "it is sent home like any other sign-out").toBe(302); | |
| 101 | + expect( | |
| 102 | + new URL(bare.headers.get("location") ?? "", target.baseUrl).pathname, | |
| 103 | + "it lands home", | |
| 104 | + ).toBe("/"); | |
| 105 | + expect( | |
| 106 | + bare.headers.getSetCookie(), | |
| 107 | + "a request that presented no session cannot expire anyone else's", | |
| 108 | + ).toEqual([]); | |
| 92 | 109 | }), |
| 93 | 110 | ); |
| 94 | 111 | |