import { createRequire } from 'node:module'; import { LoaderFunctionArgs, Session as ReactRouterSession, redirect } from 'react-router'; import { AuthenticationResponse, type FeatureFlagsRuntimeClient, type User } from '@workos-inc/node'; import * as ironSession from 'iron-session'; import * as jose from 'jose'; import { configureSessionStorage as configureSessionStorageMock, getSessionStorage as getSessionStorageMock, } from './sessionStorage.js'; import { Session } from './interfaces.js'; import { authkitLoader, encryptSession, terminateSession, refreshSession, saveSession } from './session.js'; import { assertIsResponse } from './test-utils/test-helpers.js'; import { getWorkOS } from './workos.js'; import { getConfig } from './config.js'; import { getAuthorizationUrl } from './get-authorization-url.js'; jest.mock('./sessionStorage.js', () => ({ configureSessionStorage: jest.fn(), getSessionStorage: jest.fn(), })); jest.mock('./get-authorization-url.js', () => ({ getAuthorizationUrl: jest.fn(), })); // Mock dependencies const fakeWorkosInstance = { userManagement: { getAuthorizationUrl: jest.fn().mockResolvedValue('https://auth.workos.com/oauth/authorize'), getLogoutUrl: jest.fn(({ sessionId }) => `https://auth.workos.com/logout/${sessionId}`), getJwksUrl: jest.fn((clientId: string) => `https://auth.workos.com/oauth/jwks/${clientId}`), authenticateWithRefreshToken: jest.fn(), }, featureFlags: { createRuntimeClient: jest.fn(), }, }; jest.mock('./workos.js', () => ({ getWorkOS: jest.fn(() => fakeWorkosInstance), })); const workos = getWorkOS(); const unsealData = jest.mocked(ironSession.unsealData); const sealData = jest.mocked(ironSession.sealData); const getLogoutUrl = jest.mocked(workos.userManagement.getLogoutUrl); const authenticateWithRefreshToken = jest.mocked(workos.userManagement.authenticateWithRefreshToken); const getSessionStorage = jest.mocked(getSessionStorageMock); const configureSessionStorage = jest.mocked(configureSessionStorageMock); const jwtVerify = jest.mocked(jose.jwtVerify); const getAuthorizationUrlMock = jest.mocked(getAuthorizationUrl); function getHeaderValue(headers: HeadersInit | undefined, name: string): string | null { if (!headers) { return null; } if (headers instanceof Headers) { return headers.get(name); } if (Array.isArray(headers)) { const pair = headers.find(([key]) => key.toLowerCase() === name.toLowerCase()); return pair?.[1] ?? null; } return headers[name] ?? null; } jest.mock('jose', () => ({ createRemoteJWKSet: jest.fn(), jwtVerify: jest.fn(), decodeJwt: jest.fn(() => ({ sid: 'test-session-id', })), })); jest.mock('iron-session', () => ({ unsealData: jest.fn(), sealData: jest.fn(), })); describe('session', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const createMockSession = (overrides?: Record): ReactRouterSession => ({ has: jest.fn(), get: jest.fn(), set: jest.fn(), unset: jest.fn(), flash: jest.fn(), id: 'test-session-id', data: {}, ...overrides, }) satisfies ReactRouterSession; const createMockRequest = (cookie = 'test-cookie', url = 'http://example.com./some-path') => new Request(url, { headers: new Headers({ Cookie: cookie, }), }); let getSession: jest.Mock; let destroySession: jest.Mock; let commitSession: jest.Mock; beforeEach(async () => { getSession = jest.fn(); destroySession = jest.fn().mockResolvedValue('destroyed-session-cookie'); commitSession = jest.fn(); getSessionStorage.mockResolvedValue({ cookieName: 'wos-cookie', getSession, destroySession, commitSession, }); configureSessionStorage.mockResolvedValue({ cookieName: 'wos-cookie', getSession, destroySession, commitSession, }); // Reset getAuthorizationUrl mock getAuthorizationUrlMock.mockReset(); getAuthorizationUrlMock.mockResolvedValue({ url: 'https://auth.workos.com/oauth/authorize', headers: { 'Set-Cookie': 'wos-auth-verifier-default=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600' }, }); }); describe('encryptSession', () => { it('should encrypt session data with correct parameters', async () => { const mockSession = { accessToken: 'test-access-token', refreshToken: 'test-refresh-token', user: { object: 'user', id: 'test-user', email: 'test@example.com', emailVerified: true, profilePictureUrl: 'https://example.com/avatar.jpg', firstName: 'Test', lastName: 'User', externalId: null, createdAt: '2021-01-01T00:00:00Z', updatedAt: '2021-01-01T00:00:00Z', lastSignInAt: '2021-01-01T00:00:00Z', locale: null, metadata: {}, } satisfies User, impersonator: undefined, headers: {}, } satisfies Session; sealData.mockResolvedValueOnce('encrypted-data'); const result = await encryptSession(mockSession); expect(result).toBe('encrypted-data'); expect(sealData).toHaveBeenCalledWith(mockSession, { password: getConfig('cookiePassword'), ttl: 0, }); expect(sealData).toHaveBeenCalledTimes(1); }); }); describe('terminateSession', () => { const createMockRequest = (cookie = 'test-cookie', url = 'http://example.com./some-path') => new Request(url, { headers: new Headers({ Cookie: cookie, }), }); it('should redirect to root when session token has no sessionId', async () => { const mockSession = createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), }); getSession.mockResolvedValueOnce(mockSession); // Mock session data with a token that will decode to no sessionId const mockSessionData = { accessToken: 'token.without.sessionid', refreshToken: 'refresh-token', user: { id: 'user-id' }, impersonator: null, }; unsealData.mockResolvedValueOnce(mockSessionData); // Mock decodeJwt to return no sessionId (jose.decodeJwt as jest.Mock).mockReturnValueOnce({}); const response = await terminateSession(createMockRequest()); expect(response instanceof Response).toBe(true); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('/'); expect(response.headers.get('Set-Cookie')).toBe('destroyed-session-cookie'); expect(destroySession).toHaveBeenCalledWith(mockSession); expect(getLogoutUrl).not.toHaveBeenCalled(); }); it('Should redirect to the provided returnTo if no session exists', async () => { const mockSession = createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), }); getSession.mockResolvedValueOnce(mockSession); // Mock session data with a token that will decode to no sessionId const mockSessionData = { accessToken: 'token.without.sessionid', refreshToken: 'refresh-token', user: { id: 'user-id' }, impersonator: null, }; unsealData.mockResolvedValueOnce(mockSessionData); // Mock decodeJwt to return no sessionId (jose.decodeJwt as jest.Mock).mockReturnValueOnce({}); const response = await terminateSession(createMockRequest(), { returnTo: '/login' }); expect(response instanceof Response).toBe(true); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('/login'); expect(response.headers.get('Set-Cookie')).toBe('destroyed-session-cookie'); expect(destroySession).toHaveBeenCalledWith(mockSession); expect(getLogoutUrl).not.toHaveBeenCalled(); }); it('clears orphan wos-auth-verifier cookies from abandoned OAuth flows', async () => { const mockSession = createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), }); getSession.mockResolvedValueOnce(mockSession); unsealData.mockResolvedValueOnce({ accessToken: 'token.without.sessionid', refreshToken: 'refresh-token', user: { id: 'user-id' }, impersonator: null, }); (jose.decodeJwt as jest.Mock).mockReturnValueOnce({}); const request = createMockRequest( 'wos-session=value; wos-auth-verifier-aaaaaaaa=sealed1; wos-auth-verifier-bbbbbbbb=sealed2; other=ignored', ); const response = await terminateSession(request); const setCookies = response.headers.getSetCookie(); expect(setCookies).toContain('destroyed-session-cookie'); expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-aaaaaaaa=;') && /Max-Age=0/.test(c))).toBe(true); expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-bbbbbbbb=;') && /Max-Age=0/.test(c))).toBe(true); expect(setCookies.every((c) => !c.startsWith('other='))).toBe(true); }); it('emits no PKCE cleanup headers when no orphan cookies are present', async () => { const mockSession = createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), }); getSession.mockResolvedValueOnce(mockSession); unsealData.mockResolvedValueOnce({ accessToken: 'token.without.sessionid', refreshToken: 'refresh-token', user: { id: 'user-id' }, impersonator: null, }); (jose.decodeJwt as jest.Mock).mockReturnValueOnce({}); const response = await terminateSession(createMockRequest('wos-session=value; other=still-ignored')); const setCookies = response.headers.getSetCookie(); expect(setCookies).toEqual(['destroyed-session-cookie']); }); it('should redirect to WorkOS logout URL when valid session exists', async () => { // Setup a session with jwt const mockSession = createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), }); getSession.mockResolvedValueOnce(mockSession); // Mock the unsealed session data const mockSessionData = { accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiJ0ZXN0LXNlc3Npb24taWQifQ.signature', refreshToken: 'refresh-token', user: { id: 'user-id' }, impersonator: null, }; unsealData.mockResolvedValueOnce(mockSessionData); // Execute const response = await terminateSession(createMockRequest()); // Assert response is instance of Response expect(response instanceof Response).toBe(true); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('https://auth.workos.com/logout/test-session-id'); expect(response.headers.get('Set-Cookie')).toBe('destroyed-session-cookie'); expect(destroySession).toHaveBeenCalledWith(mockSession); expect(getLogoutUrl).toHaveBeenCalledWith({ sessionId: 'test-session-id', returnTo: undefined, }); expect(mockSession.has).toHaveBeenCalledWith('jwt'); expect(mockSession.get).toHaveBeenCalledWith('jwt'); }); }); describe('authkitLoader', () => { const createLoaderArgs = (request: Request): LoaderFunctionArgs => ({ request, params: {}, context: {}, }) as LoaderFunctionArgs; describe('unauthenticated flows', () => { beforeEach(() => { // Setup session without JWT const mockSession = createMockSession({ has: jest.fn().mockReturnValue(false), get: jest.fn(), }); getSession.mockResolvedValue(mockSession); unsealData.mockResolvedValue(null); }); it('should return unauthorized data when no session exists', async () => { const { data } = await authkitLoader(createLoaderArgs(createMockRequest())); expect(data).toEqual({ user: null, impersonator: null, organizationId: null, permissions: null, entitlements: null, featureFlags: null, role: null, roles: null, sessionId: null, }); }); it('should redirect to login when ensureSignedIn is true', async () => { try { await authkitLoader(createLoaderArgs(createMockRequest()), { ensureSignedIn: true }); fail('Expected redirect response to be thrown'); } catch (response: unknown) { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toMatch(/^https:\/\/auth\.workos\.com\/oauth/); const setCookies = response.headers.getSetCookie(); expect(setCookies).toContain('destroyed-session-cookie'); expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-'))).toBe(true); } }); it('should pass through loader redirects when returned', async () => { const redirectResponse = redirect('/dashboard', { headers: { 'X-Redirect-Reason': 'test' }, }); const customLoader = jest.fn().mockReturnValue(redirectResponse); try { await authkitLoader(createLoaderArgs(createMockRequest()), customLoader); } catch (response: unknown) { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toEqual('/dashboard'); expect(response.headers.get('X-Redirect-Reason')).toEqual('test'); } }); }); describe('authenticated flows', () => { const mockSessionData = { accessToken: 'valid.jwt.token', refreshToken: 'refresh.token', user: { id: 'user-1', email: 'test@example.com', }, impersonator: null, }; beforeEach(() => { const mockSession = createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), set: jest.fn(), }); getSession.mockResolvedValue(mockSession); unsealData.mockResolvedValue({ ...mockSessionData, headers: { 'Set-Cookie': 'session-cookie', }, }); jwtVerify.mockResolvedValue({ payload: {}, protectedHeader: {}, key: new TextEncoder().encode('test-key'), } as jose.JWTVerifyResult & jose.ResolvedKey); (jose.decodeJwt as jest.Mock).mockReturnValue({ sid: 'test-session-id', org_id: 'org-123', role: 'admin', roles: ['admin'], permissions: ['read', 'write'], entitlements: ['premium'], feature_flags: ['flag-1', 'flag-2'], }); }); it('should pass through non-JSON responses with just the cookie added', async () => { // Set up a custom loader that returns HTML const htmlContent = '

