Skip to content

Commit c5697d7

Browse files
refactor(js): extract key derivation into a KeyResolver (#9032)
Co-authored-by: Robert Soriano <sorianorobertc@gmail.com>
1 parent 2914c2c commit c5697d7

4 files changed

Lines changed: 84 additions & 48 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
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.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { createKeyResolver } from '../keyResolver';
4+
5+
describe('createKeyResolver', () => {
6+
it('serializes a key as prefix::tokenId::audience', () => {
7+
const resolver = createKeyResolver();
8+
expect(resolver.toKey({ tokenId: 'session_123', audience: 'aud' })).toBe('clerk::session_123::aud');
9+
});
10+
11+
it('defaults to the clerk prefix and an empty audience segment', () => {
12+
const resolver = createKeyResolver();
13+
expect(resolver.toKey({ tokenId: 'session_123' })).toBe('clerk::session_123::');
14+
});
15+
16+
it('coalesces empty-string and undefined audience to the same key', () => {
17+
const resolver = createKeyResolver();
18+
expect(resolver.toKey({ tokenId: 'session_123', audience: '' })).toBe(resolver.toKey({ tokenId: 'session_123' }));
19+
});
20+
21+
it('produces distinct keys for different audiences of the same tokenId', () => {
22+
const resolver = createKeyResolver();
23+
expect(resolver.toKey({ tokenId: 'same', audience: 'a' })).not.toBe(
24+
resolver.toKey({ tokenId: 'same', audience: 'b' }),
25+
);
26+
});
27+
28+
it('isolates an audience-scoped key from the no-audience key of the same id', () => {
29+
const resolver = createKeyResolver();
30+
expect(resolver.toKey({ tokenId: 'same', audience: 'a' })).not.toBe(resolver.toKey({ tokenId: 'same' }));
31+
});
32+
33+
it('respects a custom prefix', () => {
34+
const resolver = createKeyResolver('custom');
35+
expect(resolver.toKey({ tokenId: 'session_123' })).toBe('custom::session_123::');
36+
});
37+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Derives the opaque string keys used to address entries in the token store,
3+
* keeping key construction out of the storage layer.
4+
*/
5+
6+
const KEY_PREFIX = 'clerk';
7+
const DELIMITER = '::';
8+
9+
/**
10+
* Identifies a cached token entry by tokenId and optional audience.
11+
*/
12+
export interface TokenCacheKeyJSON {
13+
audience?: string;
14+
tokenId: string;
15+
}
16+
17+
export interface KeyResolver {
18+
/**
19+
* Serializes a key to its string form `prefix::tokenId::audience`.
20+
* Empty-string and undefined audience collapse to the same key.
21+
*/
22+
toKey(key: TokenCacheKeyJSON): string;
23+
}
24+
25+
/**
26+
* Creates a {@link KeyResolver} bound to a key prefix.
27+
*
28+
* `audience` is currently unused by production (no caller sets it) but kept as a
29+
* key dimension so an audience-scoped token can coexist with the session token
30+
* of the same id. If it is revived, the cross-tab broadcast and Web Locks lock
31+
* name must derive from the same key so all three agree.
32+
*/
33+
export const createKeyResolver = (prefix: string = KEY_PREFIX): KeyResolver => ({
34+
toKey: ({ tokenId, audience }) => [prefix, tokenId, audience || ''].join(DELIMITER),
35+
});

packages/clerk-js/src/core/tokenCache.ts

Lines changed: 7 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,11 @@ import { debugLogger } from '@/utils/debug';
44
import { TokenId } from '@/utils/tokenId';
55

66
import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller';
7+
import { createKeyResolver, type TokenCacheKeyJSON } from './keyResolver';
78
import { Token } from './resources/internal';
89
import { pickFreshestJwt } from './tokenFreshness';
910
import { createTokenStore } from './tokenStore';
1011

11-
/**
12-
* Identifies a cached token entry by tokenId and optional audience.
13-
*/
14-
interface TokenCacheKeyJSON {
15-
audience?: string;
16-
tokenId: string;
17-
}
18-
1912
/**
2013
* Cache entry containing token metadata and resolver.
2114
* Extends TokenCacheKeyJSON with additional properties for expiration tracking and token retrieval.
@@ -105,9 +98,6 @@ export interface TokenCache {
10598
size(): number;
10699
}
107100

108-
const KEY_PREFIX = 'clerk';
109-
const DELIMITER = '::';
110-
111101
/**
112102
* Default seconds before token expiration to trigger background refresh.
113103
* This threshold accounts for timer jitter, SafeLock contention (~5s), network latency,
@@ -122,36 +112,6 @@ const BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS = 15;
122112
const BROADCAST = { broadcast: true };
123113
const NO_BROADCAST = { broadcast: false };
124114

125-
/**
126-
* Converts between cache key objects and string representations.
127-
* Format: `prefix::tokenId::audience`
128-
*/
129-
export class TokenCacheKey {
130-
/**
131-
* Parses a cache key string into a TokenCacheKey instance.
132-
*/
133-
static fromKey(key: string): TokenCacheKey {
134-
const [prefix, tokenId, audience = ''] = key.split(DELIMITER);
135-
return new TokenCacheKey(prefix, { audience, tokenId });
136-
}
137-
138-
constructor(
139-
public prefix: string,
140-
public data: TokenCacheKeyJSON,
141-
) {
142-
this.prefix = prefix;
143-
this.data = data;
144-
}
145-
146-
/**
147-
* Converts the key to its string representation for Map storage.
148-
*/
149-
toKey(): string {
150-
const { tokenId, audience } = this.data;
151-
return [this.prefix, tokenId, audience || ''].join(DELIMITER);
152-
}
153-
}
154-
155115
/**
156116
* Message format for BroadcastChannel token synchronization between tabs.
157117
*/
@@ -173,8 +133,9 @@ const generateTabId = (): string => {
173133
* Automatically manages token expiration and cleanup via scheduled timeouts.
174134
* BroadcastChannel support is enabled whenever the environment provides it.
175135
*/
176-
const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
136+
const MemoryTokenCache = (prefix?: string): TokenCache => {
177137
const store = createTokenStore<TokenCacheValue>();
138+
const keyResolver = createKeyResolver(prefix);
178139
const tabId = generateTabId();
179140

180141
let broadcastChannel: BroadcastChannel | null = null;
@@ -213,8 +174,8 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
213174
const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined => {
214175
ensureBroadcastChannel();
215176

216-
const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON);
217-
const value = store.get(cacheKey.toKey());
177+
const key = keyResolver.toKey(cacheKeyJSON);
178+
const value = store.get(key);
218179

219180
if (!value) {
220181
return;
@@ -233,7 +194,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
233194
if (value.refreshTimeoutId !== undefined) {
234195
clearTimeout(value.refreshTimeoutId);
235196
}
236-
store.delete(cacheKey.toKey());
197+
store.delete(key);
237198
return;
238199
}
239200

@@ -344,13 +305,11 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
344305
* @param options - Configuration for cache behavior; broadcast controls whether to notify other tabs
345306
*/
346307
const setInternal = (entry: TokenCacheEntry, options: { broadcast: boolean } = BROADCAST) => {
347-
const cacheKey = new TokenCacheKey(prefix, {
308+
const key = keyResolver.toKey({
348309
audience: entry.audience,
349310
tokenId: entry.tokenId,
350311
});
351312

352-
const key = cacheKey.toKey();
353-
354313
// Clear timers from any existing entry for this key to prevent orphaned
355314
// refresh timers from accumulating across set() calls (e.g., from
356315
// #hydrateCache during _updateClient AND #refreshTokenInBackground).

0 commit comments

Comments
 (0)