Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
111 changes: 111 additions & 0 deletions packages/clerk-js/src/core/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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<TokenResource>(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<TokenResource>(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<TokenResource>(tokenA) });
SessionTokenCache.set({
audience: 'https://api.example.com',
tokenId: 'aud-split',
tokenResolver: Promise.resolve<TokenResource>(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();
});
});
});
75 changes: 75 additions & 0 deletions packages/clerk-js/src/core/__tests__/tokenStore.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>();
store.set('a', 1);
expect(store.get('a')).toBe(1);
});

it('returns undefined for a missing key', () => {
const store = createTokenStore<number>();
expect(store.get('missing')).toBeUndefined();
});

it('overwrites an existing key', () => {
const store = createTokenStore<string>();
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<number>();
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<number>();
expect(() => store.delete('nope')).not.toThrow();
expect(store.size()).toBe(0);
});

it('clears all entries', () => {
const store = createTokenStore<number>();
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<number>();
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<number>();
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);
});
});
23 changes: 12 additions & 11 deletions packages/clerk-js/src/core/tokenCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, TokenCacheValue>();
const store = createTokenStore<TokenCacheValue>();
const tabId = generateTabId();

let broadcastChannel: BroadcastChannel | null = null;
Expand All @@ -198,22 +199,22 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
ensureBroadcastChannel();

const clear = () => {
cache.forEach(value => {
store.forEach(value => {
if (value.timeoutId !== undefined) {
clearTimeout(value.timeoutId);
}
if (value.refreshTimeoutId !== undefined) {
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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);

Expand All @@ -362,27 +363,27 @@ 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);
}
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 => {
// If this entry was overwritten by a newer set() call while our promise
// 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;
}

Expand Down Expand Up @@ -483,7 +484,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
};

const size = () => {
return cache.size;
return store.size();
};

return { clear, close, get, set, size };
Expand Down
45 changes: 45 additions & 0 deletions packages/clerk-js/src/core/tokenStore.ts
Original file line number Diff line number Diff line change
@@ -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<V> {
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 = <V>(): TokenStore<V> => {
const map = new Map<string, V>();

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,
};
};
Loading