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 | /** 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; |