Logout only cleared the local sealed-session cookie; the hosted AuthKit session survived, so the next Sign in silently re-authenticated without credentials (#1445). Follow WorkOS's documented sign-out: extract the session id from the sealed cookie locally (plain JWT decode, no verification, so an expired token still signs out), 302 through the WorkOS logout endpoint to end the session upstream, and land back on the public homepage via return_to. If the cookie will not unseal, fall back to the old redirect-to-/ so local sign-out always completes. The shell submits sign-out as a top-level form POST instead of fetch so the browser navigation carries WorkOS's cookies through the hop. Needs @executor-js/emulate 0.13.6 (the emulator's session-end route).
Showing the changes to apps/cloud/src/auth (2 of 7 files). Show all
Unified · Split
| @@ -305,15 +305,33 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 304 unmodified lines | |||
| 305 | }), | 305 | }), |
| 306 | ) | 306 | ) |
| 307 | .handleRaw("logout", () => | 307 | .handleRaw("logout", () => |
| 308 | Effect.succeed( | 308 | Effect.gen(function* () { |
| 309 | const workos = yield* WorkOSClient; | ||
| 310 | const session = yield* SessionContext; | ||
| 311 | |||
| 312 | // WorkOS's documented sign-out: send the browser through the WorkOS | ||
| 313 | // logout endpoint, which ends the AuthKit session upstream and then | ||
| 314 | // redirects to the registered sign-out URL. Without this hop, the | ||
| 315 | // hosted session survives and the next "Sign in" silently | ||
| 316 | // re-authenticates (issue #1445). Fail-open when the cookie won't | ||
| 317 | // unseal: local sign-out must still complete, so fall back to "/". | ||
| 318 | const origin = env.VITE_PUBLIC_SITE_URL ?? ""; | ||
| 319 | const logoutUrl = yield* workos.logoutUrl( | ||
| 320 | session.sealedSession, | ||
| 321 | origin ? `${origin}/` : undefined, | ||
| 322 | ); | ||
| 323 | |||
| 309 | // The auth-hint travels with the session: leaving it behind would | 324 | // The auth-hint travels with the session: leaving it behind would |
| 310 | // make the next page load optimistically paint the app shell for a | 325 | // make the next page load optimistically paint the app shell for a |
| 311 | // signed-out browser. | 326 | // signed-out browser. |
| 312 | deleteResponseCookie( | 327 | return deleteResponseCookie( |
| 313 | deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"), | 328 | deleteResponseCookie( |
| 329 | HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }), | ||
| 330 | "wos-session", | ||
| 331 | ), | ||
| 314 | AUTH_HINT_COOKIE, | 332 | AUTH_HINT_COOKIE, |
| 315 | ), | 333 | ); |
| 316 | ), | 334 | }), |
| 317 | ) | 335 | ) |
| 318 | .handle("organizations", () => | 336 | .handle("organizations", () => |
| 319 | Effect.gen(function* () { | 337 | Effect.gen(function* () { |
| @@ -455,6 +455,46 @@ const make = Effect.gen(function* () { · 454 unmodified lines | |||
| 455 | return refreshed.sealedSession ?? null; | 455 | return refreshed.sealedSession ?? null; |
| 456 | }), | 456 | }), |
| 457 | 457 | ||
| 458 | /** | ||
| 459 | * The WorkOS logout URL for a sealed session, or null when the cookie | ||
| 460 | * doesn't unseal or carries no session id (fail-open: the caller clears | ||
| 461 | * local cookies and redirects home instead). The access token is decoded | ||
| 462 | * WITHOUT signature verification, deliberately — WorkOS's documented | ||
| 463 | * logout flow reads `sid` from a possibly-expired token, and logout must | ||
| 464 | * still work for a stale session. (The SDK's `session.getLogoutUrl()` is | ||
| 465 | * deliberately NOT used: it re-authenticates first and throws on an | ||
| 466 | * expired token.) Pure URL construction, no network call; hitting the | ||
| 467 | * URL is the browser's navigation, which is what ends the AuthKit | ||
| 468 | * session upstream. | ||
| 469 | */ | ||
| 470 | logoutUrl: (sessionData: string, returnTo?: string) => | ||
| 471 | Effect.gen(function* () { | ||
| 472 | const unsealed = yield* Effect.tryPromise({ | ||
| 473 | try: () => unsealWorkOSSession(sessionData, cookiePassword), | ||
| 474 | catch: (cause) => new LocalSessionCookieError({ cause }), | ||
| 475 | }).pipe( | ||
| 476 | Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)), | ||
| 477 | ); | ||
| 478 | if (!unsealed) return null; | ||
| 479 | |||
| 480 | const session = Option.getOrNull(decodeSealedSessionPayload(unsealed)); | ||
| 481 | if (!session) return null; | ||
| 482 | |||
| 483 | const claims = yield* Effect.try({ | ||
| 484 | try: () => decodeJwt(session.accessToken), | ||
| 485 | catch: (cause) => new LocalSessionCookieError({ cause }), | ||
| 486 | }).pipe( | ||
| 487 | Effect.map((jwt) => Option.getOrNull(decodeJwtClaims(jwt))), | ||
| 488 | Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null)), | ||
| 489 | ); | ||
| 490 | if (!claims) return null; | ||
| 491 | |||
| 492 | return workos.userManagement.getLogoutUrl({ | ||
| 493 | sessionId: claims.sid, | ||
| 494 | ...(returnTo ? { returnTo } : {}), | ||
| 495 | }); | ||
| 496 | }), | ||
| 497 | |||
| 458 | /** | 498 | /** |
| 459 | * Authenticate a sealed session string. Returns the user info plus | 499 | * Authenticate a sealed session string. Returns the user info plus |
| 460 | * any refreshed session that needs to be set on the response. | 500 | * any refreshed session that needs to be set on the response. |