History

fix: sign out browsers whose session has already ended

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.

Rhys Sullivan committed 2 months ago

Commit 16ea2841 parent a5de72e3 files · +99 −67Browse files

Unified · Split

3 files
  1. apps/cloud/src/auth/api.ts +9 −1
  2. apps/cloud/src/auth/handlers.ts +38 −31
  3. e2e/cloud/logout-stale-session.test.ts +52 −35
apps/cloud/src/auth/api.ts+9−1
@@ -181,6 +181,15 @@ const McpApprovalErrors = [ · 180 unmodified lines
181181 /** Public auth endpoints — no authentication required */
182182 export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
183183 .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"))
184193 .add(
185194 HttpApiEndpoint.get("callback", "/auth/callback", {
186195 query: AuthCallbackSearch,
@@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 14 unmodified lines
201210 error: AuthErrors,
202211 }),
203212 )
204− .add(HttpApiEndpoint.post("logout", "/auth/logout"))
205213 .add(
206214 HttpApiEndpoint.get("organizations", "/auth/organizations", {
207215 success: AuthOrganizationsResponse,
apps/cloud/src/auth/handlers.ts+38−31
@@ -144,8 +144,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n · 143 unmodified lines
144144 HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);
145145
146146 // ---------------------------------------------------------------------------
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.
149149 // ---------------------------------------------------------------------------
150150
151151 export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi);
@@ -274,6 +274,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 122 unmodified lines
274274 );
275275 }),
276276 )
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+ )
277313 // CLI device-login discovery. The WorkOS device endpoints live on the
278314 // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,
279315 // 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
319355 };
320356 }),
321357 )
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− )
351358 .handle("organizations", () =>
352359 Effect.gen(function* () {
353360 const workos = yield* WorkOSClient;
e2e/cloud/logout-stale-session.test.ts+52−35
@@ -5,14 +5,14 @@ · 4 unmodified lines
55 // it is a screenful of raw API JSON where the homepage should be. Reported
66 // from the field as `{"_tag":"Unauthorized"}`.
77 //
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.
1616 import { expect } from "@effect/vitest";
1717 import { Effect } from "effect";
1818 import type { Page } from "playwright";
@@ -20,6 +20,9 @@ import type { Page } from "playwright"; · 1 unmodified line
2020 import { scenario } from "../src/scenario";
2121 import { Api, Browser, Target } from "../src/services";
2222
23+/** The display-only identity cookie the SSR gate mints (non-HttpOnly). */
24+const HINT_COOKIE = "executor-auth-hint";
25+
2326 /** First `Set-Cookie` header for `name`, as the raw header string. */
2427 const setCookieFor = (response: Response, name: string): string => {
2528 for (const header of response.headers.getSetCookie()) {
@@ -52,10 +55,6 @@ scenario( · 26 unmodified lines
5255 yield* Api;
5356 const target = yield* Target;
5457
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.
5958 const signOut = (cookie?: string) =>
6059 Effect.promise(() =>
6160 fetch(new URL("/api/auth/logout", target.baseUrl), {
@@ -65,30 +64,48 @@ scenario( · 3 unmodified lines
6564 }),
6665 );
6766
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());
7473
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([]);
92109 }),
93110 );
94111