diff --git a/.changeset/sdk-139-extract-key-resolver.md b/.changeset/sdk-139-extract-key-resolver.md new file mode 100644 index 00000000000..53abd9516a9 --- /dev/null +++ b/.changeset/sdk-139-extract-key-resolver.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Refactor the internal token cache so entry keys are derived through a dedicated `KeyResolver` module. This is an internal change with no effect on caching behavior or the public API. diff --git a/packages/clerk-js/src/core/__tests__/keyResolver.test.ts b/packages/clerk-js/src/core/__tests__/keyResolver.test.ts new file mode 100644 index 00000000000..d4938c2f1c0 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/keyResolver.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { createKeyResolver } from '../keyResolver'; + +describe('createKeyResolver', () => { + it('serializes a key as prefix::tokenId::audience', () => { + const resolver = createKeyResolver(); + expect(resolver.toKey({ tokenId: 'session_123', audience: 'aud' })).toBe('clerk::session_123::aud'); + }); + + it('defaults to the clerk prefix and an empty audience segment', () => { + const resolver = createKeyResolver(); + expect(resolver.toKey({ tokenId: 'session_123' })).toBe('clerk::session_123::'); + }); + + it('coalesces empty-string and undefined audience to the same key', () => { + const resolver = createKeyResolver(); + expect(resolver.toKey({ tokenId: 'session_123', audience: '' })).toBe(resolver.toKey({ tokenId: 'session_123' })); + }); + + it('produces distinct keys for different audiences of the same tokenId', () => { + const resolver = createKeyResolver(); + expect(resolver.toKey({ tokenId: 'same', audience: 'a' })).not.toBe( + resolver.toKey({ tokenId: 'same', audience: 'b' }), + ); + }); + + it('isolates an audience-scoped key from the no-audience key of the same id', () => { + const resolver = createKeyResolver(); + expect(resolver.toKey({ tokenId: 'same', audience: 'a' })).not.toBe(resolver.toKey({ tokenId: 'same' })); + }); + + it('respects a custom prefix', () => { + const resolver = createKeyResolver('custom'); + expect(resolver.toKey({ tokenId: 'session_123' })).toBe('custom::session_123::'); + }); +}); diff --git a/packages/clerk-js/src/core/keyResolver.ts b/packages/clerk-js/src/core/keyResolver.ts new file mode 100644 index 00000000000..9cc3de6c9a9 --- /dev/null +++ b/packages/clerk-js/src/core/keyResolver.ts @@ -0,0 +1,35 @@ +/** + * Derives the opaque string keys used to address entries in the token store, + * keeping key construction out of the storage layer. + */ + +const KEY_PREFIX = 'clerk'; +const DELIMITER = '::'; + +/** + * Identifies a cached token entry by tokenId and optional audience. + */ +export interface TokenCacheKeyJSON { + audience?: string; + tokenId: string; +} + +export interface KeyResolver { + /** + * Serializes a key to its string form `prefix::tokenId::audience`. + * Empty-string and undefined audience collapse to the same key. + */ + toKey(key: TokenCacheKeyJSON): string; +} + +/** + * Creates a {@link KeyResolver} bound to a key prefix. + * + * `audience` is currently unused by production (no caller sets it) but kept as a + * key dimension so an audience-scoped token can coexist with the session token + * of the same id. If it is revived, the cross-tab broadcast and Web Locks lock + * name must derive from the same key so all three agree. + */ +export const createKeyResolver = (prefix: string = KEY_PREFIX): KeyResolver => ({ + toKey: ({ tokenId, audience }) => [prefix, tokenId, audience || ''].join(DELIMITER), +}); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 400fb17d8be..217a89b1b04 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -4,18 +4,11 @@ import { debugLogger } from '@/utils/debug'; import { TokenId } from '@/utils/tokenId'; import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; +import { createKeyResolver, type TokenCacheKeyJSON } from './keyResolver'; import { Token } from './resources/internal'; import { pickFreshestJwt } from './tokenFreshness'; import { createTokenStore } from './tokenStore'; -/** - * Identifies a cached token entry by tokenId and optional audience. - */ -interface TokenCacheKeyJSON { - audience?: string; - tokenId: string; -} - /** * Cache entry containing token metadata and resolver. * Extends TokenCacheKeyJSON with additional properties for expiration tracking and token retrieval. @@ -105,9 +98,6 @@ export interface TokenCache { size(): number; } -const KEY_PREFIX = 'clerk'; -const DELIMITER = '::'; - /** * Default seconds before token expiration to trigger background refresh. * This threshold accounts for timer jitter, SafeLock contention (~5s), network latency, @@ -122,36 +112,6 @@ const BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS = 15; const BROADCAST = { broadcast: true }; const NO_BROADCAST = { broadcast: false }; -/** - * Converts between cache key objects and string representations. - * Format: `prefix::tokenId::audience` - */ -export class TokenCacheKey { - /** - * Parses a cache key string into a TokenCacheKey instance. - */ - static fromKey(key: string): TokenCacheKey { - const [prefix, tokenId, audience = ''] = key.split(DELIMITER); - return new TokenCacheKey(prefix, { audience, tokenId }); - } - - constructor( - public prefix: string, - public data: TokenCacheKeyJSON, - ) { - this.prefix = prefix; - this.data = data; - } - - /** - * Converts the key to its string representation for Map storage. - */ - toKey(): string { - const { tokenId, audience } = this.data; - return [this.prefix, tokenId, audience || ''].join(DELIMITER); - } -} - /** * Message format for BroadcastChannel token synchronization between tabs. */ @@ -173,8 +133,9 @@ const generateTabId = (): string => { * Automatically manages token expiration and cleanup via scheduled timeouts. * BroadcastChannel support is enabled whenever the environment provides it. */ -const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { +const MemoryTokenCache = (prefix?: string): TokenCache => { const store = createTokenStore(); + const keyResolver = createKeyResolver(prefix); const tabId = generateTabId(); let broadcastChannel: BroadcastChannel | null = null; @@ -213,8 +174,8 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined => { ensureBroadcastChannel(); - const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); - const value = store.get(cacheKey.toKey()); + const key = keyResolver.toKey(cacheKeyJSON); + const value = store.get(key); if (!value) { return; @@ -233,7 +194,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (value.refreshTimeoutId !== undefined) { clearTimeout(value.refreshTimeoutId); } - store.delete(cacheKey.toKey()); + store.delete(key); return; } @@ -344,13 +305,11 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { * @param options - Configuration for cache behavior; broadcast controls whether to notify other tabs */ const setInternal = (entry: TokenCacheEntry, options: { broadcast: boolean } = BROADCAST) => { - const cacheKey = new TokenCacheKey(prefix, { + const key = keyResolver.toKey({ audience: entry.audience, tokenId: entry.tokenId, }); - const key = cacheKey.toKey(); - // 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).