From 3babc5f1ac11c5dc40248b861d9a90ec6043488b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:32:49 +0000 Subject: [PATCH 1/5] Cache JWKS instance across verifyAccessToken calls The internal verifyAccessToken helper called createRemoteJWKSet from jose on every invocation, which creates a fresh JWKS instance and throws away jose's built-in per-instance key cache. This forces a network request to the JWKS endpoint for every token verification, which has been observed to take >3s under load. Lazily initialize the JWKS instance at module scope so it is created once and reused for the lifetime of the process. getWorkOS() and getConfig('clientId') are still resolved on first use (not at module load time), so consumer configuration remains respected. Co-Authored-By: jonah --- src/session.spec.ts | 66 +++++++++++++++++++++++++++++++++++++++++++++ src/session.ts | 11 +++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/session.spec.ts b/src/session.spec.ts index 1670c61..2e79a2d 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -819,6 +819,72 @@ describe('session', () => { }); }); + 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, + }; + + 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' }, + }); + // 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. + (jose.createRemoteJWKSet as jest.Mock).mockReturnValue(jest.fn()); + 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: [], + }); + }); + + it('creates the JWKS instance only once across multiple verifyAccessToken calls', async () => { + const createRemoteJWKSetMock = jose.createRemoteJWKSet as jest.Mock; + + // Prime the module-scoped cache, then clear mock state so that any + // subsequent call to createRemoteJWKSet would show up in the count. + await authkitLoader(createLoaderArgs(new Request('http://example.com/a', { headers: { Cookie: 'cookie' } }))); + createRemoteJWKSetMock.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(createRemoteJWKSetMock).not.toHaveBeenCalled(); + }); + }); + describe('saveSession', () => { const sessionData = { accessToken: 'new.valid.token', diff --git a/src/session.ts b/src/session.ts index ae34023..b947b4c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -595,8 +595,17 @@ export async function getSessionFromCookie(cookie: string, session?: SessionData } } +let cachedJWKS: ReturnType | undefined; + +function getJWKS(): ReturnType { + if (!cachedJWKS) { + cachedJWKS = createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(getConfig('clientId')))); + } + return cachedJWKS; +} + async function verifyAccessToken(accessToken: string) { - const JWKS = createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(getConfig('clientId')))); + const JWKS = getJWKS(); try { await jwtVerify(accessToken, JWKS); return true; From 0b3e8ecbfee32f2f1a0b1630ac24dd067e1122e0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:38:40 +0000 Subject: [PATCH 2/5] Invalidate JWKS cache when the JWKS URL changes Keep the lazy per-process cache, but also remember the URL it was built for. If getWorkOS()/getConfig('clientId') ever produce a different URL (e.g. a multi-tenant worker re-configures per request), discard the old instance and build a new one instead of silently serving stale keys. Add a test that exercises the URL-change path alongside the existing 'reuse on same URL' test. Co-Authored-By: jonah --- src/session.spec.ts | 38 +++++++++++++++++++++++++++++++++++--- src/session.ts | 7 +++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/session.spec.ts b/src/session.spec.ts index 2e79a2d..4063d3c 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -868,11 +868,13 @@ describe('session', () => { }); }); - it('creates the JWKS instance only once across multiple verifyAccessToken calls', async () => { + it('reuses the cached JWKS instance across multiple verifyAccessToken calls', async () => { const createRemoteJWKSetMock = jose.createRemoteJWKSet as jest.Mock; - // Prime the module-scoped cache, then clear mock state so that any - // subsequent call to createRemoteJWKSet would show up in the count. + // Ensure the module-scoped cache is populated (it may already be from an + // earlier test in this file; either way this call guarantees it). After + // this point, any subsequent verifyAccessToken must NOT invoke + // createRemoteJWKSet again for the same JWKS URL. await authkitLoader(createLoaderArgs(new Request('http://example.com/a', { headers: { Cookie: 'cookie' } }))); createRemoteJWKSetMock.mockClear(); @@ -883,6 +885,36 @@ describe('session', () => { expect(jwtVerify).toHaveBeenCalled(); expect(createRemoteJWKSetMock).not.toHaveBeenCalled(); }); + + it('rebuilds the JWKS instance when the JWKS URL changes', async () => { + const createRemoteJWKSetMock = jose.createRemoteJWKSet as jest.Mock; + const getJwksUrl = workos.userManagement.getJwksUrl as jest.Mock; + const originalImpl = getJwksUrl.getMockImplementation(); + + try { + // Populate the cache with the default URL. + await authkitLoader(createLoaderArgs(new Request('http://example.com/a', { headers: { Cookie: 'cookie' } }))); + createRemoteJWKSetMock.mockClear(); + + // Same URL → no rebuild. + await authkitLoader(createLoaderArgs(new Request('http://example.com/b', { headers: { Cookie: 'cookie' } }))); + expect(createRemoteJWKSetMock).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(createRemoteJWKSetMock).toHaveBeenCalledTimes(1); + + // Still the same new URL → still cached. + await authkitLoader(createLoaderArgs(new Request('http://example.com/d', { headers: { Cookie: 'cookie' } }))); + expect(createRemoteJWKSetMock).toHaveBeenCalledTimes(1); + } finally { + if (originalImpl) { + getJwksUrl.mockImplementation(originalImpl); + } + } + }); }); describe('saveSession', () => { diff --git a/src/session.ts b/src/session.ts index b947b4c..7e288c8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -596,10 +596,13 @@ export async function getSessionFromCookie(cookie: string, session?: SessionData } let cachedJWKS: ReturnType | undefined; +let cachedJWKSUrl: string | undefined; function getJWKS(): ReturnType { - if (!cachedJWKS) { - cachedJWKS = createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(getConfig('clientId')))); + const jwksUrl = getWorkOS().userManagement.getJwksUrl(getConfig('clientId')); + if (!cachedJWKS || cachedJWKSUrl !== jwksUrl) { + cachedJWKS = createRemoteJWKSet(new URL(jwksUrl)); + cachedJWKSUrl = jwksUrl; } return cachedJWKS; } From 57e213840ce47ddde0e11be8fa551c6a3605cfcf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:47:05 +0000 Subject: [PATCH 3/5] Isolate JWKS caching tests from other tests Load a fresh copy of ./session.js inside jest.isolateModules for each JWKS caching test so the module-scoped JWKS cache cannot leak across tests (or out of the describe block). This removes the need for the try/finally restore of the getJwksUrl mock. Co-Authored-By: jonah --- src/session.spec.ts | 159 +++++++++++++++++++++++++++----------------- 1 file changed, 97 insertions(+), 62 deletions(-) diff --git a/src/session.spec.ts b/src/session.spec.ts index 4063d3c..0c8c7fe 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -837,83 +837,118 @@ describe('session', () => { 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' }, - }); - // 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. - (jose.createRemoteJWKSet as jest.Mock).mockReturnValue(jest.fn()); - 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: [], + 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(() => { + /* eslint-disable @typescript-eslint/no-require-imports */ + const joseModule = require('jose') as typeof import('jose'); + const workosModule = require('./workos.js') as typeof import('./workos.js'); + const sessionStorageModule = require('./sessionStorage.js') as typeof import('./sessionStorage.js'); + const ironSessionModule = require('iron-session') as typeof import('iron-session'); + const sessionModule = require('./session.js') as typeof import('./session.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + + 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 createRemoteJWKSetMock = jose.createRemoteJWKSet as jest.Mock; + const { authkitLoader, createRemoteJWKSet, jwtVerify } = loadIsolated(); - // Ensure the module-scoped cache is populated (it may already be from an - // earlier test in this file; either way this call guarantees it). After - // this point, any subsequent verifyAccessToken must NOT invoke - // createRemoteJWKSet again for the same JWKS URL. + // Prime the module-scoped cache. await authkitLoader(createLoaderArgs(new Request('http://example.com/a', { headers: { Cookie: 'cookie' } }))); - createRemoteJWKSetMock.mockClear(); + 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(createRemoteJWKSetMock).not.toHaveBeenCalled(); + expect(createRemoteJWKSet).not.toHaveBeenCalled(); }); it('rebuilds the JWKS instance when the JWKS URL changes', async () => { - const createRemoteJWKSetMock = jose.createRemoteJWKSet as jest.Mock; - const getJwksUrl = workos.userManagement.getJwksUrl as jest.Mock; - const originalImpl = getJwksUrl.getMockImplementation(); + const { authkitLoader, createRemoteJWKSet, getJwksUrl } = loadIsolated(); - try { - // Populate the cache with the default URL. - await authkitLoader(createLoaderArgs(new Request('http://example.com/a', { headers: { Cookie: 'cookie' } }))); - createRemoteJWKSetMock.mockClear(); - - // Same URL → no rebuild. - await authkitLoader(createLoaderArgs(new Request('http://example.com/b', { headers: { Cookie: 'cookie' } }))); - expect(createRemoteJWKSetMock).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(createRemoteJWKSetMock).toHaveBeenCalledTimes(1); - - // Still the same new URL → still cached. - await authkitLoader(createLoaderArgs(new Request('http://example.com/d', { headers: { Cookie: 'cookie' } }))); - expect(createRemoteJWKSetMock).toHaveBeenCalledTimes(1); - } finally { - if (originalImpl) { - getJwksUrl.mockImplementation(originalImpl); - } - } + // 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); }); }); From 40b4dca18c09e2992ef50b7a612277e461d07c7e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:48:17 +0000 Subject: [PATCH 4/5] Silence CI lint rule for test-only requires Co-Authored-By: jonah --- src/session.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/session.spec.ts b/src/session.spec.ts index 0c8c7fe..6a2b095 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -851,13 +851,13 @@ describe('session', () => { function loadIsolated(): IsolatedModules { let isolated!: IsolatedModules; jest.isolateModules(() => { - /* eslint-disable @typescript-eslint/no-require-imports */ + /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ const joseModule = require('jose') as typeof import('jose'); const workosModule = require('./workos.js') as typeof import('./workos.js'); const sessionStorageModule = require('./sessionStorage.js') as typeof import('./sessionStorage.js'); const ironSessionModule = require('iron-session') as typeof import('iron-session'); const sessionModule = require('./session.js') as typeof import('./session.js'); - /* eslint-enable @typescript-eslint/no-require-imports */ + /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ const wos = workosModule.getWorkOS(); const getJwksUrlMock = wos.userManagement.getJwksUrl as jest.Mock; From 71ba2b2c36378fbd90d0f944e5264d90778dbace Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:39:01 +0000 Subject: [PATCH 5/5] Use createRequire for isolated test module loading Replaces the bare require() calls inside jest.isolateModules with a createRequire-bound loader so the eslint disables for @typescript-eslint/no-require-imports and no-var-requires are no longer needed. Co-Authored-By: jonah --- src/session.spec.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/session.spec.ts b/src/session.spec.ts index 6a2b095..4807662 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -1,3 +1,4 @@ +import { createRequire } from 'node:module'; import { LoaderFunctionArgs, Session as ReactRouterSession, redirect } from 'react-router'; import { AuthenticationResponse, type User } from '@workos-inc/node'; import * as ironSession from 'iron-session'; @@ -850,14 +851,17 @@ describe('session', () => { // 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(() => { - /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ - const joseModule = require('jose') as typeof import('jose'); - const workosModule = require('./workos.js') as typeof import('./workos.js'); - const sessionStorageModule = require('./sessionStorage.js') as typeof import('./sessionStorage.js'); - const ironSessionModule = require('iron-session') as typeof import('iron-session'); - const sessionModule = require('./session.js') as typeof import('./session.js'); - /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ + 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;