diff --git a/.changeset/decouple-tokencache-store.md b/.changeset/decouple-tokencache-store.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/decouple-tokencache-store.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index baad7691c7d..aaa0a700ad0 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -1586,4 +1586,115 @@ describe('SessionTokenCache', () => { expect(SessionTokenCache.size()).toBe(1); }); }); + + // --- SDK-117 characterization backfill --------------------------------- + // These lock in current, intended behavior before the cache is split into + // separate storage / scheduler / cross-tab collaborators. They are the + // regression bar for that refactor, covering the gaps the audit surfaced: + // BroadcastChannel lifecycle, broadcast-failure resilience, graceful + // degradation without BroadcastChannel, and audience key coalescing. + + describe('BroadcastChannel lifecycle', () => { + it('close() closes the underlying channel', () => { + expect(mockBroadcastChannel.close).not.toHaveBeenCalled(); + + SessionTokenCache.close(); + + expect(mockBroadcastChannel.close).toHaveBeenCalledTimes(1); + }); + + it('lazily reopens a new channel on the next operation after close()', () => { + SessionTokenCache.close(); + (global.BroadcastChannel as unknown as ReturnType).mockClear(); + + // get() calls ensureBroadcastChannel(), which must reconstruct the channel + SessionTokenCache.get({ tokenId: 'anything' }); + + expect(global.BroadcastChannel).toHaveBeenCalledTimes(1); + }); + }); + + describe('graceful degradation without BroadcastChannel', () => { + it('continues to cache and retrieve tokens when BroadcastChannel is unavailable', async () => { + // Simulate a runtime that does not provide BroadcastChannel. + SessionTokenCache.close(); + (global as any).BroadcastChannel = undefined; + + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + const token = new Token({ id: 'no-bc-token', jwt, object: 'token' }); + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'no-bc-token' }; + + expect(() => SessionTokenCache.set({ ...key, tokenResolver })).not.toThrow(); + await tokenResolver; + + const result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('no-bc-token'); + }); + }); + + describe('broadcast resilience', () => { + // KNOWN BUG (SDK-119): a broadcast side-effect failure currently evicts the + // freshly cached token, because the postMessage throw propagates into the + // setInternal `.catch(deleteKey)`. This asserts the intended contract and is + // marked `it.fails` so the suite stays green while documenting the bug; it + // flips red (forcing removal of `.fails`) once SDK-119 lands the try/catch. + it.fails('a failing postMessage does not evict the freshly cached token', async () => { + // postMessage can throw (e.g. InvalidStateError if the channel races a + // close). Broadcasting is a side effect; a failure must not destroy the + // cache entry that was just stored. + mockBroadcastChannel.postMessage.mockImplementationOnce(() => { + throw new Error('channel closed'); + }); + + const futureExp = Math.floor(Date.now() / 1000) + 3600; + const tokenResolver = Promise.resolve({ + getRawString: () => mockJwt, + jwt: { claims: { exp: futureExp, iat: 1675876730, sid: 'session_123' } }, + } as any); + + SessionTokenCache.set({ tokenId: 'session_123', tokenResolver }); + await tokenResolver; + await Promise.resolve(); + + const result = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(result?.entry.tokenId).toBe('session_123'); + }); + }); + + describe('audience key coalescing', () => { + it('treats empty-string audience and undefined audience as the same entry', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + const token = new Token({ id: 'aud-coalesce', jwt, object: 'token' }); + const tokenResolver = Promise.resolve(token); + + SessionTokenCache.set({ audience: '', tokenId: 'aud-coalesce', tokenResolver }); + await tokenResolver; + + // `audience || ''` collapses '' and undefined to the same key. + expect(SessionTokenCache.get({ tokenId: 'aud-coalesce' })?.entry.tokenId).toBe('aud-coalesce'); + expect(SessionTokenCache.get({ audience: '', tokenId: 'aud-coalesce' })?.entry.tokenId).toBe('aud-coalesce'); + expect(SessionTokenCache.size()).toBe(1); + }); + + it('isolates an audience-scoped token from the no-audience token of the same id', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const tokenA = new Token({ id: 'aud-split', jwt: createJwtWithTtl(nowSeconds, 60), object: 'token' }); + const tokenB = new Token({ id: 'aud-split', jwt: createJwtWithTtl(nowSeconds, 60), object: 'token' }); + + SessionTokenCache.set({ tokenId: 'aud-split', tokenResolver: Promise.resolve(tokenA) }); + SessionTokenCache.set({ + audience: 'https://api.example.com', + tokenId: 'aud-split', + tokenResolver: Promise.resolve(tokenB), + }); + await Promise.resolve(); + + expect(SessionTokenCache.size()).toBe(2); + expect(SessionTokenCache.get({ tokenId: 'aud-split' })?.entry).toBeDefined(); + expect(SessionTokenCache.get({ audience: 'https://api.example.com', tokenId: 'aud-split' })?.entry).toBeDefined(); + }); + }); }); diff --git a/packages/clerk-js/src/core/__tests__/tokenStore.test.ts b/packages/clerk-js/src/core/__tests__/tokenStore.test.ts new file mode 100644 index 00000000000..1d8b3181529 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/tokenStore.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTokenStore } from '../tokenStore'; + +describe('createTokenStore', () => { + it('stores and retrieves values by key', () => { + const store = createTokenStore(); + store.set('a', 1); + expect(store.get('a')).toBe(1); + }); + + it('returns undefined for a missing key', () => { + const store = createTokenStore(); + expect(store.get('missing')).toBeUndefined(); + }); + + it('overwrites an existing key', () => { + const store = createTokenStore(); + store.set('k', 'first'); + store.set('k', 'second'); + expect(store.get('k')).toBe('second'); + expect(store.size()).toBe(1); + }); + + it('deletes a key', () => { + const store = createTokenStore(); + store.set('a', 1); + store.delete('a'); + expect(store.get('a')).toBeUndefined(); + expect(store.size()).toBe(0); + }); + + it('treats delete on a missing key as a no-op', () => { + const store = createTokenStore(); + expect(() => store.delete('nope')).not.toThrow(); + expect(store.size()).toBe(0); + }); + + it('clears all entries', () => { + const store = createTokenStore(); + store.set('a', 1); + store.set('b', 2); + store.clear(); + expect(store.size()).toBe(0); + expect(store.get('a')).toBeUndefined(); + }); + + it('reports the number of entries', () => { + const store = createTokenStore(); + expect(store.size()).toBe(0); + store.set('a', 1); + store.set('b', 2); + expect(store.size()).toBe(2); + }); + + it('iterates every entry with forEach', () => { + const store = createTokenStore(); + store.set('a', 1); + store.set('b', 2); + + const seen = vi.fn(); + store.forEach(seen); + + expect(seen).toHaveBeenCalledTimes(2); + expect(seen).toHaveBeenCalledWith(1, 'a'); + expect(seen).toHaveBeenCalledWith(2, 'b'); + }); + + it('keeps reference identity for object values behind one generic interface', () => { + const store = createTokenStore<{ raw: string }>(); + const value = { raw: 'token' }; + store.set('x', value); + expect(store.get('x')).toBe(value); + }); +}); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index dd00df3dc64..61124960f83 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -6,6 +6,7 @@ import { TokenId } from '@/utils/tokenId'; import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { Token } from './resources/internal'; import { pickFreshestJwt } from './tokenFreshness'; +import { createTokenStore } from './tokenStore'; /** * Identifies a cached token entry by tokenId and optional audience. @@ -173,7 +174,7 @@ const generateTabId = (): string => { * BroadcastChannel support is enabled whenever the environment provides it. */ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { - const cache = new Map(); + const store = createTokenStore(); const tabId = generateTabId(); let broadcastChannel: BroadcastChannel | null = null; @@ -198,7 +199,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { ensureBroadcastChannel(); const clear = () => { - cache.forEach(value => { + store.forEach(value => { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } @@ -206,14 +207,14 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { clearTimeout(value.refreshTimeoutId); } }); - cache.clear(); + store.clear(); }; const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined => { ensureBroadcastChannel(); const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); - const value = cache.get(cacheKey.toKey()); + const value = store.get(cacheKey.toKey()); if (!value) { return; @@ -232,7 +233,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (value.refreshTimeoutId !== undefined) { clearTimeout(value.refreshTimeoutId); } - cache.delete(cacheKey.toKey()); + store.delete(cacheKey.toKey()); return; } @@ -353,7 +354,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { // Clear timers from any existing entry for this key to prevent orphaned // refresh timers from accumulating across set() calls (e.g., from // #hydrateCache during _updateClient AND #refreshTokenInBackground). - const existing = cache.get(key); + const existing = store.get(key); clearTimeout(existing?.timeoutId); clearTimeout(existing?.refreshTimeoutId); @@ -362,7 +363,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; const deleteKey = () => { - const cachedValue = cache.get(key); + const cachedValue = store.get(key); if (cachedValue === value) { if (cachedValue.timeoutId !== undefined) { clearTimeout(cachedValue.timeoutId); @@ -370,11 +371,11 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (cachedValue.refreshTimeoutId !== undefined) { clearTimeout(cachedValue.refreshTimeoutId); } - cache.delete(key); + store.delete(key); } }; - cache.set(key, value); + store.set(key, value); entry.tokenResolver .then(newToken => { @@ -382,7 +383,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { // was pending, bail out to avoid installing orphaned timers. Monotonic // replacement is enforced at the read sites (cookie + broadcast + Session) // where the user-visible state lives. - if (cache.get(key) !== value) { + if (store.get(key) !== value) { return; } @@ -483,7 +484,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { }; const size = () => { - return cache.size; + return store.size(); }; return { clear, close, get, set, size }; diff --git a/packages/clerk-js/src/core/tokenStore.ts b/packages/clerk-js/src/core/tokenStore.ts new file mode 100644 index 00000000000..523141a068c --- /dev/null +++ b/packages/clerk-js/src/core/tokenStore.ts @@ -0,0 +1,45 @@ +/** + * Generic in-memory key/value store backing the token cache. + * + * Pure storage: no timers, no BroadcastChannel, and no JWT knowledge. The cache + * layers proactive-refresh scheduling and cross-tab synchronization on top. + * Synchronous by design — the in-memory path never needs to be async — modelled + * on auth0-spa-js's synchronous cache interface. + */ +export interface TokenStore { + get(key: string): V | undefined; + set(key: string, value: V): void; + delete(key: string): void; + clear(): void; + /** + * Iterates over every stored entry. Used by the cache to release per-entry + * timers before clearing. + */ + forEach(callback: (value: V, key: string) => void): void; + size(): number; +} + +/** + * Creates an empty in-memory {@link TokenStore} backed by a Map. + */ +export const createTokenStore = (): TokenStore => { + const map = new Map(); + + return { + get: key => map.get(key), + set: (key, value) => { + map.set(key, value); + }, + delete: key => { + map.delete(key); + }, + clear: () => { + map.clear(); + }, + forEach: callback => { + // Wrap so the underlying Map reference is not leaked as a third argument. + map.forEach((value, key) => callback(value, key)); + }, + size: () => map.size, + }; +};