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 | /** Public auth endpoints — no authentication required */ | 181 | /** Public auth endpoints — no authentication required */ |
| 182 | export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") | 182 | export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") |
| 183 | .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch })) | 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 | .add( | 193 | .add( |
| 185 | HttpApiEndpoint.get("callback", "/auth/callback", { | 194 | HttpApiEndpoint.get("callback", "/auth/callback", { |
| 186 | query: AuthCallbackSearch, | 195 | query: AuthCallbackSearch, |
| @@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 14 unmodified lines | |||
| 201 | error: AuthErrors, | 210 | error: AuthErrors, |
| 202 | }), | 211 | }), |
| 203 | ) | 212 | ) |
| 204 | .add(HttpApiEndpoint.post("logout", "/auth/logout")) | ||
| 205 | .add( | 213 | .add( |
| 206 | HttpApiEndpoint.get("organizations", "/auth/organizations", { | 214 | HttpApiEndpoint.get("organizations", "/auth/organizations", { |
| 207 | success: AuthOrganizationsResponse, | 215 | success: AuthOrganizationsResponse, |
| @@ -144,8 +144,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n · 143 unmodified lines | |||
| 144 | HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); | 144 | HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); |
| 145 | 145 | ||
| 146 | // --------------------------------------------------------------------------- | 146 | // --------------------------------------------------------------------------- |
| 147 | // Single non-protected API surface — public (login/callback) + session | 147 | // Single non-protected API surface — public (login/callback/logout) + session |
| 148 | // (me/logout/organizations/switch-organization). The session group has SessionAuth on it. | 148 | // (me/organizations/switch-organization). The session group has SessionAuth on it. |
| 149 | // --------------------------------------------------------------------------- | 149 | // --------------------------------------------------------------------------- |
| 150 | 150 | ||
| 151 | export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); | 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 | // CLI device-login discovery. The WorkOS device endpoints live on the | 313 | // CLI device-login discovery. The WorkOS device endpoints live on the |
| 278 | // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, | 314 | // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, |
| 279 | // the SAME base the SDK uses, so e2e points the CLI at the emulator with | 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 | .handle("organizations", () => | 358 | .handle("organizations", () => |
| 352 | Effect.gen(function* () { | 359 | Effect.gen(function* () { |
| 353 | const workos = yield* WorkOSClient; | 360 | const workos = yield* WorkOSClient; |
| @@ -5,14 +5,14 @@ · 4 unmodified lines | |||
| 5 | // it is a screenful of raw API JSON where the homepage should be. Reported | 5 | // it is a screenful of raw API JSON where the homepage should be. Reported |
| 6 | // from the field as `{"_tag":"Unauthorized"}`. | 6 | // from the field as `{"_tag":"Unauthorized"}`. |
| 7 | // | 7 | // |
| 8 | // The endpoint sits behind SessionAuth, so a browser whose sealed session no | 8 | // The endpoint used to sit behind SessionAuth, so a browser whose sealed |
| 9 | // longer authenticates AT THE MOMENT OF THE CLICK gets the middleware's 401 | 9 | // session no longer authenticated AT THE MOMENT OF THE CLICK got the |
| 10 | // instead of being signed out. Reaching that state needs nothing exotic: a | 10 | // middleware's 401 instead of being signed out. Reaching that state needs |
| 11 | // second open tab does it (sign out in one, the other's shell is still painted | 11 | // nothing exotic: a second open tab does it (sign out in one, the other's |
| 12 | // but its cookie is gone), and an expired or upstream-revoked session lands on | 12 | // shell is still painted but its cookie is gone), and an expired or |
| 13 | // the same branch. Signing out of a session that is already over is the one | 13 | // upstream-revoked session lands on the same branch. Signing out of a session |
| 14 | // request that must never fail — the user is asking for the state they are | 14 | // that is already over is the one request that must never fail — the user is |
| 15 | // already in. | 15 | // asking for the state they are already in. |
| 16 | import { expect } from "@effect/vitest"; | 16 | import { expect } from "@effect/vitest"; |
| 17 | import { Effect } from "effect"; | 17 | import { Effect } from "effect"; |
| 18 | import type { Page } from "playwright"; | 18 | import type { Page } from "playwright"; |
| @@ -20,6 +20,9 @@ import type { Page } from "playwright"; · 1 unmodified line | |||
| 20 | import { scenario } from "../src/scenario"; | 20 | import { scenario } from "../src/scenario"; |
| 21 | import { Api, Browser, Target } from "../src/services"; | 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 | /** First `Set-Cookie` header for `name`, as the raw header string. */ | 26 | /** First `Set-Cookie` header for `name`, as the raw header string. */ |
| 24 | const setCookieFor = (response: Response, name: string): string => { | 27 | const setCookieFor = (response: Response, name: string): string => { |
| 25 | for (const header of response.headers.getSetCookie()) { | 28 | for (const header of response.headers.getSetCookie()) { |
| @@ -52,10 +55,6 @@ scenario( · 26 unmodified lines | |||
| 52 | yield* Api; | 55 | yield* Api; |
| 53 | const target = yield* Target; | 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 | const signOut = (cookie?: string) => | 58 | const signOut = (cookie?: string) => |
| 60 | Effect.promise(() => | 59 | Effect.promise(() => |
| 61 | fetch(new URL("/api/auth/logout", target.baseUrl), { | 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 [ | 67 | // A session cookie that no longer authenticates — expired, or revoked |
| 69 | ["no session cookie at all", undefined], | 68 | // upstream while the tab sat open. There is nothing to end at WorkOS, but |
| 70 | ["a session cookie that no longer authenticates", "wos-session=stale-sealed-session"], | 69 | // this browser must still leave signed out: a stale session cookie left in |
| 71 | ] as const) { | 70 | // place keeps routing / into the app instead of marketing. |
| 72 | const response = yield* signOut(cookie); | 71 | const stale = yield* signOut("wos-session=stale-sealed-session"); |
| 73 | const body = yield* Effect.promise(() => response.text()); | 72 | const staleBody = yield* Effect.promise(() => stale.text()); |
| 74 | 73 | ||
| 75 | // The response IS the page the user reads. This is the reported symptom. | 74 | // 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( | 75 | expect(staleBody, "sign-out never renders an API error envelope").not.toContain("_tag"); |
| 77 | "_tag", | 76 | expect(stale.status, "sign-out sends the browser onward").toBe(302); |
| 78 | ); | 77 | expect( |
| 79 | expect(response.status, `sign-out with ${label} sends the browser onward`).toBe(302); | 78 | new URL(stale.headers.get("location") ?? "", target.baseUrl).pathname, |
| 80 | expect( | 79 | "sign-out lands the browser home", |
| 81 | new URL(response.headers.get("location") ?? "", target.baseUrl).pathname, | 80 | ).toBe("/"); |
| 82 | `sign-out with ${label} lands the browser home`, | 81 | expect(setCookieFor(stale, "wos-session"), "the dead session cookie is dropped").toContain( |
| 83 | ).toBe("/"); | 82 | "Max-Age=0", |
| 84 | // Nothing to end upstream without a session, but the browser must still | 83 | ); |
| 85 | // leave with its cookies dropped — a stale session cookie left in place | 84 | expect( |
| 86 | // keeps routing / into the app instead of marketing. | 85 | setCookieFor(stale, HINT_COOKIE), |
| 87 | expect( | 86 | "the auth hint never outlives the session it describes", |
| 88 | setCookieFor(response, "wos-session"), | 87 | ).toContain("Max-Age=0"); |
| 89 | `sign-out with ${label} still clears the session cookie`, | 88 | |
| 90 | ).toContain("Max-Age=0"); | 89 | // A POST carrying no cookies at all is what a CROSS-SITE form reaching |
| 91 | } | 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 | ||