From 73a7396548a9eb2d48b5f0a8d910e5e3232211f7 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 8 Mar 2026 12:03:49 -0500 Subject: [PATCH] fix(session): use auth context refresh token instead of stale request cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/server/auth-helpers.spec.ts | 45 ++++++++++++++++++++++++++------- src/server/auth-helpers.ts | 17 ++++++++----- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/server/auth-helpers.spec.ts b/src/server/auth-helpers.spec.ts index 9c824f8..e7842f2 100644 --- a/src/server/auth-helpers.spec.ts +++ b/src/server/auth-helpers.spec.ts @@ -97,26 +97,24 @@ describe('Auth Helpers', () => { expect(result).toBeNull(); }); - it('returns null when no refresh token in session', async () => { + it('returns null when no refresh token in auth context', async () => { mockAuthContext = { auth: () => ({ user: { id: 'user_123' }, accessToken: 'token' }), request: new Request('http://test.local'), }; - mockAuthkit.getSession.mockResolvedValue({ refreshToken: null }); const result = await getSessionWithRefreshToken(); expect(result).toBeNull(); }); - it('returns session data with refresh token', async () => { + it('returns session data with refresh token from auth context', async () => { const user = { id: 'user_123', email: 'test@example.com' }; const impersonator = { email: 'admin@example.com' }; mockAuthContext = { - auth: () => ({ user, accessToken: 'access_token', impersonator }), + auth: () => ({ user, accessToken: 'access_token', refreshToken: 'refresh_token', impersonator }), request: new Request('http://test.local'), }; - mockAuthkit.getSession.mockResolvedValue({ refreshToken: 'refresh_token' }); const result = await getSessionWithRefreshToken(); @@ -127,6 +125,37 @@ describe('Auth Helpers', () => { impersonator, }); }); + + it('uses middleware-refreshed token instead of stale request token', async () => { + // Simulates the case where middleware auto-refreshed the session + // (e.g., expired access token). The auth context has the NEW refresh token, + // while the original request cookie has the OLD (invalidated) one. + const user = { id: 'user_123' }; + mockAuthContext = { + auth: () => ({ + user, + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', // refreshed by middleware + impersonator: undefined, + }), + request: new Request('http://test.local', { + headers: { cookie: 'wos-session=old_encrypted_session' }, + }), + }; + + const result = await getSessionWithRefreshToken(); + + // Should use the NEW refresh token from auth context, not the old one from request + expect(result).toEqual({ + refreshToken: 'new_refresh_token', + accessToken: 'new_access_token', + user, + impersonator: undefined, + }); + + // Should NOT call getSession on the request (no longer needed) + expect(mockAuthkit.getSession).not.toHaveBeenCalled(); + }); }); describe('refreshSession', () => { @@ -144,10 +173,9 @@ describe('Auth Helpers', () => { it('refreshes session and saves encrypted session', async () => { const user = { id: 'user_123' }; mockAuthContext = { - auth: () => ({ user, accessToken: 'old_token' }), + auth: () => ({ user, accessToken: 'old_token', refreshToken: 'refresh_token' }), request: new Request('http://test.local'), }; - mockAuthkit.getSession.mockResolvedValue({ refreshToken: 'refresh_token' }); mockAuthkit.refreshSession.mockResolvedValue({ auth: { user, accessToken: 'new_token', sessionId: 'session_123' }, encryptedSession: 'encrypted_data', @@ -171,10 +199,9 @@ describe('Auth Helpers', () => { it('does not save session when no encrypted data', async () => { const user = { id: 'user_123' }; mockAuthContext = { - auth: () => ({ user, accessToken: 'token' }), + auth: () => ({ user, accessToken: 'token', refreshToken: 'refresh_token' }), request: new Request('http://test.local'), }; - mockAuthkit.getSession.mockResolvedValue({ refreshToken: 'refresh_token' }); mockAuthkit.refreshSession.mockResolvedValue({ auth: { user }, encryptedSession: null, diff --git a/src/server/auth-helpers.ts b/src/server/auth-helpers.ts index 22bd62c..3ecd4dc 100644 --- a/src/server/auth-helpers.ts +++ b/src/server/auth-helpers.ts @@ -28,7 +28,9 @@ export function getRedirectUriFromContext(): string | undefined { } /** - * Gets the session with refresh token from the current request. + * Gets the session with refresh token from the auth context. + * Uses the middleware auth context which always has the latest refresh token, + * even if the middleware auto-refreshed during withAuth(). * Returns null if no valid session exists. */ export async function getSessionWithRefreshToken(): Promise<{ @@ -43,16 +45,17 @@ export async function getSessionWithRefreshToken(): Promise<{ return null; } - const ctx = getAuthKitContext(); - const authkit = await getAuthkit(); - const session = await authkit.getSession(ctx.request); - - if (!session?.refreshToken) { + // Use the refresh token from the auth context — it's always up-to-date. + // Previously we re-read the session from the original request, but if the + // middleware auto-refreshed (e.g., expired access token), the old refresh + // token in the request cookie would already be invalidated. + const refreshToken = 'refreshToken' in auth ? auth.refreshToken : undefined; + if (!refreshToken) { return null; } return { - refreshToken: session.refreshToken, + refreshToken, accessToken: auth.accessToken, user: auth.user, impersonator: auth.impersonator,