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.
Showing the changes to apps/cloud/src (2 of 3 files). Show all
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; |