Skip to content

fix(session): use auth context refresh token instead of stale request cookie#55

Open
nicknisi wants to merge 1 commit intomainfrom
fix/issue-53
Open

fix(session): use auth context refresh token instead of stale request cookie#55
nicknisi wants to merge 1 commit intomainfrom
fix/issue-53

Conversation

@nicknisi
Copy link
Member

@nicknisi nicknisi commented Mar 8, 2026

Summary

  • Fixes switchToOrganization (and refreshAuth) failing with "Failed to refresh tokens" when the middleware auto-refreshes an expired access token
  • getSessionWithRefreshToken() was re-reading the session from the original request cookie, which has a stale refresh token after middleware refresh. Now reads from the auth context instead, which always has the latest token.

Root cause

When a user's access token expires, the middleware's withAuth() automatically refreshes it — which invalidates the old refresh token at WorkOS. But getSessionWithRefreshToken() was calling authkit.getSession(ctx.request) to read the session from the original request, getting the now-invalid old refresh token. Any subsequent operation using that token (switchToOrganization, refreshAuth) would fail because WorkOS rejects the stale token.

The auth context (AuthResult) from the middleware already contains the correct (refreshed) refresh token. The fix simply reads it from there instead of re-reading the original request.

What changed

  • src/server/auth-helpers.ts: getSessionWithRefreshToken() now reads auth.refreshToken from the middleware context instead of re-reading the session from the request cookie
  • src/server/auth-helpers.spec.ts: Updated tests + added test for the middleware-refresh race condition

Test plan

  • All 159 existing tests pass
  • New test: "uses middleware-refreshed token instead of stale request token" — verifies the auth context token is used, not the stale request token
  • Build + typecheck passes
  • Manual test with WORKOS_COOKIE_NAME=my-custom-session (see screenshots below)

Manual verification

Tested with Playwright against the example app with WORKOS_COOKIE_NAME=my-custom-session set in .env.

Authenticated with custom cookie name — /client page

03-client-page-authed.png

After clicking Switch — org claims populated successfully

04-switch-org-success.png

Steps:

  1. Started example app with WORKOS_COOKIE_NAME=my-custom-session
  2. Signed in via AuthKit — confirmed my-custom-session cookie in browser
  3. Navigated to /client, entered org ID, clicked Switch
  4. Result: Success — Organization ID, Role (member), Roles populated. Green callout shown. No errors.

Fixes #53

… cookie

When the middleware's withAuth() auto-refreshes an expired access token,
the old refresh token in the original request cookie is invalidated by
WorkOS. However, getSessionWithRefreshToken() was re-reading the session
from the original request, getting the now-invalid old refresh token.
Subsequent operations like switchToOrganization would then fail with
"Failed to refresh tokens" because the stale token was rejected by the
WorkOS API.

The fix reads the refresh token directly from the middleware auth context
(AuthResult), which always has the latest token — whether the middleware
refreshed or not. This eliminates the race condition entirely.

Fixes #53
@greptile-apps
Copy link

greptile-apps bot commented Mar 8, 2026

Greptile Summary

This PR fixes a token invalidation race condition in getSessionWithRefreshToken: when the middleware (withAuth()) auto-refreshes an expired access token, the old refresh token stored in the original request cookie becomes invalid at WorkOS. The previous implementation re-fetched the session from the request cookie via authkit.getSession(ctx.request), picking up the now-stale refresh token and causing switchToOrganization / refreshAuth to fail with "Failed to refresh tokens". The fix reads refreshToken directly from the middleware-populated AuthResult in the auth context, which always reflects the latest (post-refresh) token.

Key changes:

  • src/server/auth-helpers.ts: getSessionWithRefreshToken now reads auth.refreshToken from the context rather than calling authkit.getSession() on the original request; the function is no longer dependent on the getAuthkit import for this code path.
  • src/server/auth-helpers.spec.ts: All tests updated to supply refreshToken on the mock auth context; a new test explicitly verifies that getSession is never called and that the context token wins over a stale request cookie.
  • Minor style note: getSessionWithRefreshToken is still marked async despite having no await calls after the refactor — this is harmless but worth tidying up.

