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
181/** Public auth endpoints — no authentication required */181/** Public auth endpoints — no authentication required */
182export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")182export 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,
apps/cloud/src/auth/handlers.ts+38−31
@@ -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) + session147// 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
151export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi);151export 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 the313 // 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 with315 // 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;

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