Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions src/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,139 @@ describe('session', () => {
});
});

describe('JWKS caching', () => {
const createLoaderArgs = (request: Request): LoaderFunctionArgs =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since we're dealing with a cache, and the cache is at the module-level, I think we should sandbox these tests within jest.isolateModules() to ensure that anything going on here doesn't affect any other tests. It would be an unlikely ordering bug should such an issue occur, but a subtle and annoying one, to be sure.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done in 57e2138 — each test now loads a fresh copy of ./session.js via jest.isolateModules(...) and re-wires the mocks on the isolated jose/workos/sessionStorage/iron-session. That also kills the module-scoped JWKS cache at the end of every test, so no state leaks out of this describe.

({
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(() => {
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can the code be written such that these disables are not necessary?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done in 71ba2b2 — swapped the bare require() calls for a createRequire(__filename)-bound loader so the eslint disables are gone. Tests still pass and npm run lint is clean locally.

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 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',
Expand Down
14 changes: 13 additions & 1 deletion src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,20 @@ export async function getSessionFromCookie(cookie: string, session?: SessionData
}
}

let cachedJWKS: ReturnType<typeof createRemoteJWKSet> | undefined;
let cachedJWKSUrl: string | undefined;

function getJWKS(): ReturnType<typeof createRemoteJWKSet> {
const jwksUrl = getWorkOS().userManagement.getJwksUrl(getConfig('clientId'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since getJwksUrl issues an API call, I think it would make sense, if possible, to check that getConfig('clientId') is in the cache. As in, if getConfig('clientId')'s JWKS URL is fetched once, don't fetch it again. I guess it depends on how frequently one's JWKS URL would change -- I'm not sure if this is a real problem or not, but it does seem expensive to hit an endpoint every time you're investigating a cache.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good instinct to check, but getJwksUrl is pure string formatting — no API call. In @workos-inc/node:

getJwksUrl(clientId) {
  if (!clientId) {
    throw TypeError('clientId must be a valid clientId');
  }
  return `${this.workos.baseURL}/sso/jwks/${clientId}`;
}

So the "fetch once per clientId" goal is already what this cache achieves: same clientId → same URL → cache hit; no network. The only thing we re-invoke each call is a template-literal concatenation, which is effectively free vs. the actual JWKS round-trip this PR is eliminating.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ok, sounds good! Thanks clanker.

if (!cachedJWKS || cachedJWKSUrl !== jwksUrl) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In src/session.ts:603, cachedJWKS and cachedJWKSUrl are module-level let bindings — in environments that share a module instance across concurrent requests (Node.js workers, edge runtimes), what happens if two requests race through the if (!cachedJWKS || cachedJWKSUrl !== jwksUrl) check simultaneously with different URLs? Could both write cachedJWKS and cachedJWKSUrl non-atomically, leaving the URL and instance out of sync?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good question, but getJWKS() is fully synchronous — there's no await between the check and the two assignments — and both Node.js workers and edge runtimes (CF Workers, Vercel Edge, etc.) run JS on a single-threaded event loop. The function runs to completion atomically from the loop's perspective, so two concurrent verifyAccessToken calls cannot interleave between the if and the writes, and cachedJWKS/cachedJWKSUrl cannot end up out of sync.

Within a single process the failure mode is the trivial one: if two calls genuinely arrive in the same tick before either has populated the cache (only possible if a prior microtask scheduled them both), each would compute jwksUrl, both would see !cachedJWKS, and the second would just overwrite the first with an equivalent instance — wasted construction, never inconsistent state.

True parallelism only shows up across separate workers / isolates, and those don't share module state at all — each gets its own cachedJWKS, which is the intended behavior (one JWKS instance per process, with jose's built-in key cache per instance).

cachedJWKS = createRemoteJWKSet(new URL(jwksUrl));
cachedJWKSUrl = jwksUrl;
}
return cachedJWKS;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

async function verifyAccessToken(accessToken: string) {
const JWKS = createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(getConfig('clientId'))));
const JWKS = getJWKS();
try {
await jwtVerify(accessToken, JWKS);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
return true;
Expand Down