Confidence Score: 5/5

  • This PR is safe to merge; it is a targeted, well-tested bug fix with no breaking changes to the public API.
  • The change is minimal and correctly addresses the root cause. The existing test suite still passes, a new regression test was added, and the 'refreshToken' in auth guard is a safe way to handle both present and absent properties on the AuthResult union type. The only finding is a redundant async keyword — a style issue with no runtime impact.
  • No files require special attention.

Important Files Changed

Filename Overview
src/server/auth-helpers.ts Core fix: getSessionWithRefreshToken now reads refreshToken from the auth context instead of re-fetching from the request cookie, correctly resolving the stale-token race condition. The async keyword is now redundant but harmless.
src/server/auth-helpers.spec.ts Tests updated to reflect the new auth-context-based token read; a well-targeted new test case explicitly asserts that getSession is never called and the refreshed context token is used over the stale request cookie.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Middleware as withAuth() Middleware
    participant AuthContext as Auth Context (AuthResult)
    participant WorkOS

    Client->>Middleware: Request with expired access token (cookie)
    Middleware->>WorkOS: Refresh tokens (old refresh token)
    WorkOS-->>Middleware: New access token + new refresh token
    Middleware->>AuthContext: Store refreshed AuthResult (new tokens)

    Note over Middleware,AuthContext: OLD behaviour (broken)
    Client->>getSessionWithRefreshToken: Call
    getSessionWithRefreshToken->>Middleware: getSession(ctx.request) — reads cookie
    Middleware-->>getSessionWithRefreshToken: OLD (stale) refresh token
    getSessionWithRefreshToken->>WorkOS: switchToOrganization / refreshAuth
    WorkOS-->>getSessionWithRefreshToken: ❌ "Failed to refresh tokens"

    Note over AuthContext,WorkOS: NEW behaviour (fixed)
    Client->>getSessionWithRefreshToken: Call
    getSessionWithRefreshToken->>AuthContext: auth.refreshToken
    AuthContext-->>getSessionWithRefreshToken: NEW (valid) refresh token
    getSessionWithRefreshToken->>WorkOS: switchToOrganization / refreshAuth
    WorkOS-->>getSessionWithRefreshToken: ✅ Success
Loading

Last reviewed commit: 73a7396

* even if the middleware auto-refreshed during withAuth().
* Returns null if no valid session exists.
*/
export async function getSessionWithRefreshToken(): Promise<{
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant async keyword

After this refactor, getSessionWithRefreshToken no longer contains any await expressions. The async keyword is now redundant — the function still behaves correctly (returning a resolved Promise), but linters with @typescript-eslint/require-await will flag this, and it may be slightly misleading to future readers who expect an I/O operation inside.

Consider removing async and returning Promise.resolve(...) explicitly, or alternatively converting the return type from Promise<...> to a synchronous return and updating all call sites (though since the public API uses await, keeping the Promise wrapper is safest).

@nicknisi
Copy link
Member Author

nicknisi commented Mar 8, 2026

Manual Verification with WORKOS_COOKIE_NAME=my-custom-session

Test environment: Example app running on localhost:3000 with custom cookie name set via WORKOS_COOKIE_NAME=my-custom-session in .env

Steps performed:

  1. Started example app with custom cookie name
  2. Signed in via AuthKit (email/password flow)
  3. Verified browser cookie named my-custom-session was set (confirmed via Playwright cookie-list)
  4. Navigated to /client page — authenticated state correctly loaded via useAuth() hook
  5. Entered org ID org_01KK771BW93RFG6RJDZ5R8ZSC4 in "Switch to Org" field
  6. Clicked "Switch" button

Result: SUCCESS

After clicking Switch:

  • Organization ID field populated with org_01KK771BW93RFG6RJDZ5R8ZSC4
  • Role field showed member
  • Roles badges showed member
  • Green success callout: "✅ Success! Check updated claims above."
  • No errors in server console or browser console

Before (what the bug looked like):

With the old code, after an access token expired and middleware auto-refreshed it, switchToOrganization would throw TokenRefreshError: Failed to refresh tokens because it read the now-invalidated old refresh token from the original request cookie instead of the refreshed one from the auth context.

Test data cleanup:

  • Created temporary org org_01KK771BW93RFG6RJDZ5R8ZSC4 with user membership
  • Deleted org and membership after test
  • Removed WORKOS_COOKIE_NAME from example .env

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

switchToOrganization on client reports failed to refresh tokens

1 participant