diff --git a/.changeset/fresh-tigers-hunt.md b/.changeset/fresh-tigers-hunt.md new file mode 100644 index 00000000000..0e799b98cc4 --- /dev/null +++ b/.changeset/fresh-tigers-hunt.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add stale-while-revalidate support for session tokens diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index cbc3539426c..250c4b24f71 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,8 +1,8 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "538KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "63KB" }, - { "path": "./dist/clerk.chips.browser.js", "maxSize": "63KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "63.5KB" }, + { "path": "./dist/clerk.chips.browser.js", "maxSize": "63.5KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "105KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "65KB" }, diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index a5d7f892bb8..eec3d16ce04 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -206,9 +206,9 @@ describe('SessionTokenCache', () => { } as MessageEvent; broadcastListener(newerEvent); - const cachedEntryAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntryAfterNewer).toBeDefined(); - const newerCreatedAt = cachedEntryAfterNewer?.createdAt; + const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(resultAfterNewer).toBeDefined(); + const newerCreatedAt = resultAfterNewer?.entry.createdAt; // mockJwt has iat: 1666648250, so create an older one with iat: 1666648190 (60 seconds earlier) const olderJwt = @@ -226,9 +226,9 @@ describe('SessionTokenCache', () => { broadcastListener(olderEvent); - const cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntryAfterOlder).toBeDefined(); - expect(cachedEntryAfterOlder?.createdAt).toBe(newerCreatedAt); + const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(resultAfterOlder).toBeDefined(); + expect(resultAfterOlder?.entry.createdAt).toBe(newerCreatedAt); }); it('successfully updates cache with valid token', () => { @@ -245,9 +245,9 @@ describe('SessionTokenCache', () => { broadcastListener(event); - const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntry).toBeDefined(); - expect(cachedEntry?.tokenId).toBe('session_123'); + const result = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('session_123'); }); it('does not re-broadcast when receiving a broadcast message', async () => { @@ -271,8 +271,8 @@ describe('SessionTokenCache', () => { await Promise.resolve(); // Verify cache was updated - const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntry).toBeDefined(); + const result = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(result).toBeDefined(); // Critical: postMessage should NOT be called when handling a broadcast expect(mockBroadcastChannel.postMessage).not.toHaveBeenCalled(); @@ -331,9 +331,10 @@ describe('SessionTokenCache', () => { // Wait for promise to resolve await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'future_token' }); - expect(cachedEntry).toBeDefined(); - expect(cachedEntry?.tokenId).toBe('future_token'); + const result = SessionTokenCache.get({ tokenId: 'future_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('future_token'); + expect(result?.needsRefresh).toBe(false); }); it('removes token when it has already expired based on duration', async () => { @@ -351,11 +352,11 @@ describe('SessionTokenCache', () => { await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'expired_token' }); - expect(cachedEntry).toBeUndefined(); + const result = SessionTokenCache.get({ tokenId: 'expired_token' }); + expect(result).toBeUndefined(); }); - it('removes token when it expires within the leeway threshold', async () => { + it('returns token with needsRefresh when remaining TTL is less than leeway (SWR)', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const iat = nowSeconds; const exp = iat + 20; @@ -366,12 +367,16 @@ describe('SessionTokenCache', () => { jwt: { claims: { exp, iat } }, } as any); - SessionTokenCache.set({ createdAt: nowSeconds - 13, tokenId: 'soon_expired_token', tokenResolver }); + // Token has 20s TTL, created 11s ago = 9s remaining (< 10s default leeway) + SessionTokenCache.set({ createdAt: nowSeconds - 11, tokenId: 'soon_expired_token', tokenResolver }); await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); - expect(cachedEntry).toBeUndefined(); + // SWR: Token is still valid (9s > 0), so it should be returned with needsRefresh=true + const result = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('soon_expired_token'); + expect(result?.needsRefresh).toBe(true); }); it('returns token when expiresAt is undefined (promise not yet resolved)', () => { @@ -380,9 +385,9 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId: 'pending_token', tokenResolver: pendingTokenResolver }); - const cachedEntry = SessionTokenCache.get({ tokenId: 'pending_token' }); - expect(cachedEntry).toBeDefined(); - expect(cachedEntry?.tokenId).toBe('pending_token'); + const result = SessionTokenCache.get({ tokenId: 'pending_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('pending_token'); }); }); @@ -471,7 +476,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); expect(SessionTokenCache.size()).toBe(1); SessionTokenCache.clear(); @@ -512,56 +517,142 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); - const cachedWhilePending = SessionTokenCache.get(key); - expect(cachedWhilePending).toBeDefined(); - expect(cachedWhilePending?.tokenId).toBe('lifecycle-token'); + const resultWhilePending = SessionTokenCache.get(key); + expect(resultWhilePending).toBeDefined(); + expect(resultWhilePending?.entry.tokenId).toBe('lifecycle-token'); expect(isResolved).toBe(false); vi.advanceTimersByTime(100); await tokenResolver; - const cachedAfterResolved = SessionTokenCache.get(key); + const resultAfterResolved = SessionTokenCache.get(key); expect(isResolved).toBe(true); - expect(cachedAfterResolved).toBeDefined(); - expect(cachedAfterResolved?.tokenId).toBe('lifecycle-token'); + expect(resultAfterResolved).toBeDefined(); + expect(resultAfterResolved?.entry.tokenId).toBe('lifecycle-token'); vi.advanceTimersByTime(60 * 1000); - const cachedAfterExpiration = SessionTokenCache.get(key); - expect(cachedAfterExpiration).toBeUndefined(); + const resultAfterExpiration = SessionTokenCache.get(key); + expect(resultAfterExpiration).toBeUndefined(); }); }); - describe('leeway precision', () => { - it('includes 5 second sync leeway on top of default 10 second leeway', async () => { + describe('SWR leeway behavior', () => { + it('returns needsRefresh=false when token has plenty of time remaining', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'leeway-token', + id: 'fresh-token', jwt, object: 'token', }); const tokenResolver = Promise.resolve(token); - const key = { audience: 'leeway-test', tokenId: 'leeway-token' }; + const key = { audience: 'fresh-test', tokenId: 'fresh-token' }; SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toMatchObject({ tokenId: 'leeway-token' }); + // Token just created, 60s remaining - should return needsRefresh=false + const result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('fresh-token'); + expect(result?.needsRefresh).toBe(false); + }); + + it('returns needsRefresh=true when token is within default leeway (SWR)', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'expiring-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { audience: 'expiring-test', tokenId: 'expiring-token' }; + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // At 44s elapsed, 16s remaining - fresh, no refresh needed (> 15s MIN_REMAINING_TTL) vi.advanceTimersByTime(44 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('expiring-token'); + expect(result?.needsRefresh).toBe(false); - vi.advanceTimersByTime(1 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + // At 46s elapsed, 14s remaining (< 15s MIN_REMAINING_TTL) - SWR: return token with needsRefresh=true + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('expiring-token'); + expect(result?.needsRefresh).toBe(true); + + // At 60s elapsed, 0s remaining - token actually expired, return undefined + vi.advanceTimersByTime(14 * 1000); + result = SessionTokenCache.get(key); + expect(result).toBeUndefined(); + }); - vi.advanceTimersByTime(1 * 1000); - expect(SessionTokenCache.get(key)).toBeUndefined(); + it('returns needsRefresh=true consistently while token is within leeway', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'dedupe-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { audience: 'dedupe-test', tokenId: 'dedupe-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // Advance to within leeway + vi.advanceTimersByTime(51 * 1000); // 9s remaining + + // First call: needsRefresh=true + let result = SessionTokenCache.get(key); + expect(result?.needsRefresh).toBe(true); + + // Second call: needsRefresh=true (consistently signals refresh needed) + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('dedupe-token'); + expect(result?.needsRefresh).toBe(true); }); - it('enforces minimum 5 second sync leeway even when leeway is set to 0', async () => { + it('honors larger custom leeway values', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'custom-leeway-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { audience: 'custom-leeway-test', tokenId: 'custom-leeway-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // At 29s elapsed, 31s remaining - fresh with 30s leeway + vi.advanceTimersByTime(29 * 1000); + let result = SessionTokenCache.get(key, 30); + expect(result?.entry.tokenId).toBe('custom-leeway-token'); + expect(result?.needsRefresh).toBe(false); + + // At 31s elapsed, 29s remaining (< 30s leeway) - needs refresh + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key, 30); + expect(result?.entry.tokenId).toBe('custom-leeway-token'); + expect(result?.needsRefresh).toBe(true); + }); + + it('enforces minimum 15 second threshold even when leeway is set to 0', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); @@ -577,13 +668,167 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key, 0)).toMatchObject({ tokenId: 'zero-leeway-token' }); + // 16s remaining (above 15s threshold) + vi.advanceTimersByTime(44 * 1000); + let result = SessionTokenCache.get(key, 0); + expect(result?.entry.tokenId).toBe('zero-leeway-token'); + expect(result?.needsRefresh).toBe(false); + + // 14s remaining (below 15s threshold) + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key, 0); + expect(result?.entry.tokenId).toBe('zero-leeway-token'); + expect(result?.needsRefresh).toBe(true); + + // 0s remaining (expired) + vi.advanceTimersByTime(14 * 1000); + result = SessionTokenCache.get(key, 0); + expect(result).toBeUndefined(); + }); + }); + + describe('conservative threshold behavior', () => { + it('returns needsRefresh=false when TTL is comfortably above 15s threshold', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'above-threshold-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'above-threshold-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 60s remaining + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('above-threshold-token'); + expect(result?.needsRefresh).toBe(false); + + // 20s remaining + vi.advanceTimersByTime(40 * 1000); + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('above-threshold-token'); + expect(result?.needsRefresh).toBe(false); + }); + + it('returns needsRefresh=true when TTL drops just below 15s threshold', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'below-threshold-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'below-threshold-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 16s remaining + vi.advanceTimersByTime(44 * 1000); + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('below-threshold-token'); + expect(result?.needsRefresh).toBe(false); + // 14s remaining + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('below-threshold-token'); + expect(result?.needsRefresh).toBe(true); + }); + + it('uses caller leeway when larger than 15s threshold', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'large-leeway-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'large-leeway-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 21s remaining (leeway=20s) + vi.advanceTimersByTime(39 * 1000); + let result = SessionTokenCache.get(key, 20); + expect(result?.entry.tokenId).toBe('large-leeway-token'); + expect(result?.needsRefresh).toBe(false); + + // 19s remaining (leeway=20s) + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key, 20); + expect(result?.entry.tokenId).toBe('large-leeway-token'); + expect(result?.needsRefresh).toBe(true); + }); + + it('ignores caller leeway when smaller than 15s threshold', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'small-leeway-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'small-leeway-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 16s remaining (leeway=5s, but min threshold is 15s) + vi.advanceTimersByTime(44 * 1000); + let result = SessionTokenCache.get(key, 5); + expect(result?.entry.tokenId).toBe('small-leeway-token'); + expect(result?.needsRefresh).toBe(false); + + // 14s remaining + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key, 5); + expect(result?.entry.tokenId).toBe('small-leeway-token'); + expect(result?.needsRefresh).toBe(true); + }); + + it('forces synchronous refresh when token has less than poller interval remaining', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'hard-cutoff-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'hard-cutoff-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 6s remaining (just above 5s cutoff) vi.advanceTimersByTime(54 * 1000); - expect(SessionTokenCache.get(key, 0)).toBeDefined(); + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('hard-cutoff-token'); + expect(result?.needsRefresh).toBe(true); + // 4s remaining (below 5s cutoff) - forces sync refresh vi.advanceTimersByTime(2 * 1000); - expect(SessionTokenCache.get(key, 0)).toBeUndefined(); + result = SessionTokenCache.get(key); + expect(result).toBeUndefined(); }); }); @@ -604,7 +849,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); vi.advanceTimersByTime(30 * 1000); @@ -627,10 +872,10 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); vi.advanceTimersByTime(90 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); vi.advanceTimersByTime(30 * 1000); expect(SessionTokenCache.get(key)).toBeUndefined(); @@ -656,7 +901,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId: label, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get({ tokenId: label })).toBeDefined(); + expect(SessionTokenCache.get({ tokenId: label })?.entry).toBeDefined(); vi.advanceTimersByTime(ttl * 1000); expect(SessionTokenCache.get({ tokenId: label })).toBeUndefined(); @@ -684,9 +929,9 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...keyWithAudience, tokenResolver }); await tokenResolver; - const cached = SessionTokenCache.get(keyWithAudience); - expect(cached).toBeDefined(); - expect(cached?.audience).toBe('https://api.example.com'); + const result = SessionTokenCache.get(keyWithAudience); + expect(result).toBeDefined(); + expect(result?.entry.audience).toBe('https://api.example.com'); }); it('treats tokens with different audiences as separate entries', async () => { @@ -709,8 +954,8 @@ describe('SessionTokenCache', () => { await Promise.all([resolver1, resolver2]); expect(SessionTokenCache.size()).toBe(2); - expect(SessionTokenCache.get(key1)).toBeDefined(); - expect(SessionTokenCache.get(key2)).toBeDefined(); + expect(SessionTokenCache.get(key1)?.entry).toBeDefined(); + expect(SessionTokenCache.get(key2)?.entry).toBeDefined(); }); }); @@ -762,6 +1007,61 @@ describe('SessionTokenCache', () => { }); }); + describe('resolvedToken', () => { + it('is populated after tokenResolver resolves', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'resolved-token-test', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'resolved-token-test' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + + // Before promise resolves, resolvedToken should be undefined + let result = SessionTokenCache.get(key); + expect(result?.entry.resolvedToken).toBeUndefined(); + + // Wait for promise to resolve + await tokenResolver; + + // After promise resolves, resolvedToken should be populated + result = SessionTokenCache.get(key); + expect(result?.entry.resolvedToken).toBeDefined(); + expect(result?.entry.resolvedToken?.getRawString()).toBeTruthy(); + }); + + it('can be provided when setting a pre-resolved token', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'pre-resolved-token', + jwt, + object: 'token', + }); + + const key = { tokenId: 'pre-resolved-token' }; + + // Set with both tokenResolver and resolvedToken + SessionTokenCache.set({ + ...key, + resolvedToken: token, + tokenResolver: Promise.resolve(token), + }); + + // resolvedToken should be immediately available + const result = SessionTokenCache.get(key); + expect(result?.entry.resolvedToken).toBeDefined(); + expect(result?.entry.resolvedToken).toBe(token); + }); + }); + describe('multi-session isolation', () => { it('stores tokens from different session IDs separately without interference', async () => { const nowSeconds = Math.floor(Date.now() / 1000); @@ -813,15 +1113,15 @@ describe('SessionTokenCache', () => { // (not session2's token) - tokens are isolated by tokenId const retrievedSession1Token = SessionTokenCache.get({ tokenId: session1Id }); expect(retrievedSession1Token).toBeDefined(); - const resolvedSession1Token = await retrievedSession1Token!.tokenResolver; + const resolvedSession1Token = await retrievedSession1Token!.entry.tokenResolver; expect(resolvedSession1Token.jwt?.claims?.iat).toBe(nowSeconds); - expect(retrievedSession1Token!.tokenId).toBe(session1Id); + expect(retrievedSession1Token!.entry.tokenId).toBe(session1Id); // Verify session2's token is separate const retrievedSession2Token = SessionTokenCache.get({ tokenId: session2Id }); expect(retrievedSession2Token).toBeDefined(); - expect(retrievedSession2Token!.tokenId).toBe(session2Id); - expect(retrievedSession2Token!.tokenId).not.toBe(session1Id); + expect(retrievedSession2Token!.entry.tokenId).toBe(session2Id); + expect(retrievedSession2Token!.entry.tokenId).not.toBe(session1Id); }); it('accepts broadcast messages from the same session ID', async () => { @@ -847,7 +1147,7 @@ describe('SessionTokenCache', () => { const cachedToken = SessionTokenCache.get({ tokenId: sessionId }); expect(cachedToken).toBeDefined(); - const resolvedToken = await cachedToken!.tokenResolver; + const resolvedToken = await cachedToken!.entry.tokenResolver; expect(resolvedToken.jwt?.claims?.iat).toBe(nowSeconds - 10); const newerJwt = createJwtWithTtl(nowSeconds, 60); @@ -867,7 +1167,7 @@ describe('SessionTokenCache', () => { await vi.waitFor(async () => { const updatedCached = SessionTokenCache.get({ tokenId: sessionId }); expect(updatedCached).toBeDefined(); - const updatedToken = await updatedCached!.tokenResolver; + const updatedToken = await updatedCached!.entry.tokenResolver; expect(updatedToken.jwt?.claims?.iat).toBe(nowSeconds); }); diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index 91e8040f79d..ed9f1f04c76 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -3,7 +3,8 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers'; import { SafeLock } from './safeLock'; const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; -const INTERVAL_IN_MS = 5 * 1_000; + +export const POLLER_INTERVAL_IN_MS = 5 * 1_000; export class SessionCookiePoller { private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); @@ -20,7 +21,7 @@ export class SessionCookiePoller { const run = async () => { this.initiated = true; await this.lock.acquireLockAndRun(cb); - this.timerId = this.workerTimers.setTimeout(run, INTERVAL_IN_MS); + this.timerId = this.workerTimers.setTimeout(run, POLLER_INTERVAL_IN_MS); }; void run(); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b6fd29c81ed..95b042c28d0 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -44,6 +44,12 @@ import { SessionVerification } from './SessionVerification'; export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; + /** + * Tracks token IDs with in-flight background refresh requests. + * Prevents multiple concurrent background refreshes for the same token. + */ + static #backgroundRefreshInProgress = new Set(); + id!: string; status!: SessionStatus; lastActiveAt!: Date; @@ -350,81 +356,142 @@ export class Session extends BaseResource implements SessionResource { return null; } - const { leewayInSeconds, template, skipCache = false } = options || {}; + const { backgroundRefreshThreshold, refreshIfStale = false, skipCache = false, template } = options || {}; // If no organization ID is provided, default to the selected organization in memory // Note: this explicitly allows passing `null` or `""`, which should select the personal workspace. const organizationId = typeof options?.organizationId === 'undefined' ? this.lastActiveOrganizationId : options?.organizationId; - if (!template && Number(leewayInSeconds) >= 60) { - throw new Error('Leeway can not exceed the token lifespan (60 seconds)'); + if (!template && Number(backgroundRefreshThreshold) >= 60) { + throw new Error('backgroundRefreshThreshold cannot exceed the token lifespan (60 seconds)'); } const tokenId = this.#getCacheId(template, organizationId); - const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); + const cacheResult = skipCache ? undefined : SessionTokenCache.get({ tokenId }, backgroundRefreshThreshold); // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; - if (cachedEntry) { - debugLogger.debug( - 'Using cached token (no fetch needed)', - { - tokenId, - }, - 'session', - ); - const cachedToken = await cachedEntry.tokenResolver; + if (cacheResult) { + // If caller requests refresh when stale (e.g., poller), fetch fresh token instead of returning cached + if (cacheResult.needsRefresh && refreshIfStale) { + debugLogger.debug('Token is stale, refreshing as requested', { tokenId }, 'session'); + return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); + } + + // Trigger background refresh if token is expiring soon + // This guarantees cache revalidation without relying solely on the poller + if (cacheResult.needsRefresh) { + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate); + } + + debugLogger.debug('Using cached token', { tokenId, needsRefresh: cacheResult.needsRefresh }, 'session'); + + // Prefer synchronous read to avoid microtask overhead when token is already resolved + const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } - // Return null when raw string is empty to indicate that there it's signed-out + // Return null when raw string is empty to indicate signed-out state return cachedToken.getRawString() || null; } - debugLogger.info( - 'Fetching new token from API', - { - organizationId, - template, - tokenId, - }, - 'session', - ); + return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); + } + #createTokenResolver( + template: string | undefined, + organizationId: string | undefined | null, + skipCache: boolean, + ): Promise { const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; - // TODO: update template endpoint to accept organizationId - const params: Record = template ? {} : { organizationId }; - + const params: Record = template ? {} : { organizationId: organizationId ?? null }; const lastActiveToken = this.lastActiveToken?.getRawString(); - const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { + return Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { if (MissingExpiredTokenError.is(e) && lastActiveToken) { return Token.create(path, { ...params }, { expired_token: lastActiveToken }); } throw e; }); - SessionTokenCache.set({ tokenId, tokenResolver }); + } - return tokenResolver.then(token => { - if (shouldDispatchTokenUpdate) { - eventBus.emit(events.TokenUpdate, { token }); + #dispatchTokenEvents(token: TokenResource, shouldDispatch: boolean): void { + if (!shouldDispatch) { + return; + } - if (token.jwt) { - this.lastActiveToken = token; - // Emits the updated session with the new token to the state listeners - eventBus.emit(events.SessionTokenResolved, null); - } - } + eventBus.emit(events.TokenUpdate, { token }); - // Return null when raw string is empty to indicate that there it's signed-out + if (token.jwt) { + this.lastActiveToken = token; + eventBus.emit(events.SessionTokenResolved, null); + } + } + + #fetchToken( + template: string | undefined, + organizationId: string | undefined | null, + tokenId: string, + shouldDispatchTokenUpdate: boolean, + skipCache: boolean, + ): Promise { + debugLogger.info('Fetching new token from API', { organizationId, template, tokenId }, 'session'); + + const tokenResolver = this.#createTokenResolver(template, organizationId, skipCache); + SessionTokenCache.set({ tokenId, tokenResolver }); + + return tokenResolver.then(token => { + this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + // Return null when raw string is empty to indicate signed-out state return token.getRawString() || null; }); } + /** + * Triggers a background token refresh without caching the pending promise. + * This allows concurrent getToken() calls to continue returning the stale cached token + * while the refresh is in progress. The cache is only updated after the refresh succeeds. + * + * Uses a static Set to prevent multiple concurrent background refreshes for the same token. + */ + #refreshTokenInBackground( + template: string | undefined, + organizationId: string | undefined | null, + tokenId: string, + shouldDispatchTokenUpdate: boolean, + ): void { + // Prevent multiple concurrent background refreshes for the same token + if (Session.#backgroundRefreshInProgress.has(tokenId)) { + debugLogger.debug('Background refresh already in progress', { tokenId }, 'session'); + return; + } + + Session.#backgroundRefreshInProgress.add(tokenId); + debugLogger.info('Refreshing token in background', { organizationId, template, tokenId }, 'session'); + + const tokenResolver = this.#createTokenResolver(template, organizationId, false); + + // Don't cache the promise immediately - only update cache on success + // This allows concurrent calls to continue using the stale token + tokenResolver + .then(token => { + // Cache the resolved token for future calls + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token) }); + this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + }) + .catch(error => { + // Log but don't propagate - callers already have stale token + debugLogger.warn('Background token refresh failed', { error, tokenId }, 'session'); + }) + .finally(() => { + Session.#backgroundRefreshInProgress.delete(tokenId); + }); + } + get currentTask() { const [task] = this.tasks ?? []; return task; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index eb51257191d..1a52b03b997 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -101,7 +101,7 @@ describe('Session', () => { expect(dispatchSpy).toHaveBeenCalledTimes(2); }); - it('does not re-cache token when Session is reconstructed with same token', async () => { + it('returns same token without API call when Session is reconstructed', async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), }); @@ -120,10 +120,6 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - expect(SessionTokenCache.size()).toBe(1); - const cachedEntry1 = SessionTokenCache.get({ tokenId: 'session_1-activeOrganization' }); - expect(cachedEntry1).toBeDefined(); - const session2 = new Session({ status: 'active', id: 'session_1', @@ -136,8 +132,6 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - expect(SessionTokenCache.size()).toBe(1); - const token1 = await session1.getToken(); const token2 = await session2.getToken(); @@ -146,12 +140,12 @@ describe('Session', () => { expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); }); - it('caches token from cookie during degraded mode recovery', async () => { + it('returns lastActiveToken without API call (degraded mode recovery)', async () => { BaseResource.clerk = clerkMock(); SessionTokenCache.clear(); - const sessionFromCookie = new Session({ + const session = new Session({ status: 'active', id: 'session_1', object: 'session', @@ -163,11 +157,8 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - expect(SessionTokenCache.size()).toBe(1); - const cachedEntry = SessionTokenCache.get({ tokenId: 'session_1' }); - expect(cachedEntry).toBeDefined(); + const token = await session.getToken(); - const token = await sessionFromCookie.getToken(); expect(token).toEqual(mockJwt); expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); }); @@ -427,6 +418,323 @@ describe('Session', () => { expect(requestSpy).toHaveBeenCalledTimes(2); }); + + describe('stale-while-revalidate (SWR) behavior', () => { + it('returns stale token immediately while refreshing in background', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // Advance time so token needs refresh (< 15s remaining of 60s TTL) + vi.advanceTimersByTime(46 * 1000); + + // Hold the network request pending + let resolveNetworkRequest!: (value: any) => void; + requestSpy.mockClear(); + requestSpy.mockReturnValueOnce( + new Promise(resolve => { + resolveNetworkRequest = resolve; + }), + ); + + // Concurrent calls should all return immediately with stale token + const [token1, token2, token3] = await Promise.all([ + session.getToken(), + session.getToken(), + session.getToken(), + ]); + + expect(token1).toEqual(mockJwt); + expect(token2).toEqual(mockJwt); + expect(token3).toEqual(mockJwt); + expect(requestSpy).toHaveBeenCalledTimes(1); + + // Cleanup: resolve the pending request + resolveNetworkRequest({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + await vi.advanceTimersByTimeAsync(0); + }); + + it('continues returning tokens after background refresh failure', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + vi.advanceTimersByTime(46 * 1000); + + // Background refresh fails + requestSpy.mockClear(); + requestSpy.mockRejectedValueOnce(new Error('Network error')); + + const token = await session.getToken(); + expect(token).toEqual(mockJwt); + + // Wait for background failure to complete + await vi.advanceTimersByTimeAsync(100); + + // Subsequent call should still return token (stale is preserved) + const token2 = await session.getToken(); + expect(token2).toEqual(mockJwt); + }); + + it('retries background refresh after previous failure', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + vi.advanceTimersByTime(46 * 1000); + + // First call triggers background refresh that fails + requestSpy.mockClear(); + requestSpy.mockRejectedValueOnce(new Error('Network error')); + + await session.getToken(); + expect(requestSpy).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + + // Second call should trigger another refresh attempt + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + + await session.getToken(); + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('uses refreshed token for subsequent calls after background refresh succeeds', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const newMockJwt = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg0MDAsImlhdCI6MTY2NjY0ODM0MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5leGFtcGxlLmNvbSIsImp0aSI6Im5ld3Rva2VuIiwibmJmIjoxNjY2NjQ4MzQwLCJzaWQiOiJzZXNzXzFxcTlveTVHaU5IeGRSMlhXVTZnRzZtSWNCWCIsInN1YiI6InVzZXJfMXFxOW95NUdpTkh4ZFIyWFdVNmdHNm1JY0JYIn0.mock'; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + vi.advanceTimersByTime(46 * 1000); + + // Background refresh returns new token + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: newMockJwt }, status: 200 }); + + // First call returns stale token + const staleToken = await session.getToken(); + expect(staleToken).toEqual(mockJwt); + + // Wait for background refresh to complete + await vi.advanceTimersByTimeAsync(100); + + // Subsequent call returns refreshed token (no new API call needed) + requestSpy.mockClear(); + const freshToken = await session.getToken(); + expect(freshToken).toEqual(newMockJwt); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('forces synchronous refresh when refreshIfStale is true and token is stale', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const newMockJwt = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg0MDAsImlhdCI6MTY2NjY0ODM0MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5leGFtcGxlLmNvbSIsImp0aSI6Im5ld3Rva2VuIiwibmJmIjoxNjY2NjQ4MzQwLCJzaWQiOiJzZXNzXzFxcTlveTVHaU5IeGRSMlhXVTZnRzZtSWNCWCIsInN1YiI6InVzZXJfMXFxOW95NUdpTkh4ZFIyWFdVNmdHNm1JY0JYIn0.mock'; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // Advance time so token needs refresh (< 15s remaining of 60s TTL) + vi.advanceTimersByTime(46 * 1000); + + // With refreshIfStale: true, should fetch fresh token synchronously + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: newMockJwt }, status: 200 }); + + const token = await session.getToken({ refreshIfStale: true }); + + // Should return the NEW token (synchronous refresh), not the stale one + expect(token).toEqual(newMockJwt); + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('returns cached token without refresh when refreshIfStale is true but token is fresh', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // Advance only 10s - token still has 50s remaining, not stale yet + vi.advanceTimersByTime(10 * 1000); + + requestSpy.mockClear(); + const token = await session.getToken({ refreshIfStale: true }); + + // Should return cached token, no API call + expect(token).toEqual(mockJwt); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('respects backgroundRefreshThreshold for earlier background refresh', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // With 30s threshold and 25s remaining (< 30), token should trigger background refresh + vi.advanceTimersByTime(35 * 1000); // 25s remaining + + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + + const token = await session.getToken({ backgroundRefreshThreshold: 30 }); + + // Should return stale token immediately (SWR behavior) + expect(token).toEqual(mockJwt); + // Should trigger background refresh because 25s < 30s threshold + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('allows backgroundRefreshThreshold below 15s (minimum is 5s poller interval)', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // With 8s remaining and backgroundRefreshThreshold: 10, should trigger refresh + vi.advanceTimersByTime(52 * 1000); // 8s remaining + + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + + const token = await session.getToken({ backgroundRefreshThreshold: 10 }); + + // Should return cached token immediately (SWR behavior) + expect(token).toEqual(mockJwt); + + // Background refresh should be triggered because 8s < 10s threshold + // (threshold of 10s is respected, not floored to 15s) + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('does not trigger background refresh when token has more than threshold remaining', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // With 40s remaining and default 15s threshold, token is fresh + vi.advanceTimersByTime(20 * 1000); // 40s remaining + + requestSpy.mockClear(); + const token = await session.getToken(); + + expect(token).toEqual(mockJwt); + expect(requestSpy).not.toHaveBeenCalled(); + }); + }); }); describe('touch()', () => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 74697b80c63..7220938af66 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -3,6 +3,7 @@ import type { TokenResource } from '@clerk/shared/types'; import { debugLogger } from '@/utils/debug'; import { TokenId } from '@/utils/tokenId'; +import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { Token } from './resources/internal'; /** @@ -23,6 +24,11 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { * Used for expiration and cleanup scheduling. */ createdAt?: Seconds; + /** + * The resolved token value for synchronous reads. + * Populated after tokenResolver resolves. Check this first to avoid microtask overhead. + */ + resolvedToken?: TokenResource; /** * Promise that resolves to the TokenResource. * May be pending and should be awaited before accessing token data. @@ -42,6 +48,15 @@ interface TokenCacheValue { timeoutId?: ReturnType; } +/** + * Result from cache lookup containing the entry and refresh status. + */ +export interface TokenCacheGetResult { + entry: TokenCacheEntry; + /** Indicates the token is valid but expiring soon and should be refreshed in the background */ + needsRefresh: boolean; +} + export interface TokenCache { /** * Removes all cached entries and clears associated timeouts. @@ -56,13 +71,16 @@ export interface TokenCache { close(): void; /** - * Retrieves a cached token entry if it exists and has not expired. + * Retrieves a cached token entry if it exists and is safe to use. + * Implements stale-while-revalidate: returns valid tokens immediately and signals when background refresh is needed. + * Forces synchronous refresh if token has less than one poller interval remaining. * * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry - * @param leeway - Optional seconds before expiration to treat token as expired (default: 10s). Combined with 5s sync leeway. - * @returns The cached TokenCacheEntry if found and valid, undefined otherwise + * @param refreshThreshold - Seconds before expiration to trigger background refresh (default: 15s, minimum: 15s). + * Higher values trigger earlier background refresh but may cause excessive requests and trip rate limiting. + * @returns Result with entry and refresh flag, or undefined if token is missing/expired/too close to expiration */ - get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheEntry | undefined; + get(cacheKeyJSON: TokenCacheKeyJSON, refreshThreshold?: number): TokenCacheGetResult | undefined; /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. @@ -82,9 +100,17 @@ export interface TokenCache { const KEY_PREFIX = 'clerk'; const DELIMITER = '::'; -const LEEWAY = 10; -// This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller -const SYNC_LEEWAY = 5; + +/** + * Default seconds before token expiration to trigger background refresh. + * This threshold accounts for timer jitter, SafeLock contention (~5s), network latency, + * and tolerance for missed poller ticks. + * + * Users can customize this value: + * - Lower values (min: 5s) delay background refresh until closer to expiration + * - Higher values trigger earlier background refresh but may cause more frequent requests + */ +const BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS = 15; const BROADCAST = { broadcast: true }; const NO_BROADCAST = { broadcast: false }; @@ -174,7 +200,10 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { cache.clear(); }; - const get = (cacheKeyJSON: TokenCacheKeyJSON, leeway = LEEWAY): TokenCacheEntry | undefined => { + const get = ( + cacheKeyJSON: TokenCacheKeyJSON, + refreshThreshold = BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, + ): TokenCacheGetResult | undefined => { ensureBroadcastChannel(); const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); @@ -186,13 +215,11 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const nowSeconds = Math.floor(Date.now() / 1000); const elapsed = nowSeconds - value.createdAt; + const remainingTtl = (value.expiresIn ?? Infinity) - elapsed; - // Include poller interval as part of the leeway to ensure the cache value - // will be valid for more than the SYNC_LEEWAY or the leeway in the next poll. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const expiresSoon = value.expiresIn! - elapsed < (leeway || 1) + SYNC_LEEWAY; - - if (expiresSoon) { + // Token expired or dangerously close to expiration - force synchronous refresh + // Uses poller interval as threshold since the poller might not get to it in time + if (remainingTtl <= POLLER_INTERVAL_IN_MS / 1000) { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } @@ -200,7 +227,15 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return; } - return value.entry; + // Ensure threshold is at least the poller interval (values below this have no effect + // since tokens with less than POLLER_INTERVAL remaining force a synchronous refresh) + const effectiveThreshold = Math.max(refreshThreshold, POLLER_INTERVAL_IN_MS / 1000); + + // Token is valid but expiring soon - signal that background refresh is needed + const needsRefresh = remainingTtl < effectiveThreshold; + + // Return the valid token immediately, caller decides whether to refresh + return { entry: value.entry, needsRefresh }; }; /** @@ -249,9 +284,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } try { - const existingEntry = get({ tokenId: data.tokenId }); - if (existingEntry) { - const existingToken = await existingEntry.tokenResolver; + const result = get({ tokenId: data.tokenId }); + if (result) { + const existingToken = await result.entry.tokenResolver; const existingIat = existingToken.jwt?.claims?.iat; if (existingIat && existingIat >= iat) { debugLogger.debug( @@ -330,6 +365,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { entry.tokenResolver .then(newToken => { + // Store resolved token for synchronous reads + entry.resolvedToken = newToken; + const claims = newToken.jwt?.claims; if (!claims || typeof claims.exp !== 'number' || typeof claims.iat !== 'number') { return deleteKey(); diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 727c044ad48..fdb074ad681 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -339,10 +339,29 @@ export interface SessionTask { } export type GetTokenOptions = { - template?: string; + /** + * Seconds before token expiration to trigger background refresh. + * When a token's remaining TTL falls below this threshold, `getToken()` returns the + * cached token immediately while triggering a background refresh. + * + * - Minimum value: 5 seconds (the poller interval) + * - Default value: 15 seconds + * + * Lower values delay background refresh until closer to expiration. + * Higher values trigger earlier background refresh, which can reduce latency for + * time-sensitive operations but may cause more frequent token refresh requests. + */ + backgroundRefreshThreshold?: number; organizationId?: string; - leewayInSeconds?: number; + /** + * @internal + * When true, triggers a background token refresh if the cached token is within the + * refresh threshold period, while still returning the current valid token immediately. + * Used by the session poller to proactively refresh tokens before they expire. + */ + refreshIfStale?: boolean; skipCache?: boolean; + template?: string; }; /** * @inline diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md b/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md new file mode 100644 index 00000000000..3cb28cb4830 --- /dev/null +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md @@ -0,0 +1,54 @@ +--- +title: '`getToken` `leewayInSeconds` renamed to `backgroundRefreshThreshold`' +matcher: + - 'leewayInSeconds' + - 'backgroundRefreshThreshold' + - 'getToken' +category: 'behavior-change' +warning: true +--- + +The `leewayInSeconds` option in `session.getToken()` has been renamed to `backgroundRefreshThreshold` for clarity. This option controls when background token refresh is triggered before expiration. + +### What changed? + +1. **Renamed option**: `leewayInSeconds` → `backgroundRefreshThreshold` +2. **Lower minimum**: The minimum value is now 5 seconds (the poller interval) instead of 15 seconds +3. **Clearer semantics**: The new name better describes what the option does + +### Migration + +```js +// Before +const token = await session.getToken({ leewayInSeconds: 30 }); + +// After +const token = await session.getToken({ backgroundRefreshThreshold: 30 }); +``` + +### How it works + +When a token's remaining TTL falls below the `backgroundRefreshThreshold`, `getToken()` returns the cached token immediately while triggering a background refresh. + +- **Minimum value**: 5 seconds (the poller interval) +- **Default value**: 15 seconds + +``` +Token TTL Timeline +──────────────────────────────────────────────────────► + expires + +│←── Fresh zone ──→│←── Background refresh ──→│←─ Sync ─→│ + (no refresh) (SWR: return + refresh) (force) + + > threshold 5s - threshold < 5s +``` + +### Rate Limiting Warning + +Setting `backgroundRefreshThreshold` higher than the default triggers earlier background refresh. While this can reduce latency for time-sensitive operations, values that are too high may cause excessive token refresh requests and potentially trigger rate limiting. + +```js +// Use with caution - triggers refresh 30s before expiration +const token = await session.getToken({ backgroundRefreshThreshold: 30 }); +``` diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md new file mode 100644 index 00000000000..f105b671f66 --- /dev/null +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md @@ -0,0 +1,48 @@ +--- +title: '`getToken` now uses stale-while-revalidate pattern' +matcher: + - 'getToken' + - 'session\.getToken' +category: 'behavior-change' +warning: true +--- + +`session.getToken()` now implements a stale-while-revalidate pattern that improves performance by returning cached tokens immediately while refreshing them in the background when they're close to expiration. + +### How it works + +1. When a token is within 15 seconds of expiration (configurable via `backgroundRefreshThreshold`), `getToken()` returns the valid cached token immediately +2. A background refresh is triggered automatically to fetch a fresh token +3. Subsequent calls receive the new token once the background refresh completes + +### Benefits + +- **Reduced latency**: No more waiting for token refresh on every call near expiration +- **Better user experience**: API calls proceed immediately with valid (though expiring) tokens +- **Automatic refresh**: Fresh tokens are ready before the old ones expire + +### Cross-tab synchronization + +Token updates are automatically synchronized across browser tabs using `BroadcastChannel`. When one tab refreshes a token, other tabs receive the update automatically. + +### Example + +```js +// Token is cached and valid but expiring in 10 seconds +// Core 2 behavior: Would block and fetch new token +// Core 3 behavior: Returns cached token immediately, refreshes in background +const token = await session.getToken(); +``` + +### Compatibility + +This is a transparent improvement - no code changes are required. Your existing `getToken()` calls benefit automatically. + +### Customizing refresh timing + +Use `backgroundRefreshThreshold` to customize when background refresh is triggered (minimum 5 seconds, default 15 seconds): + +```js +// Start background refresh 30 seconds before expiration +const token = await session.getToken({ backgroundRefreshThreshold: 30 }); +```