diff --git a/.changeset/fresh-tigers-hunt.md b/.changeset/fresh-tigers-hunt.md new file mode 100644 index 00000000000..f684c62e178 --- /dev/null +++ b/.changeset/fresh-tigers-hunt.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +Add proactive session token refresh. Tokens are now automatically refreshed in the background before they expire. The `leewayInSeconds` option controls how far in advance refresh is triggered (default: 15 seconds, minimum: 5 seconds). diff --git a/.changeset/sparkly-aliens-see.md b/.changeset/sparkly-aliens-see.md new file mode 100644 index 00000000000..ee5ec4c3ee7 --- /dev/null +++ b/.changeset/sparkly-aliens-see.md @@ -0,0 +1,30 @@ +--- +'@clerk/tanstack-react-start': major +'@clerk/react-router': major +'@clerk/clerk-js': major +'@clerk/upgrade': major +'@clerk/nextjs': major +'@clerk/shared': major +'@clerk/react': major +'@clerk/nuxt': major +'@clerk/vue': major +--- + +`getToken()` now throws `ClerkOfflineError` instead of returning `null` when the client is offline. + +This makes it explicit that a token fetch failure was due to network conditions, not authentication state. Previously, returning `null` could be misinterpreted as "user is signed out," potentially causing the cached token to be cleared. + +To handle this change, catch `ClerkOfflineError` from `getToken()` calls: + +```typescript +import { ClerkOfflineError } from '@clerk/react/errors'; + +try { + const token = await session.getToken(); +} catch (error) { + if (ClerkOfflineError.is(error)) { + // Handle offline scenario - show offline UI, retry later, etc. + } + throw error; +} +``` diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index c30515ee889..3db853fc03f 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1,4 +1,4 @@ -import { EmailLinkErrorCodeStatus } from '@clerk/shared/error'; +import { ClerkOfflineError, EmailLinkErrorCodeStatus } from '@clerk/shared/error'; import type { ActiveSessionResource, PendingSessionResource, @@ -570,6 +570,73 @@ describe('Clerk singleton', () => { expect(sut.session).toMatchObject(mockSessionWithOrganization); }); }); + + describe('when offline', () => { + const mockSession = { + id: '1', + remove: vi.fn(), + status: 'active', + user: {}, + touch: vi.fn(() => Promise.resolve()), + getToken: vi.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + let eventBusSpy: ReturnType; + + beforeEach(() => { + eventBusSpy = vi.spyOn(eventBus, 'emit'); + }); + + afterEach(() => { + mockSession.remove.mockReset(); + mockSession.touch.mockReset(); + mockSession.getToken.mockReset(); + eventBusSpy?.mockRestore(); + }); + + it('does not emit TokenUpdate with null when getToken throws ClerkOfflineError', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockSession.getToken.mockRejectedValue(new ClerkOfflineError('Network request failed while offline.')); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + + expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: mockSession.lastActiveToken }); + + const tokenUpdateCalls = eventBusSpy.mock.calls.filter(call => call[0] === 'token:update'); + const nullTokenCalls = tokenUpdateCalls.filter(call => call[1]?.token === null); + expect(nullTokenCalls.length).toBe(0); + }); + + it('preserves existing auth state when offline during setActive', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockSession.getToken.mockRejectedValue(new ClerkOfflineError('Network request failed while offline.')); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + + expect(sut.session).toBeDefined(); + }); + + it('re-throws non-offline errors from getToken', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockSession.getToken.mockRejectedValue(new Error('Some other error')); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + await expect(sut.setActive({ session: mockSession as any as ActiveSessionResource })).rejects.toThrow( + 'Some other error', + ); + }); + }); }); describe('.load()', () => { diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index a5d7f892bb8..14174c218af 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,9 @@ 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'); }); it('removes token when it has already expired based on duration', async () => { @@ -351,11 +351,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 when remaining TTL is above poller interval', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const iat = nowSeconds; const exp = iat + 20; @@ -366,12 +366,15 @@ 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 (> 5s poller interval) + SessionTokenCache.set({ createdAt: nowSeconds - 11, tokenId: 'soon_expired_token', tokenResolver }); await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); - expect(cachedEntry).toBeUndefined(); + // Token is still valid (9s > 5s poller interval), so it should be returned + const result = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('soon_expired_token'); }); it('returns token when expiresAt is undefined (promise not yet resolved)', () => { @@ -380,9 +383,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 +474,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,78 +515,217 @@ 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('proactive refresh timer', () => { + it('calls onRefresh callback when refresh timer fires', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'leeway-token', + id: 'refresh-timer-token', jwt, object: 'token', }); + const onRefresh = vi.fn(); const tokenResolver = Promise.resolve(token); - const key = { audience: 'leeway-test', tokenId: 'leeway-token' }; + const key = { audience: 'refresh-test', tokenId: 'refresh-timer-token' }; + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); + await tokenResolver; + + // Timer should fire at: 60s - 15s (leeway) - 2s (lead time) = 43s + expect(onRefresh).not.toHaveBeenCalled(); + + // Advance to just before timer should fire + vi.advanceTimersByTime(42 * 1000); + expect(onRefresh).not.toHaveBeenCalled(); + + // Advance past timer fire time + vi.advanceTimersByTime(2 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not schedule refresh timer when onRefresh is not provided', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'no-callback-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'no-callback-token' }; + + // Set without onRefresh (like broadcast-received tokens) SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toMatchObject({ tokenId: 'leeway-token' }); + // Token should still be cached and retrievable + const result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('no-callback-token'); - vi.advanceTimersByTime(44 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + // Advance past when timer would fire - nothing should happen + vi.advanceTimersByTime(50 * 1000); - vi.advanceTimersByTime(1 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + // Token should still be valid (10s remaining) + const stillCached = SessionTokenCache.get(key); + expect(stillCached?.entry.tokenId).toBe('no-callback-token'); + }); - vi.advanceTimersByTime(1 * 1000); - expect(SessionTokenCache.get(key)).toBeUndefined(); + it('clears refresh timer when entry is deleted', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'clear-timer-token', + jwt, + object: 'token', + }); + + const onRefresh = vi.fn(); + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'clear-timer-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); + await tokenResolver; + + // Clear the cache before timer fires + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.clear(); + + // Advance past when timer would have fired + vi.advanceTimersByTime(20 * 1000); + + // onRefresh should not have been called since entry was cleared + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('does not schedule refresh timer for tokens with very short TTL', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + // Token with 10s TTL - refreshFireTime would be 10 - 15 - 2 = -7 (negative) + const jwt = createJwtWithTtl(nowSeconds, 10); + + const token = new Token({ + id: 'short-ttl-token', + jwt, + object: 'token', + }); + + const onRefresh = vi.fn(); + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'short-ttl-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); + await tokenResolver; + + // Advance past token expiration + vi.advanceTimersByTime(15 * 1000); + + // onRefresh should not have been called - no timer was scheduled + expect(onRefresh).not.toHaveBeenCalled(); }); - it('enforces minimum 5 second sync leeway even when leeway is set to 0', async () => { + it('returns token until expiration even after refresh timer fires', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'zero-leeway-token', + id: 'still-valid-token', jwt, object: 'token', }); + const onRefresh = vi.fn(); const tokenResolver = Promise.resolve(token); - const key = { audience: 'zero-leeway-test', tokenId: 'zero-leeway-token' }; + const key = { tokenId: 'still-valid-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); + await tokenResolver; + + // Advance past refresh timer (43s) + vi.advanceTimersByTime(50 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + + // Token should still be retrievable (10s remaining, > 5s poller interval) + const result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('still-valid-token'); + }); + }); + + describe('hard cutoff behavior', () => { + it('returns token when TTL is above poller interval', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'above-cutoff-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'above-cutoff-token' }; SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key, 0)).toMatchObject({ tokenId: 'zero-leeway-token' }); + // 60s remaining + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('above-cutoff-token'); + + // 10s remaining (above 5s cutoff) + vi.advanceTimersByTime(50 * 1000); + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('above-cutoff-token'); + }); + + 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'); + // 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 +746,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 +769,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 +798,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 +826,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 +851,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 +904,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 +1010,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 +1044,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 +1064,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/clerk.ts b/packages/clerk-js/src/core/clerk.ts index bdff941f72b..1cc3b8af2ed 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1,6 +1,7 @@ import { inBrowser as inClientSide, isValidBrowserOnline } from '@clerk/shared/browser'; import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus'; import { + ClerkOfflineError, ClerkRuntimeError, EmailLinkError, EmailLinkErrorCodeStatus, @@ -1537,16 +1538,21 @@ export class Clerk implements ClerkInterface { } // getToken syncs __session and __client_uat to cookies using events.TokenUpdate dispatched event. - const token = await newSession?.getToken(); - if (!token) { - if (!isValidBrowserOnline()) { + try { + const token = await newSession?.getToken(); + if (!token) { + eventBus.emit(events.TokenUpdate, { token: null }); + } + } catch (error) { + if (ClerkOfflineError.is(error)) { debugLogger.warn( - 'Token is null when setting active session (offline)', + 'Token fetch failed when setting active session (offline). Preserving existing auth state.', { sessionId: newSession?.id }, 'clerk', ); + } else { + throw error; } - eventBus.emit(events.TokenUpdate, { token: null }); } //2. When navigation is required we emit the session as undefined, diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b6fd29c81ed..dbbb5111bcf 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -1,5 +1,6 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; -import { ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error'; +import { isValidBrowserOnline } from '@clerk/shared/browser'; +import { ClerkOfflineError, ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -44,6 +45,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; @@ -103,17 +110,41 @@ export class Session extends BaseResource implements SessionResource { getToken: GetToken = async (options?: GetTokenOptions): Promise => { // This will retry the getToken call if it fails with a non-4xx error - // We're going to trigger 8 retries in the span of ~3 minutes, - // Example delays: 3s, 5s, 13s, 19s, 26s, 34s, 43s, 50s, total: ~193s - return retry(() => this._getToken(options), { - factor: 1.55, - initialDelay: 3 * 1000, - maxDelayBetweenRetries: 50 * 1_000, - jitter: false, - shouldRetry: (error, iterationsCount) => { - return !is4xxError(error) && iterationsCount <= 8; - }, - }); + // For offline state, we use shorter retries (~15s total) before throwing ClerkOfflineError + // For other errors, we retry up to 8 times over ~3 minutes + try { + const result = await retry(() => this._getToken(options), { + factor: 1.55, + initialDelay: 3 * 1000, + maxDelayBetweenRetries: 50 * 1_000, + jitter: false, + shouldRetry: (error, iterationsCount) => { + if (is4xxError(error)) { + return false; + } + + if (!isValidBrowserOnline()) { + return iterationsCount <= 3; + } + return iterationsCount <= 8; + }, + }); + + // If we're offline and got a null/empty result, this is likely due to + // BaseResource._baseFetch returning null when offline (silent failure mode). + // Throw ClerkOfflineError to make the offline state explicit. + if (!result && !isValidBrowserOnline()) { + throw new ClerkOfflineError('Network request failed while offline. The browser appears to be disconnected.'); + } + + return result; + } catch (error) { + // If the browser is offline after retries, throw ClerkOfflineError + if (!isValidBrowserOnline()) { + throw new ClerkOfflineError('Network request failed while offline. The browser appears to be disconnected.'); + } + throw error; + } }; checkAuthorization: CheckAuthorization = params => { @@ -132,9 +163,14 @@ export class Session extends BaseResource implements SessionResource { #hydrateCache = (token: TokenResource | null) => { if (token) { + const tokenId = this.#getCacheId(); + // Dispatch tokenUpdate for __session tokens with the session's active organization ID + const shouldDispatchTokenUpdate = true; SessionTokenCache.set({ - tokenId: this.#getCacheId(), + tokenId, tokenResolver: Promise.resolve(token), + onRefresh: () => + this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, shouldDispatchTokenUpdate), }); } }; @@ -350,55 +386,42 @@ export class Session extends BaseResource implements SessionResource { return null; } - const { leewayInSeconds, template, skipCache = false } = options || {}; + const { leewayInSeconds, 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)'); - } - const tokenId = this.#getCacheId(template, organizationId); - const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); + const cacheResult = skipCache ? undefined : SessionTokenCache.get({ tokenId }); // 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) { + // Proactive refresh is handled by timers scheduled in the cache + // 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, leewayInSeconds); + } + #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 => { @@ -407,24 +430,101 @@ export class Session extends BaseResource implements SessionResource { } throw e; }); - SessionTokenCache.set({ tokenId, tokenResolver }); - return tokenResolver.then(token => { - if (shouldDispatchTokenUpdate) { - eventBus.emit(events.TokenUpdate, { token }); + return tokenResolver; + } - if (token.jwt) { - this.lastActiveToken = token; - // Emits the updated session with the new token to the state listeners - eventBus.emit(events.SessionTokenResolved, null); - } - } + #dispatchTokenEvents(token: TokenResource, shouldDispatch: boolean): void { + if (!shouldDispatch) { + return; + } + + eventBus.emit(events.TokenUpdate, { token }); + + if (token.jwt) { + this.lastActiveToken = token; + eventBus.emit(events.SessionTokenResolved, null); + } + } - // Return null when raw string is empty to indicate that there it's signed-out + #fetchToken( + template: string | undefined, + organizationId: string | undefined | null, + tokenId: string, + shouldDispatchTokenUpdate: boolean, + skipCache: boolean, + leewayInSeconds?: number, + ): Promise { + debugLogger.info('Fetching new token from API', { organizationId, template, tokenId }, 'session'); + + const tokenResolver = this.#createTokenResolver(template, organizationId, skipCache); + SessionTokenCache.set({ + leewayInSeconds, + tokenId, + tokenResolver, + onRefresh: () => + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate, leewayInSeconds), + }); + + 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, + leewayInSeconds?: number, + ): void { + // Prevent multiple concurrent background refreshes for the same token + if (Session.#backgroundRefreshInProgress.has(tokenId)) { + return; + } + + Session.#backgroundRefreshInProgress.add(tokenId); + + 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({ + leewayInSeconds, + tokenId, + tokenResolver: Promise.resolve(token), + onRefresh: () => + this.#refreshTokenInBackground( + template, + organizationId, + tokenId, + shouldDispatchTokenUpdate, + leewayInSeconds, + ), + }); + 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..1a8bcaf1b53 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1,4 +1,4 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { ClerkAPIResponseError, ClerkOfflineError } from '@clerk/shared/error'; import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types'; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; @@ -21,9 +21,9 @@ describe('Session', () => { beforeEach(() => { // Mock Date.now() to make the test tokens appear valid // mockJwt has iat: 1666648250, exp: 1666648310 - // Set current time to 1666648260 (10 seconds after iat, 50 seconds before exp) + // Set current time to iat so token appears freshly issued (60 seconds before exp) vi.useFakeTimers(); - vi.setSystemTime(new Date(1666648260 * 1000)); + vi.setSystemTime(new Date(1666648250 * 1000)); }); afterEach(() => { @@ -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(); }); @@ -269,13 +260,13 @@ describe('Session', () => { }); describe('with offline browser and network failure', () => { - let warnSpy; beforeEach(() => { + // Use real timers for offline tests to avoid unhandled rejection issues with retry logic + vi.useRealTimers(); Object.defineProperty(window.navigator, 'onLine', { writable: true, value: false, }); - warnSpy = vi.spyOn(console, 'warn').mockReturnValue(); }); afterEach(() => { @@ -283,10 +274,10 @@ describe('Session', () => { writable: true, value: true, }); - warnSpy.mockRestore(); + vi.useFakeTimers(); }); - it('returns null', async () => { + it('throws ClerkOfflineError when offline', { timeout: 20000 }, async () => { const session = new Session({ status: 'active', id: 'session_1', @@ -301,11 +292,33 @@ describe('Session', () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - const token = await session.getToken(); + try { + await session.getToken({ skipCache: true }); + expect.fail('Expected ClerkOfflineError to be thrown'); + } catch (error) { + expect(ClerkOfflineError.is(error)).toBe(true); + } + }); + it('throws ClerkOfflineError after fetch fails while offline', { timeout: 20000 }, async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: 'activeOrganization', + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + await expect(session.getToken({ skipCache: true })).rejects.toThrow(ClerkOfflineError); + + // Fetch should have been called at least once expect(global.fetch).toHaveBeenCalled(); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(token).toEqual(null); }); }); @@ -427,6 +440,182 @@ describe('Session', () => { expect(requestSpy).toHaveBeenCalledTimes(2); }); + + describe('timer-based proactive refresh', () => { + it('triggers background refresh via timer before leeway period', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + // Create session with last_active_token to trigger cache hydration and timer scheduling + 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(); + requestSpy.mockClear(); + + // Timer fires at 60s - 15s (leeway) - 2s (lead time) = 43s + // Advance to just before timer fires + vi.advanceTimersByTime(42 * 1000); + expect(requestSpy).not.toHaveBeenCalled(); + + // Set up the mock for the refresh + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + + // Advance past timer fire time + await vi.advanceTimersByTimeAsync(2 * 1000); + + // Background refresh should have been triggered by the timer + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('continues returning cached token while timer-triggered refresh is pending', 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(); + requestSpy.mockClear(); + + // Hold the network request pending + let resolveNetworkRequest!: (value: any) => void; + requestSpy.mockReturnValueOnce( + new Promise(resolve => { + resolveNetworkRequest = resolve; + }), + ); + + // Advance to trigger the timer (43s) + await vi.advanceTimersByTimeAsync(44 * 1000); + + // Concurrent calls should all return cached 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); + + // Cleanup: resolve the pending request + resolveNetworkRequest({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + await vi.advanceTimersByTimeAsync(0); + }); + + it('continues returning tokens after timer-triggered 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(); + requestSpy.mockClear(); + + // Timer-triggered refresh fails + requestSpy.mockRejectedValueOnce(new Error('Network error')); + + // Advance to trigger timer (43s) and wait for it to complete + await vi.advanceTimersByTimeAsync(44 * 1000); + + // getToken should still return the cached token + const token = await session.getToken(); + expect(token).toEqual(mockJwt); + }); + + it('uses refreshed token after timer-triggered 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(); + requestSpy.mockClear(); + + // Timer-triggered refresh returns new token + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: newMockJwt }, status: 200 }); + + // Advance to trigger timer and wait for refresh to complete + await vi.advanceTimersByTimeAsync(44 * 1000); + + // 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('does not make API call when token has plenty of time 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..e2e4d403733 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,22 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { * Used for expiration and cleanup scheduling. */ createdAt?: Seconds; + /** + * Seconds before token expiration when background refresh should be triggered. + * Defaults to BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS if not provided. + */ + leewayInSeconds?: Seconds; + /** + * Callback to refresh this token before it expires. + * Called by the proactive refresh timer to trigger background refresh. + * If not provided, no refresh timer will be scheduled (e.g., for broadcast-received tokens). + */ + onRefresh?: () => void; + /** + * 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. @@ -33,13 +50,23 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { type Seconds = number; /** - * Internal cache value containing the entry, expiration metadata, and cleanup timer. + * Internal cache value containing the entry, expiration metadata, and timers. */ interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; + /** Timer for automatic cache cleanup when token expires */ timeoutId?: ReturnType; + /** Timer for proactive refresh before token enters leeway period */ + refreshTimeoutId?: ReturnType; +} + +/** + * Result from cache lookup containing the entry. + */ +export interface TokenCacheGetResult { + entry: TokenCacheEntry; } export interface TokenCache { @@ -56,13 +83,14 @@ 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. + * Forces synchronous refresh if token has less than one poller interval remaining. + * Proactive refresh is handled by timers scheduled when tokens are cached. * * @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 + * @returns Result with entry, or undefined if token is missing/expired/too close to expiration */ - get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheEntry | undefined; + get(cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined; /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. @@ -82,9 +110,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 }; @@ -170,11 +206,14 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } + if (value.refreshTimeoutId !== undefined) { + clearTimeout(value.refreshTimeoutId); + } }); cache.clear(); }; - const get = (cacheKeyJSON: TokenCacheKeyJSON, leeway = LEEWAY): TokenCacheEntry | undefined => { + const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined => { ensureBroadcastChannel(); const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); @@ -186,21 +225,23 @@ 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); } + if (value.refreshTimeoutId !== undefined) { + clearTimeout(value.refreshTimeoutId); + } cache.delete(cacheKey.toKey()); return; } - return value.entry; + // Proactive refresh is handled by timers scheduled in setInternal() + return { entry: value.entry }; }; /** @@ -249,9 +290,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( @@ -324,12 +365,18 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (cachedValue.timeoutId !== undefined) { clearTimeout(cachedValue.timeoutId); } + if (cachedValue.refreshTimeoutId !== undefined) { + clearTimeout(cachedValue.refreshTimeoutId); + } cache.delete(key); } }; 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(); @@ -339,6 +386,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const issuedAt = claims.iat; const expiresIn: Seconds = expiresAt - issuedAt; + value.createdAt = issuedAt; value.expiresIn = expiresIn; const timeoutId = setTimeout(deleteKey, expiresIn * 1000); @@ -350,6 +398,25 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { (timeoutId as any).unref(); } + // Schedule proactive refresh timer to fire before token enters leeway period + // This ensures new tokens are ready before the old one expires + const refreshLeadTime = 2; // Fire 2s before leeway starts + const minLeeway = POLLER_INTERVAL_IN_MS / 1000; // Minimum is poller interval (5s) + const leeway = Math.max(entry.leewayInSeconds ?? BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, minLeeway); + const refreshFireTime = expiresIn - leeway - refreshLeadTime; + + if (refreshFireTime > 0 && entry.onRefresh) { + const refreshTimeoutId = setTimeout(() => { + entry.onRefresh?.(); + }, refreshFireTime * 1000); + + value.refreshTimeoutId = refreshTimeoutId; + + if (typeof (refreshTimeoutId as any).unref === 'function') { + (refreshTimeoutId as any).unref(); + } + } + const channel = broadcastChannel; if (channel && options.broadcast) { const tokenRaw = newToken.getRawString(); diff --git a/packages/nextjs/src/errors.ts b/packages/nextjs/src/errors.ts index f263b29c26e..79a5f72553e 100644 --- a/packages/nextjs/src/errors.ts +++ b/packages/nextjs/src/errors.ts @@ -8,4 +8,4 @@ export { EmailLinkErrorCodeStatus, } from './client-boundary/hooks'; -export { isClerkAPIResponseError } from '@clerk/react/errors'; +export { ClerkOfflineError, isClerkAPIResponseError } from '@clerk/react/errors'; diff --git a/packages/nuxt/src/runtime/errors.ts b/packages/nuxt/src/runtime/errors.ts index 98366fc26b4..f34178321c4 100644 --- a/packages/nuxt/src/runtime/errors.ts +++ b/packages/nuxt/src/runtime/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isClerkRuntimeError, isEmailLinkError, diff --git a/packages/react-router/src/errors.ts b/packages/react-router/src/errors.ts index ab2d95ccdcc..6381944d22c 100644 --- a/packages/react-router/src/errors.ts +++ b/packages/react-router/src/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isEmailLinkError, isKnownError, diff --git a/packages/react/src/errors.ts b/packages/react/src/errors.ts index 1528fd83607..5939724894a 100644 --- a/packages/react/src/errors.ts +++ b/packages/react/src/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isClerkRuntimeError, isEmailLinkError, diff --git a/packages/shared/src/__tests__/error.spec.ts b/packages/shared/src/__tests__/error.spec.ts index d04a312977a..98fe6a85324 100644 --- a/packages/shared/src/__tests__/error.spec.ts +++ b/packages/shared/src/__tests__/error.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { ErrorThrowerOptions } from '../error'; -import { buildErrorThrower, ClerkRuntimeError, isClerkRuntimeError } from '../error'; +import { buildErrorThrower, ClerkOfflineError, ClerkRuntimeError, isClerkRuntimeError } from '../error'; describe('ErrorThrower', () => { const errorThrower = buildErrorThrower({ packageName: '@clerk/test-package' }); @@ -62,3 +62,45 @@ describe('ClerkRuntimeError', () => { expect(isClerkRuntimeError(clerkRuntimeError)).toEqual(true); }); }); + +describe('ClerkOfflineError', () => { + it('is an instance of ClerkRuntimeError', () => { + const error = new ClerkOfflineError('Network request failed'); + expect(error).toBeInstanceOf(ClerkRuntimeError); + expect(error.code).toBe('clerk_offline'); + }); + + describe('ClerkOfflineError.is() type guard', () => { + it('returns true for ClerkOfflineError instances', () => { + const error = new ClerkOfflineError('test'); + expect(ClerkOfflineError.is(error)).toBe(true); + }); + + it('returns true for ClerkRuntimeError with clerk_offline code', () => { + const error = new ClerkRuntimeError('test', { code: 'clerk_offline' }); + expect(ClerkOfflineError.is(error)).toBe(true); + }); + + it('returns false for other ClerkRuntimeError instances', () => { + const error = new ClerkRuntimeError('test', { code: 'other_code' }); + expect(ClerkOfflineError.is(error)).toBe(false); + }); + + it('returns false for regular Error instances', () => { + const error = new Error('test'); + expect(ClerkOfflineError.is(error)).toBe(false); + }); + + it('returns false for null', () => { + expect(ClerkOfflineError.is(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(ClerkOfflineError.is(undefined)).toBe(false); + }); + + it('returns false for non-error objects', () => { + expect(ClerkOfflineError.is({ message: 'test' })).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 328a363015e..b75e569a5db 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -4,6 +4,7 @@ export { ClerkAPIError, isClerkAPIError } from './errors/clerkApiError'; export { ClerkAPIResponseError, isClerkAPIResponseError } from './errors/clerkApiResponseError'; export { ClerkError, isClerkError } from './errors/clerkError'; export { MissingExpiredTokenError } from './errors/missingExpiredTokenError'; +export { ClerkOfflineError } from './errors/clerkOfflineError'; export { buildErrorThrower, type ErrorThrower, type ErrorThrowerOptions } from './errors/errorThrower'; diff --git a/packages/shared/src/errors/clerkOfflineError.ts b/packages/shared/src/errors/clerkOfflineError.ts new file mode 100644 index 00000000000..d7d90842621 --- /dev/null +++ b/packages/shared/src/errors/clerkOfflineError.ts @@ -0,0 +1,54 @@ +import { ClerkRuntimeError, isClerkRuntimeError } from './clerkRuntimeError'; + +/** + * Error thrown when a network request fails due to the client being offline. + * + * This error is thrown instead of returning `null` to make it explicit that + * the failure was due to network conditions, not authentication state. + * + * @example + * ```typescript + * try { + * const token = await session.getToken(); + * } catch (error) { + * if (ClerkOfflineError.is(error)) { + * // Handle offline scenario + * showOfflineScreen(); + * } + * } + * ``` + */ +export class ClerkOfflineError extends ClerkRuntimeError { + static kind = 'ClerkOfflineError'; + static readonly ERROR_CODE = 'clerk_offline' as const; + + constructor(message: string) { + super(message, { code: ClerkOfflineError.ERROR_CODE }); + Object.setPrototypeOf(this, ClerkOfflineError.prototype); + } + + /** + * Type guard to check if an error is a ClerkOfflineError. + * This checks both instanceof and the error code to support cross-bundle/cross-realm errors + * + * @example + * ```typescript + * try { + * const token = await session.getToken(); + * } catch (error) { + * if (ClerkOfflineError.is(error)) { + * // error is typed as ClerkOfflineError + * console.log('User is offline'); + * } + * } + * ``` + */ + static is(error: unknown): error is ClerkOfflineError { + if (error === null || error === undefined) { + return false; + } + return ( + error instanceof ClerkOfflineError || (isClerkRuntimeError(error) && error.code === ClerkOfflineError.ERROR_CODE) + ); + } +} diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts index b181df2ec74..25dec7c28cf 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -104,6 +104,9 @@ async function waitForClerk(): Promise { * * @throws {ClerkRuntimeError} When Clerk fails to load within timeout (code: `clerk_runtime_load_timeout`) * + * @throws {ClerkOfflineError} When the browser is offline and unable to fetch a token (code: `clerk_offline`). + * Use `ClerkOfflineError.is(error)` to check for this error type. + * * @example * ```typescript * // In an Axios interceptor diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 727c044ad48..4834e666465 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -339,10 +339,15 @@ export interface SessionTask { } export type GetTokenOptions = { - template?: string; - organizationId?: string; + /** + * The number of seconds before token expiration when background refresh should be triggered. + * Must be at least 5 seconds (the poller interval). + * @default 15 + */ leewayInSeconds?: number; + organizationId?: string; skipCache?: boolean; + template?: string; }; /** * @inline diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 6e0d04985b8..bbb5aac5442 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -2,6 +2,7 @@ exports[`errors public exports > should not change unexpectedly 1`] = ` [ + "ClerkOfflineError", "EmailLinkErrorCode", "EmailLinkErrorCodeStatus", "isClerkAPIResponseError", diff --git a/packages/tanstack-react-start/src/errors.ts b/packages/tanstack-react-start/src/errors.ts index ab2d95ccdcc..6381944d22c 100644 --- a/packages/tanstack-react-start/src/errors.ts +++ b/packages/tanstack-react-start/src/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isEmailLinkError, isKnownError, 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-offline-error.md b/packages/upgrade/src/versions/core-3/changes/gettoken-offline-error.md new file mode 100644 index 00000000000..2583975a461 --- /dev/null +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-offline-error.md @@ -0,0 +1,84 @@ +--- +title: '`getToken()` throws `ClerkOfflineError` when offline' +matcher: 'getToken\(|session\.getToken' +matcherFlags: 'm' +category: 'breaking' +--- + +`getToken()` now throws a `ClerkOfflineError` instead of returning `null` when the browser is offline. This change makes it explicit that the request failed due to network conditions, not because the user is signed out. + +### Before (Core 2) + +```typescript +const token = await session.getToken(); +if (token === null) { + // Ambiguous: could mean signed out OR offline +} +``` + +### After (Core 3) + +```typescript +import { ClerkOfflineError } from '@clerk/react/errors'; +// Or from other packages: '@clerk/nextjs/errors', '@clerk/vue/errors', etc. + +try { + const token = await session.getToken(); + // token is guaranteed to be a valid string if user is signed in +} catch (error) { + if (ClerkOfflineError.is(error)) { + // Handle offline scenario explicitly + showOfflineScreen(); + } else { + // Handle other errors + throw error; + } +} +``` + +### Using the Type Guard + +Use `ClerkOfflineError.is()` to check if an error is an offline error: + +```typescript +import { ClerkOfflineError } from '@clerk/react/errors'; + +try { + const token = await clerk.session?.getToken(); +} catch (error) { + if (ClerkOfflineError.is(error)) { + // TypeScript knows error is ClerkOfflineError here + console.log('User is offline'); + } +} +``` + +### Common Patterns + +**Pattern 1: Show offline UI** + +```typescript +catch (error) { + if (ClerkOfflineError.is(error)) { + showToast('You appear to be offline. Some features may be limited.'); + return; + } +} +``` + +**Pattern 2: Queue for retry** + +```typescript +catch (error) { + if (ClerkOfflineError.is(error)) { + retryQueue.add(() => fetchData()); + return; + } +} +``` + +### Migration Notes + +- If you were checking for `null` from `getToken()` to detect offline state, wrap the call in a try-catch and check for `ClerkOfflineError` +- The error is thrown after a short retry period (~15 seconds) to handle temporary network issues +- `getToken()` still returns `null` when the user is not signed in 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 }); +``` diff --git a/packages/vue/src/errors.ts b/packages/vue/src/errors.ts index ca64aeb9fbe..1b9934e3769 100644 --- a/packages/vue/src/errors.ts +++ b/packages/vue/src/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isClerkRuntimeError, isEmailLinkError,