Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
137 changes: 137 additions & 0 deletions src/session.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -819,6 +820,142 @@ 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 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',
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);
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.

P1 security Missing iss and aud claim validation in jwtVerify

jwtVerify is called without issuer or audience options, so the JWT's iss and aud claims go unchecked. A validly-signed WorkOS JWT issued for a different client ID (aud) or by a different issuer (iss) — e.g., a token from a different WorkOS environment — would pass this check and authenticate the request. The team's JWT rule requires both claims to be validated.

Note: The exact issuer string should match what WorkOS sets in the iss claim — verify against the WorkOS docs or a decoded token. At minimum, pass audience: getConfig('clientId') to bind the token to the configured client.

Rule Used: JWTs should always be validated before use and the... (source)

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.

Thanks — this is a real gap but it's pre-existing on main (the original jwtVerify(accessToken, JWKS) call had no issuer/audience either), and fixing it is out of scope for a caching-only change. The validation requirements (what to pass for issuer, how to handle multi-environment aud, rollout considerations) deserve their own PR and tests. Will flag to the user to track separately.

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.

Bots, I hear you. But since it's flagged, and since it's security related... it would be more than nice if this validation occurred. 😅

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.

Confirmed with Jonah that we're keeping this PR caching-only — none of the other WorkOS SDKs validate iss/aud on access tokens today, so adding it here alone would be an inconsistent one-off. If/when we do that work, it should go across SDKs together (and needs decisions on the exact iss string WorkOS stamps and how to handle tokens from other environments).

Happy to file a cross-SDK tracking issue if useful — just let me know.

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, sure, turn it into an issue.

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.

Filed: #69 — scoped it as a cross-SDK tracking issue (since the same gap exists in authkit-nextjs / authkit-remix / authkit-astro and needs a coordinated rollout + a pinned-down iss string). This PR stays caching-only.

return true;
Expand Down