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 3 months ago

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

Showing the changes to apps/cloud/src (2 of 3 files). Show all

Unified · Split

2 files
  1. apps/cloud/src/auth/api.ts +9 −1
  2. apps/cloud/src/auth/handlers.ts +38 −31
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;

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t