Hello World!

'; const customLoader = jest.fn().mockReturnValue( new Response(htmlContent, { headers: { 'Content-Type': 'text/html', 'X-Custom-Header': 'test-value', }, }), ); // Call authkitLoader with the HTML-returning loader const result = await authkitLoader(createLoaderArgs(createMockRequest()), customLoader); // Verify we got back a Response, not a DataWithResponseInit assertIsResponse(result); // Check that the response body wasn't modified const resultText = await result.clone().text(); expect(resultText).toBe(htmlContent); // Check that original headers were preserved expect(result.headers.get('Content-Type')).toBe('text/html'); expect(result.headers.get('X-Custom-Header')).toBe('test-value'); // Check that session cookie was added expect(result.headers.get('Set-Cookie')).toBe('session-cookie'); // Verify that the JSON parsing method was not called const jsonSpy = jest.spyOn(Response.prototype, 'json'); expect(jsonSpy).not.toHaveBeenCalled(); jsonSpy.mockRestore(); }); it('validates the access token issuer claim against https://api.workos.com by default', async () => { await authkitLoader(createLoaderArgs(createMockRequest())); expect(jwtVerify).toHaveBeenCalled(); for (const call of jwtVerify.mock.calls) { expect(call[0]).toBe('valid.jwt.token'); expect(call[2]).toEqual({ issuer: 'https://api.workos.com' }); } }); it('derives the expected issuer from apiHostname', async () => { jwtVerify.mockClear(); process.env.WORKOS_API_HOSTNAME = 'api.workos-test.com'; try { await authkitLoader(createLoaderArgs(createMockRequest())); } finally { delete process.env.WORKOS_API_HOSTNAME; } expect(jwtVerify).toHaveBeenCalled(); for (const call of jwtVerify.mock.calls) { expect(call[2]).toEqual({ issuer: 'https://api.workos-test.com' }); } }); it('derives an http issuer with a custom port from apiHttps and apiPort', async () => { jwtVerify.mockClear(); process.env.WORKOS_API_HOSTNAME = 'localhost'; process.env.WORKOS_API_HTTPS = 'false'; process.env.WORKOS_API_PORT = '7000'; try { await authkitLoader(createLoaderArgs(createMockRequest())); } finally { delete process.env.WORKOS_API_HOSTNAME; delete process.env.WORKOS_API_HTTPS; delete process.env.WORKOS_API_PORT; } expect(jwtVerify).toHaveBeenCalled(); for (const call of jwtVerify.mock.calls) { expect(call[2]).toEqual({ issuer: 'http://localhost:7000' }); } }); it('derives an https issuer with a custom port from apiPort', async () => { jwtVerify.mockClear(); process.env.WORKOS_API_PORT = '8443'; try { await authkitLoader(createLoaderArgs(createMockRequest())); } finally { delete process.env.WORKOS_API_PORT; } expect(jwtVerify).toHaveBeenCalled(); for (const call of jwtVerify.mock.calls) { expect(call[2]).toEqual({ issuer: 'https://api.workos.com:8443' }); } }); it('prefers an explicitly configured issuer over apiHostname', async () => { jwtVerify.mockClear(); process.env.WORKOS_API_HOSTNAME = 'api.workos-test.com'; process.env.WORKOS_ISSUER = 'https://auth.example.com'; try { await authkitLoader(createLoaderArgs(createMockRequest())); } finally { delete process.env.WORKOS_API_HOSTNAME; delete process.env.WORKOS_ISSUER; } expect(jwtVerify).toHaveBeenCalled(); for (const call of jwtVerify.mock.calls) { expect(call[2]).toEqual({ issuer: 'https://auth.example.com' }); } }); it('accepts a comma-separated list of issuers', async () => { jwtVerify.mockClear(); process.env.WORKOS_ISSUER = 'https://auth.example.com,https://api.workos.com/user_management/client_123'; try { await authkitLoader(createLoaderArgs(createMockRequest())); } finally { delete process.env.WORKOS_ISSUER; } expect(jwtVerify).toHaveBeenCalled(); for (const call of jwtVerify.mock.calls) { expect(call[2]).toEqual({ issuer: ['https://auth.example.com', 'https://api.workos.com/user_management/client_123'], }); } }); it('should return authorized data with session claims', async () => { const { data } = await authkitLoader(createLoaderArgs(createMockRequest())); expect(data).toEqual({ user: mockSessionData.user, impersonator: null, organizationId: 'org-123', permissions: ['read', 'write'], entitlements: ['premium'], featureFlags: ['flag-1', 'flag-2'], role: 'admin', roles: ['admin'], sessionId: 'test-session-id', }); }); it('should populate featureFlags from the runtime client when configured', async () => { const runtimeClient = { waitUntilReady: jest.fn().mockResolvedValue(undefined), getAllFlags: jest.fn().mockReturnValue({ 'runtime-flag': true, 'disabled-runtime-flag': false, }), } as unknown as FeatureFlagsRuntimeClient; const { data } = await authkitLoader(createLoaderArgs(createMockRequest()), { featureFlags: { runtimeClient, waitUntilReady: { timeoutMs: 100 }, }, }); expect(runtimeClient.waitUntilReady).toHaveBeenCalledWith({ timeoutMs: 100 }); expect(runtimeClient.getAllFlags).toHaveBeenCalledWith({ userId: mockSessionData.user.id, organizationId: 'org-123', }); expect(data).toEqual( expect.objectContaining({ featureFlags: ['runtime-flag'], }), ); }); it('should call onFeatureFlagsError and fall back when waitUntilReady fails', async () => { const error = new Error('runtime not ready'); const onFeatureFlagsError = jest.fn(); const request = createMockRequest(); const runtimeClient = { waitUntilReady: jest.fn().mockRejectedValue(error), getAllFlags: jest.fn(), } as unknown as FeatureFlagsRuntimeClient; const { data } = await authkitLoader(createLoaderArgs(request), { onFeatureFlagsError, featureFlags: { runtimeClient, waitUntilReady: true, }, }); expect(runtimeClient.waitUntilReady).toHaveBeenCalledWith(undefined); expect(runtimeClient.getAllFlags).not.toHaveBeenCalled(); expect(data).toEqual( expect.objectContaining({ featureFlags: ['flag-1', 'flag-2'], }), ); expect(onFeatureFlagsError).toHaveBeenCalledWith({ error, request, user: mockSessionData.user, organizationId: 'org-123', tokenFeatureFlags: ['flag-1', 'flag-2'], }); }); it('should call onFeatureFlagsError and fall back when getAllFlags fails', async () => { const error = new Error('runtime client closed'); const onFeatureFlagsError = jest.fn(); const request = createMockRequest(); const runtimeClient = { waitUntilReady: jest.fn().mockResolvedValue(undefined), getAllFlags: jest.fn().mockImplementation(() => { throw error; }), } as unknown as FeatureFlagsRuntimeClient; const { data } = await authkitLoader(createLoaderArgs(request), { onFeatureFlagsError, featureFlags: { runtimeClient, waitUntilReady: true, }, }); expect(runtimeClient.waitUntilReady).toHaveBeenCalledWith(undefined); expect(runtimeClient.getAllFlags).toHaveBeenCalledWith({ userId: mockSessionData.user.id, organizationId: 'org-123', }); expect(data).toEqual( expect.objectContaining({ featureFlags: ['flag-1', 'flag-2'], }), ); expect(onFeatureFlagsError).toHaveBeenCalledWith({ error, request, user: mockSessionData.user, organizationId: 'org-123', tokenFeatureFlags: ['flag-1', 'flag-2'], }); }); it('should log runtime evaluation failures when debug is enabled', async () => { const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const runtimeClient = { waitUntilReady: jest.fn().mockRejectedValue(new Error('runtime not ready')), getAllFlags: jest.fn(), } as unknown as FeatureFlagsRuntimeClient; await authkitLoader(createLoaderArgs(createMockRequest()), { debug: true, featureFlags: { runtimeClient, waitUntilReady: true, }, }); expect(warnSpy).toHaveBeenCalledWith( '[AuthKit] Failed to evaluate feature flags with the WorkOS runtime client. Falling back to access token feature flags.', expect.any(Error), ); logSpy.mockRestore(); warnSpy.mockRestore(); }); it('should handle custom loader data', async () => { const customLoader = jest.fn().mockReturnValue({ customData: 'test-value', metadata: { key: 'value' }, }); const { data } = await authkitLoader(createLoaderArgs(createMockRequest()), customLoader); expect(data).toEqual( expect.objectContaining({ customData: 'test-value', metadata: { key: 'value' }, user: mockSessionData.user, sessionId: 'test-session-id', }), ); }); it('should handle custom loader response with headers', async () => { const customLoader = jest.fn().mockReturnValue( new Response(JSON.stringify({ customData: 'test-value' }), { headers: { 'Custom-Header': 'test-header', 'Content-Type': 'application/json', }, }), ); const { data, init } = await authkitLoader(createLoaderArgs(createMockRequest()), customLoader); expect(getHeaderValue(init?.headers, 'Custom-Header')).toBe('test-header'); expect(getHeaderValue(init?.headers, 'Content-Type')).toBe('application/json'); expect(data).toEqual( expect.objectContaining({ customData: 'test-value', user: mockSessionData.user, }), ); }); it('should pass through loader redirects', async () => { const redirectResponse = redirect('/dashboard', { headers: { 'X-Redirect-Reason': 'test' }, }); const customLoader = jest.fn().mockImplementation(() => { throw redirectResponse; }); try { await authkitLoader(createLoaderArgs(createMockRequest()), customLoader); fail('Expected redirect response to be thrown'); } catch (response: unknown) { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('/dashboard'); expect(response.headers.get('X-Redirect-Reason')).toBe('test'); } }); it('should provide getAccessToken function to custom loader', async () => { const customLoader = jest.fn().mockImplementation(({ getAccessToken }) => { const token = getAccessToken(); return { retrievedToken: token }; }); const { data } = await authkitLoader(createLoaderArgs(createMockRequest()), customLoader); // Verify the loader was called with getAccessToken function expect(customLoader).toHaveBeenCalledWith( expect.objectContaining({ auth: expect.objectContaining({ user: mockSessionData.user, }), getAccessToken: expect.any(Function), }), ); // Verify the token was retrieved correctly expect(data).toEqual( expect.objectContaining({ retrievedToken: mockSessionData.accessToken, user: mockSessionData.user, }), ); }); it('should return null from getAccessToken for unauthenticated users', async () => { // Mock no session unsealData.mockResolvedValue(null); const customLoader = jest.fn().mockImplementation(({ getAccessToken }) => { const token = getAccessToken(); return { retrievedToken: token }; }); const { data } = await authkitLoader(createLoaderArgs(createMockRequest()), customLoader); // Verify getAccessToken returned null expect(data).toEqual( expect.objectContaining({ retrievedToken: null, user: null, }), ); }); }); describe('session refresh', () => { beforeEach(() => { // Setup session with expired token const mockSession = createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), set: jest.fn(), }); getSession.mockResolvedValue(mockSession); const expiredSessionData = { accessToken: 'expired.token', refreshToken: 'refresh.token', user: { id: 'user-1' }, impersonator: null, }; unsealData.mockResolvedValue(expiredSessionData); sealData.mockResolvedValue('new-encrypted-jwt'); commitSession.mockResolvedValue('new-session-cookie'); // Token verification fails jwtVerify.mockRejectedValue(new Error('Token expired')); // But refresh succeeds authenticateWithRefreshToken.mockResolvedValue({ accessToken: 'new.valid.token', refreshToken: 'new.refresh.token', } as AuthenticationResponse); // Mock different JWT decoding results for expired vs new token (jose.decodeJwt as jest.Mock).mockImplementation((token: string) => { if (token === 'expired.token') { return { sid: 'test-session-id', org_id: 'org-123', role: null, roles: [], permissions: [], entitlements: [], feature_flags: [], }; } if (token === 'new.valid.token') { return { sid: 'new-session-id', org_id: 'org-123', role: 'user', roles: ['user'], permissions: ['read'], entitlements: ['basic'], feature_flags: ['flag-1'], }; } return {}; // fallback }); }); it('should refresh session when access token is invalid', async () => { const { data, init } = await authkitLoader(createLoaderArgs(createMockRequest())); // Verify the refresh token flow was triggered expect(authenticateWithRefreshToken).toHaveBeenCalledWith({ clientId: expect.any(String), refreshToken: 'refresh.token', organizationId: 'org-123', }); // Verify the response contains the new token data expect(data).toEqual( expect.objectContaining({ sessionId: 'new-session-id', organizationId: 'org-123', role: 'user', roles: ['user'], permissions: ['read'], entitlements: ['basic'], featureFlags: ['flag-1'], }), ); // Verify cookie was set expect(getHeaderValue(init?.headers, 'Set-Cookie')).toBe('new-session-cookie'); }); it('should redirect to authorization URL preserving returnPathname when refresh fails', async () => { authenticateWithRefreshToken.mockRejectedValue(new Error('Refresh token invalid')); // Setup the mock to return a URL with state parameter getAuthorizationUrlMock.mockResolvedValue({ url: 'https://auth.workos.com/oauth/authorize?state=abc123', headers: { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600' }, }); try { const mockRequest = createMockRequest('test-cookie', 'https://app.example.com/dashboard/settings'); await authkitLoader(createLoaderArgs(mockRequest)); fail('Expected redirect response to be thrown'); } catch (response: unknown) { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('https://auth.workos.com/oauth/authorize?state=abc123'); // The destroy cookie and the new PKCE cookie must both be present const setCookies = response.headers.getSetCookie(); expect(setCookies).toContain('destroyed-session-cookie'); expect(setCookies).toContain('wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600'); // Verify getAuthorizationUrl was called with the correct returnPathname // and the request is threaded through for Secure-attribute detection. expect(getAuthorizationUrlMock).toHaveBeenCalledWith( expect.objectContaining({ returnPathname: '/dashboard/settings', request: expect.any(Request), }), ); } }); it.each([ ['a rate limit (429)', Object.assign(new Error('Too many requests'), { status: 429 })], ['a server error (503)', Object.assign(new Error('Service unavailable'), { status: 503 })], ['a request timeout (408)', Object.assign(new Error('Request timeout'), { status: 408 })], ['a network error', new TypeError('fetch failed')], // The SDK re-wraps a raw network TypeError in a plain Error with the // TypeError as its cause; the classifier must follow the cause chain. [ 'an SDK-wrapped network error', new Error('Unexpected error: TypeError: fetch failed', { cause: new TypeError('fetch failed') }), ], ])('should preserve the session cookie when refresh fails transiently: %s', async (_label, transientError) => { authenticateWithRefreshToken.mockRejectedValue(transientError); getAuthorizationUrlMock.mockResolvedValue({ url: 'https://auth.workos.com/oauth/authorize?state=abc123', headers: { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600' }, }); try { await authkitLoader(createLoaderArgs(createMockRequest())); fail('Expected redirect response to be thrown'); } catch (response: unknown) { assertIsResponse(response); expect(response.status).toBe(302); // The sealed session must not be destroyed on a transient failure. expect(destroySession).not.toHaveBeenCalled(); const setCookies = response.headers.getSetCookie(); expect(setCookies).not.toContain('destroyed-session-cookie'); expect(setCookies).toContain('wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600'); } }); it('should destroy the session for a terminal status even if its cause chain looks network-like', async () => { // A terminal HTTP status must win over the network-cause fallback: a // 400 that happens to wrap a "fetch failed" TypeError is still terminal. authenticateWithRefreshToken.mockRejectedValue( Object.assign(new Error('invalid_grant', { cause: new TypeError('fetch failed') }), { status: 400, error: 'invalid_grant', }), ); getAuthorizationUrlMock.mockResolvedValue({ url: 'https://auth.workos.com/oauth/authorize?state=abc123', headers: { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600' }, }); try { await authkitLoader(createLoaderArgs(createMockRequest())); fail('Expected redirect response to be thrown'); } catch (response: unknown) { assertIsResponse(response); expect(response.status).toBe(302); expect(destroySession).toHaveBeenCalled(); expect(response.headers.getSetCookie()).toContain('destroyed-session-cookie'); } }); it('should destroy the session for a terminal refresh failure (invalid_grant)', async () => { authenticateWithRefreshToken.mockRejectedValue( Object.assign(new Error('invalid_grant'), { status: 400, error: 'invalid_grant' }), ); getAuthorizationUrlMock.mockResolvedValue({ url: 'https://auth.workos.com/oauth/authorize?state=abc123', headers: { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600' }, }); try { await authkitLoader(createLoaderArgs(createMockRequest())); fail('Expected redirect response to be thrown'); } catch (response: unknown) { assertIsResponse(response); expect(response.status).toBe(302); expect(destroySession).toHaveBeenCalled(); expect(response.headers.getSetCookie()).toContain('destroyed-session-cookie'); } }); it('calls onSessionRefreshSuccess when provided', async () => { const onSessionRefreshSuccess = jest.fn(); await authkitLoader(createLoaderArgs(createMockRequest()), { onSessionRefreshSuccess, }); expect(onSessionRefreshSuccess).toHaveBeenCalled(); }); it('calls onSessionRefreshError when provided and refresh fails', async () => { authenticateWithRefreshToken.mockRejectedValue(new Error('Refresh token invalid')); const onSessionRefreshError = jest.fn().mockReturnValue(redirect('/error')); await authkitLoader(createLoaderArgs(createMockRequest()), { onSessionRefreshError, }); expect(onSessionRefreshError).toHaveBeenCalled(); }); it('passes isTransient: true to onSessionRefreshError for a transient failure', async () => { authenticateWithRefreshToken.mockRejectedValue( Object.assign(new Error('Service unavailable'), { status: 503 }), ); const onSessionRefreshError = jest.fn().mockReturnValue(redirect('/error')); await authkitLoader(createLoaderArgs(createMockRequest()), { onSessionRefreshError, }); expect(onSessionRefreshError).toHaveBeenCalledWith(expect.objectContaining({ isTransient: true })); }); it('passes isTransient: false to onSessionRefreshError for a terminal failure', async () => { authenticateWithRefreshToken.mockRejectedValue(Object.assign(new Error('invalid_grant'), { status: 400 })); const onSessionRefreshError = jest.fn().mockReturnValue(redirect('/error')); await authkitLoader(createLoaderArgs(createMockRequest()), { onSessionRefreshError, }); expect(onSessionRefreshError).toHaveBeenCalledWith(expect.objectContaining({ isTransient: false })); }); it('allows redirect from onSessionRefreshError callback', async () => { authenticateWithRefreshToken.mockRejectedValue(new Error('Refresh token invalid')); try { await authkitLoader(createLoaderArgs(createMockRequest()), { onSessionRefreshError: () => { throw redirect('/'); }, }); fail('Expected redirect response to be thrown'); } catch (response: unknown) { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('/'); } }); }); }); describe('refreshSession', () => { const createMockRequest = (cookie = 'test-cookie', url = 'http://example.com./some-path') => new Request(url, { headers: new Headers({ Cookie: cookie, }), }); let getSession: jest.Mock; let destroySession: jest.Mock; let commitSession: jest.Mock; let mockSession: ReactRouterSession; beforeEach(() => { getSession = jest.fn(); destroySession = jest.fn().mockResolvedValue('destroyed-session-cookie'); commitSession = jest.fn().mockResolvedValue('new-session-cookie'); mockSession = createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), set: jest.fn(), }); getSessionStorage.mockResolvedValue({ cookieName: 'wos-cookie', getSession, destroySession, commitSession, }); getSession.mockResolvedValue(mockSession); const validSessionData = { accessToken: 'valid.token', refreshToken: 'refresh.token', user: { id: 'user-1', email: 'test@example.com', firstName: 'Test', lastName: 'User', object: 'user', }, impersonator: null, }; unsealData.mockResolvedValue(validSessionData); sealData.mockResolvedValue('new-encrypted-jwt'); authenticateWithRefreshToken.mockResolvedValue({ accessToken: 'new.valid.token', refreshToken: 'new.refresh.token', user: { object: 'user', id: 'user-1', email: 'test@example.com', emailVerified: true, profilePictureUrl: null, firstName: 'Test', lastName: 'User', lastSignInAt: '2021-01-01T00:00:00Z', createdAt: '2021-01-01T00:00:00Z', updatedAt: '2021-01-01T00:00:00Z', externalId: null, }, impersonator: undefined, } as AuthenticationResponse); // Mock JWT decoding (jose.decodeJwt as jest.Mock).mockReturnValue({ sid: 'new-session-id', org_id: 'org-123', role: 'user', roles: ['user'], permissions: ['read'], entitlements: ['basic'], feature_flags: ['flag-1'], }); }); it('should refresh the session successfully', async () => { const refreshedSession = await refreshSession(createMockRequest()); expect(getSessionStorage).toHaveBeenCalled(); expect(authenticateWithRefreshToken).toHaveBeenCalledWith({ clientId: expect.any(String), refreshToken: 'refresh.token', organizationId: undefined, }); expect(mockSession.set).toHaveBeenCalledWith('jwt', 'new-encrypted-jwt'); expect(commitSession).toHaveBeenCalledWith(mockSession); expect(refreshedSession).toEqual({ user: expect.objectContaining({ id: 'user-1' }), sessionId: 'new-session-id', accessToken: 'new.valid.token', organizationId: 'org-123', role: 'user', roles: ['user'], permissions: ['read'], entitlements: ['basic'], featureFlags: ['flag-1'], impersonator: null, sealedSession: 'encrypted-jwt', headers: { 'Set-Cookie': 'new-session-cookie', }, }); }); it('should refresh the session with organizationId', async () => { await refreshSession(createMockRequest(), { organizationId: 'org-456' }); expect(authenticateWithRefreshToken).toHaveBeenCalledWith({ clientId: expect.any(String), refreshToken: 'refresh.token', organizationId: 'org-456', }); }); it('should redirect to sign-in when no session exists', async () => { // Mock no session found unsealData.mockResolvedValue(null); try { await refreshSession(createMockRequest()); fail('Expected redirect response to be thrown'); } catch (response: unknown) { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toMatch(/^https:\/\/auth\.workos\.com\/oauth/); } }); it('should throw error when refresh fails', async () => { // Mock refresh token failure authenticateWithRefreshToken.mockRejectedValue(new Error('Invalid refresh token')); await expect(refreshSession(createMockRequest())).rejects.toThrow( 'Failed to refresh session: Invalid refresh token', ); }); }); describe('JWKS caching', () => { const createLoaderArgs = (request: Request): LoaderFunctionArgs => ({ request, params: {}, context: {}, }) as LoaderFunctionArgs; const mockSessionData = { accessToken: 'valid.jwt.token', refreshToken: 'refresh.token', user: { id: 'user-1', email: 'test@example.com', }, impersonator: null, }; type IsolatedModules = { authkitLoader: typeof authkitLoader; createRemoteJWKSet: jest.Mock; jwtVerify: jest.Mock; getJwksUrl: jest.Mock; }; // Each test gets its own freshly-loaded copy of ./session.js so the // module-level JWKS cache never leaks across tests (or out of this // describe block). This guards against subtle ordering bugs where a later // test would depend on cache state set up here. function loadIsolated(): IsolatedModules { let isolated!: IsolatedModules; // jest.isolateModules only scopes synchronous CJS require() calls; a // dynamic `await import()` would need --experimental-vm-modules. Use // createRequire to avoid a bare `require` keyword in source while still // participating in Jest's module loader hooks. const isolatedRequire = createRequire(__filename); jest.isolateModules(() => { const joseModule = isolatedRequire('jose') as typeof import('jose'); const workosModule = isolatedRequire('./workos.js') as typeof import('./workos.js'); const sessionStorageModule = isolatedRequire('./sessionStorage.js') as typeof import('./sessionStorage.js'); const ironSessionModule = isolatedRequire('iron-session') as typeof import('iron-session'); const sessionModule = isolatedRequire('./session.js') as typeof import('./session.js'); const wos = workosModule.getWorkOS(); const getJwksUrlMock = wos.userManagement.getJwksUrl as jest.Mock; const createRemoteJWKSetMock = joseModule.createRemoteJWKSet as jest.Mock; const jwtVerifyMock = joseModule.jwtVerify as jest.Mock; const decodeJwtMock = joseModule.decodeJwt as jest.Mock; const getSessionStorageMock = sessionStorageModule.getSessionStorage as jest.Mock; const unsealDataMock = ironSessionModule.unsealData as jest.Mock; const isolatedGetSession = jest.fn().mockResolvedValue( createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), set: jest.fn(), }), ); getSessionStorageMock.mockResolvedValue({ cookieName: 'wos-cookie', getSession: isolatedGetSession, destroySession: jest.fn().mockResolvedValue('destroyed-session-cookie'), commitSession: jest.fn(), }); unsealDataMock.mockResolvedValue({ ...mockSessionData, headers: { 'Set-Cookie': 'session-cookie' }, }); getJwksUrlMock.mockImplementation((clientId: string) => `https://auth.workos.com/oauth/jwks/${clientId}`); // Real createRemoteJWKSet returns a getKey function used by jwtVerify. // The mock needs to return a truthy value so the module-level cache // check in session.ts treats it as populated. createRemoteJWKSetMock.mockReturnValue(jest.fn()); jwtVerifyMock.mockResolvedValue({ payload: {}, protectedHeader: {}, key: new TextEncoder().encode('test-key'), }); decodeJwtMock.mockReturnValue({ sid: 'test-session-id', org_id: 'org-123', role: 'admin', roles: ['admin'], permissions: ['read', 'write'], entitlements: ['premium'], feature_flags: [], }); isolated = { authkitLoader: sessionModule.authkitLoader, createRemoteJWKSet: createRemoteJWKSetMock, jwtVerify: jwtVerifyMock, getJwksUrl: getJwksUrlMock, }; }); return isolated; } it('reuses the cached JWKS instance across multiple verifyAccessToken calls', async () => { const { authkitLoader, createRemoteJWKSet, jwtVerify } = loadIsolated(); // Prime the module-scoped cache. await authkitLoader(createLoaderArgs(new Request('http://example.com/a', { headers: { Cookie: 'cookie' } }))); createRemoteJWKSet.mockClear(); await authkitLoader(createLoaderArgs(new Request('http://example.com/b', { headers: { Cookie: 'cookie' } }))); await authkitLoader(createLoaderArgs(new Request('http://example.com/c', { headers: { Cookie: 'cookie' } }))); await authkitLoader(createLoaderArgs(new Request('http://example.com/d', { headers: { Cookie: 'cookie' } }))); expect(jwtVerify).toHaveBeenCalled(); expect(createRemoteJWKSet).not.toHaveBeenCalled(); }); it('rebuilds the JWKS instance when the JWKS URL changes', async () => { const { authkitLoader, createRemoteJWKSet, getJwksUrl } = loadIsolated(); // Populate the cache with the default URL. await authkitLoader(createLoaderArgs(new Request('http://example.com/a', { headers: { Cookie: 'cookie' } }))); createRemoteJWKSet.mockClear(); // Same URL → no rebuild. await authkitLoader(createLoaderArgs(new Request('http://example.com/b', { headers: { Cookie: 'cookie' } }))); expect(createRemoteJWKSet).not.toHaveBeenCalled(); // URL changes (e.g. consumer re-configures with a different clientId) → // the cache must be invalidated and a new JWKS instance created. getJwksUrl.mockImplementation(() => 'https://auth.workos.com/oauth/jwks/other-client'); await authkitLoader(createLoaderArgs(new Request('http://example.com/c', { headers: { Cookie: 'cookie' } }))); expect(createRemoteJWKSet).toHaveBeenCalledTimes(1); // Still the same new URL → still cached. await authkitLoader(createLoaderArgs(new Request('http://example.com/d', { headers: { Cookie: 'cookie' } }))); expect(createRemoteJWKSet).toHaveBeenCalledTimes(1); }); }); describe('saveSession', () => { const sessionData = { accessToken: 'new.valid.token', refreshToken: 'new.refresh.token', user: { object: 'user', id: 'user-1', email: 'test@example.com', emailVerified: true, profilePictureUrl: null, firstName: 'Test', lastName: 'User', lastSignInAt: '2021-01-01T00:00:00Z', createdAt: '2021-01-01T00:00:00Z', updatedAt: '2021-01-01T00:00:00Z', externalId: null, locale: null, metadata: {}, } satisfies User, impersonator: undefined, headers: {}, } satisfies Session; const createMockRequest = (cookie = 'test-cookie', url = 'http://example.com./some-path') => new Request(url, { headers: new Headers({ Cookie: cookie, }), }); let getSession: jest.Mock; let destroySession: jest.Mock; let commitSession: jest.Mock; let mockSession: ReactRouterSession; beforeEach(() => { getSession = jest.fn(); destroySession = jest.fn().mockResolvedValue('destroyed-session-cookie'); commitSession = jest.fn().mockResolvedValue('new-session-cookie'); mockSession = createMockSession({ has: jest.fn().mockReturnValue(true), get: jest.fn().mockReturnValue('encrypted-jwt'), set: jest.fn(), }); getSessionStorage.mockResolvedValue({ cookieName: 'wos-cookie', getSession, destroySession, commitSession, }); getSession.mockResolvedValue(mockSession); const validSessionData = { accessToken: 'valid.token', refreshToken: 'refresh.token', user: { id: 'user-1', email: 'test@example.com', firstName: 'Test', lastName: 'User', object: 'user', }, impersonator: null, }; unsealData.mockResolvedValue(validSessionData); sealData.mockResolvedValue('new-encrypted-jwt'); authenticateWithRefreshToken.mockResolvedValue(sessionData); // Mock JWT decoding (jose.decodeJwt as jest.Mock).mockReturnValue({ sid: 'new-session-id', org_id: 'org-123', role: 'user', roles: ['user'], permissions: ['read'], entitlements: ['basic'], feature_flags: ['flag-1'], }); }); it('should save the session to the cookie', async () => { await saveSession(sessionData, createMockRequest()); expect(getSessionStorage).toHaveBeenCalled(); expect(commitSession).toHaveBeenCalledWith(mockSession); expect(mockSession.set).toHaveBeenCalledWith('jwt', 'new-encrypted-jwt'); }); }); });