Skip to content

Commit ca20b97

Browse files
committed
Move locking into getToken()
1 parent b991f29 commit ca20b97

File tree

5 files changed

+182
-167
lines changed

5 files changed

+182
-167
lines changed

.changeset/fix-token-refresh-race-condition.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,13 @@
22
"@clerk/clerk-js": patch
33
---
44

5-
Fix race condition where multiple browser tabs could fetch session tokens simultaneously. The `refreshTokenOnFocus` handler now uses the same cross-tab lock as the session token poller, preventing duplicate API calls when switching between tabs or when focus events fire while another tab is already refreshing the token.
5+
Fix race condition where multiple browser tabs could fetch session tokens simultaneously.
66

7+
Key changes:
8+
- `getToken()` now uses a cross-tab lock (via Web Locks API or localStorage fallback) to coordinate token refresh operations
9+
- Per-tokenId locking allows different token types (different orgs, JWT templates) to be fetched in parallel while preventing duplicates for the same token
10+
- Double-checked locking pattern: cache is checked before and after acquiring the lock, so tabs that wait will find the token already cached by the tab that fetched it
11+
- Graceful timeout handling: if lock acquisition times out (~5 seconds), the operation proceeds in degraded mode rather than failing
12+
- Removed redundant lock from SessionCookiePoller since coordination is now handled within `getToken()` itself
13+
14+
This ensures all callers of `getToken()` (pollers, focus handlers, user code) automatically benefit from cross-tab coordination.

packages/clerk-js/src/core/auth/SessionCookiePoller.ts

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,27 @@
11
import { createWorkerTimers } from '@clerk/shared/workerTimers';
22

3-
import type { SafeLockReturn } from './safeLock';
4-
import { SafeLock } from './safeLock';
5-
6-
export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
73
const INTERVAL_IN_MS = 5 * 1_000;
84

95
/**
10-
* Polls for session token refresh at regular intervals with cross-tab coordination.
11-
*
12-
* @example
13-
* ```typescript
14-
* // Create a shared lock for coordination with focus handlers
15-
* const sharedLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
16-
*
17-
* // Poller uses the shared lock
18-
* const poller = new SessionCookiePoller(sharedLock);
19-
* poller.startPollingForSessionToken(() => refreshToken());
6+
* Polls for session token refresh at regular intervals.
207
*
21-
* // Focus handler can use the same lock to prevent races
22-
* window.addEventListener('focus', () => {
23-
* sharedLock.acquireLockAndRun(() => refreshToken());
24-
* });
25-
* ```
8+
* Note: Cross-tab coordination is handled within Session.getToken() itself,
9+
* so this poller simply triggers the refresh callback without additional locking.
2610
*/
2711
export class SessionCookiePoller {
28-
private lock: SafeLockReturn;
2912
private workerTimers = createWorkerTimers();
3013
private timerId: ReturnType<typeof this.workerTimers.setInterval> | null = null;
3114
// Disallows for multiple `startPollingForSessionToken()` calls before `callback` is executed.
3215
private initiated = false;
3316

34-
constructor(lock?: SafeLockReturn) {
35-
this.lock = lock ?? SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
36-
}
37-
3817
public startPollingForSessionToken(cb: () => Promise<unknown>): void {
3918
if (this.timerId || this.initiated) {
4019
return;
4120
}
4221

4322
const run = async () => {
4423
this.initiated = true;
45-
await this.lock.acquireLockAndRun(cb);
24+
await cb();
4625
this.timerId = this.workerTimers.setTimeout(run, INTERVAL_IN_MS);
4726
};
4827

packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts

Lines changed: 69 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

3-
import type { SafeLockReturn } from '../safeLock';
43
import { SessionCookiePoller } from '../SessionCookiePoller';
54

65
describe('SessionCookiePoller', () => {
@@ -13,132 +12,129 @@ describe('SessionCookiePoller', () => {
1312
vi.restoreAllMocks();
1413
});
1514

16-
describe('shared lock coordination', () => {
17-
it('accepts an external lock for coordination with other components', () => {
18-
const sharedLock: SafeLockReturn = {
19-
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
20-
};
21-
22-
const poller = new SessionCookiePoller(sharedLock);
15+
describe('startPollingForSessionToken', () => {
16+
it('executes callback immediately on start', async () => {
17+
const poller = new SessionCookiePoller();
2318
const callback = vi.fn().mockResolvedValue(undefined);
2419

2520
poller.startPollingForSessionToken(callback);
2621

27-
// Verify the shared lock is used
28-
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback);
22+
// Flush microtasks to let the async run() execute
23+
await Promise.resolve();
24+
25+
expect(callback).toHaveBeenCalledTimes(1);
2926

3027
poller.stopPollingForSessionToken();
3128
});
3229

33-
it('creates internal lock when none provided (backward compatible)', () => {
34-
// Should not throw when no lock is provided
30+
it('prevents multiple concurrent polling sessions', async () => {
3531
const poller = new SessionCookiePoller();
36-
expect(poller).toBeInstanceOf(SessionCookiePoller);
37-
});
38-
39-
it('enables focus handler and poller to share the same lock', () => {
40-
// This test demonstrates the shared lock pattern used in AuthCookieService
41-
const sharedLock: SafeLockReturn = {
42-
acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise<unknown>) => {
43-
return cb();
44-
}),
45-
};
46-
47-
const poller = new SessionCookiePoller(sharedLock);
48-
const pollerCallback = vi.fn().mockResolvedValue('poller-result');
32+
const callback = vi.fn().mockResolvedValue(undefined);
4933

50-
// Poller uses the shared lock
51-
poller.startPollingForSessionToken(pollerCallback);
34+
poller.startPollingForSessionToken(callback);
35+
poller.startPollingForSessionToken(callback); // Second call should be ignored
5236

53-
// Simulate focus handler also using the shared lock (like AuthCookieService does)
54-
const focusCallback = vi.fn().mockResolvedValue('focus-result');
55-
void sharedLock.acquireLockAndRun(focusCallback);
37+
await Promise.resolve();
5638

57-
// Both should use the same lock instance
58-
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2);
59-
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(pollerCallback);
60-
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(focusCallback);
39+
expect(callback).toHaveBeenCalledTimes(1);
6140

6241
poller.stopPollingForSessionToken();
6342
});
6443
});
6544

66-
describe('startPollingForSessionToken', () => {
67-
it('executes callback immediately on start', () => {
68-
const sharedLock: SafeLockReturn = {
69-
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
70-
};
71-
72-
const poller = new SessionCookiePoller(sharedLock);
45+
describe('stopPollingForSessionToken', () => {
46+
it('stops polling when called', async () => {
47+
const poller = new SessionCookiePoller();
7348
const callback = vi.fn().mockResolvedValue(undefined);
7449

7550
poller.startPollingForSessionToken(callback);
51+
await Promise.resolve();
7652

77-
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback);
53+
expect(callback).toHaveBeenCalledTimes(1);
7854

7955
poller.stopPollingForSessionToken();
80-
});
81-
82-
it('prevents multiple concurrent polling sessions', () => {
83-
const sharedLock: SafeLockReturn = {
84-
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
85-
};
86-
87-
const poller = new SessionCookiePoller(sharedLock);
88-
const callback = vi.fn().mockResolvedValue(undefined);
8956

90-
poller.startPollingForSessionToken(callback);
91-
poller.startPollingForSessionToken(callback); // Second call should be ignored
92-
93-
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1);
57+
// Advance time - callback should not be called again
58+
await vi.advanceTimersByTimeAsync(10000);
9459

95-
poller.stopPollingForSessionToken();
60+
expect(callback).toHaveBeenCalledTimes(1);
9661
});
97-
});
9862

99-
describe('stopPollingForSessionToken', () => {
10063
it('allows restart after stop', async () => {
101-
const sharedLock: SafeLockReturn = {
102-
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
103-
};
104-
105-
const poller = new SessionCookiePoller(sharedLock);
64+
const poller = new SessionCookiePoller();
10665
const callback = vi.fn().mockResolvedValue(undefined);
10766

10867
// Start and stop
10968
poller.startPollingForSessionToken(callback);
69+
await Promise.resolve();
11070
poller.stopPollingForSessionToken();
11171

112-
// Clear mock to check restart
113-
vi.mocked(sharedLock.acquireLockAndRun).mockClear();
72+
expect(callback).toHaveBeenCalledTimes(1);
11473

11574
// Should be able to start again
11675
poller.startPollingForSessionToken(callback);
117-
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1);
76+
await Promise.resolve();
77+
78+
expect(callback).toHaveBeenCalledTimes(2);
11879

11980
poller.stopPollingForSessionToken();
12081
});
12182
});
12283

12384
describe('polling interval', () => {
12485
it('schedules next poll after callback completes', async () => {
125-
const sharedLock: SafeLockReturn = {
126-
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
127-
};
128-
129-
const poller = new SessionCookiePoller(sharedLock);
86+
const poller = new SessionCookiePoller();
13087
const callback = vi.fn().mockResolvedValue(undefined);
13188

13289
poller.startPollingForSessionToken(callback);
13390

13491
// Initial call
135-
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1);
92+
await Promise.resolve();
93+
expect(callback).toHaveBeenCalledTimes(1);
13694

13795
// Wait for first interval (5 seconds)
13896
await vi.advanceTimersByTimeAsync(5000);
13997

14098
// Should have scheduled another call
141-
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2);
99+
expect(callback).toHaveBeenCalledTimes(2);
100+
101+
// Another interval
102+
await vi.advanceTimersByTimeAsync(5000);
103+
expect(callback).toHaveBeenCalledTimes(3);
104+
105+
poller.stopPollingForSessionToken();
106+
});
107+
108+
it('waits for callback to complete before scheduling next poll', async () => {
109+
const poller = new SessionCookiePoller();
110+
111+
let resolveCallback: () => void;
112+
const callbackPromise = new Promise<void>(resolve => {
113+
resolveCallback = resolve;
114+
});
115+
const callback = vi.fn().mockReturnValue(callbackPromise);
116+
117+
poller.startPollingForSessionToken(callback);
118+
119+
// Let the first call start
120+
await Promise.resolve();
121+
expect(callback).toHaveBeenCalledTimes(1);
122+
123+
// Advance time while callback is still running - should NOT schedule next poll
124+
// because the callback promise hasn't resolved yet
125+
await vi.advanceTimersByTimeAsync(5000);
126+
127+
// Should still only be 1 call since previous call hasn't completed
128+
expect(callback).toHaveBeenCalledTimes(1);
129+
130+
// Complete the callback
131+
resolveCallback!();
132+
await Promise.resolve();
133+
134+
// Now advance time for the next interval
135+
await vi.advanceTimersByTimeAsync(5000);
136+
137+
expect(callback).toHaveBeenCalledTimes(2);
142138

143139
poller.stopPollingForSessionToken();
144140
});

packages/clerk-js/src/core/auth/safeLock.ts

Lines changed: 28 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,18 @@
11
import Lock from 'browser-tabs-lock';
22

3-
/**
4-
* Return type for SafeLock providing cross-tab lock coordination.
5-
*/
6-
export interface SafeLockReturn {
7-
/**
8-
* Acquires a cross-tab lock and executes the callback while holding it.
9-
* Other tabs attempting to acquire the same lock will wait until this callback completes.
10-
*
11-
* @param cb - Async callback to execute while holding the lock
12-
* @returns The callback's return value, or `false` if lock acquisition times out
13-
*/
14-
acquireLockAndRun: (cb: () => Promise<unknown>) => Promise<unknown>;
15-
}
3+
import { debugLogger } from '@/utils/debug';
4+
5+
const LOCK_TIMEOUT_MS = 4999;
166

177
/**
18-
* Creates a cross-tab lock mechanism for coordinating exclusive operations across browser tabs.
19-
*
20-
* This is used to prevent multiple tabs from performing the same operation simultaneously,
21-
* such as refreshing session tokens. When one tab holds the lock, other tabs will wait
22-
* until the lock is released before proceeding.
8+
* Creates a cross-tab lock for coordinating exclusive operations across browser tabs.
239
*
24-
* @param key - Shared identifier for the lock
25-
* @returns SafeLockReturn with acquireLockAndRun method
10+
* Uses Web Locks API in secure contexts (HTTPS), falling back to browser-tabs-lock
11+
* (localStorage-based) in non-secure contexts.
2612
*
27-
* @example
28-
* ```typescript
29-
* const tokenLock = SafeLock('clerk.lock.refreshToken');
30-
*
31-
* // In Tab 1:
32-
* await tokenLock.acquireLockAndRun(async () => {
33-
* await refreshToken(); // Only one tab executes this at a time
34-
* });
35-
*
36-
* // Tab 2 will wait for Tab 1 to finish before executing its callback
37-
* ```
13+
* @param key - Unique identifier for the lock (same key = same lock across all tabs)
3814
*/
39-
export function SafeLock(key: string): SafeLockReturn {
15+
export function SafeLock(key: string) {
4016
const lock = new Lock();
4117

4218
// Release any held locks when the tab is closing to prevent deadlocks
@@ -45,32 +21,41 @@ export function SafeLock(key: string): SafeLockReturn {
4521
await lock.releaseLock(key);
4622
});
4723

48-
const acquireLockAndRun = async (cb: () => Promise<unknown>) => {
24+
/**
25+
* Acquires the cross-tab lock and executes the callback while holding it.
26+
* If lock acquisition fails or times out, executes the callback anyway (degraded mode)
27+
* to ensure the operation completes rather than failing.
28+
*/
29+
const acquireLockAndRun = async <T>(cb: () => Promise<T>): Promise<T> => {
4930
if ('locks' in navigator && isSecureContext) {
5031
const controller = new AbortController();
51-
const lockTimeout = setTimeout(() => controller.abort(), 4999);
32+
const lockTimeout = setTimeout(() => controller.abort(), LOCK_TIMEOUT_MS);
5233

53-
return await navigator.locks
54-
.request(key, { signal: controller.signal }, async () => {
34+
try {
35+
return await navigator.locks.request(key, { signal: controller.signal }, async () => {
5536
clearTimeout(lockTimeout);
5637
return await cb();
57-
})
58-
.catch(() => {
59-
// Lock request was aborted (timeout) or failed
60-
// Return false to indicate lock was not acquired (matches browser-tabs-lock behavior)
61-
return false;
6238
});
39+
} catch {
40+
// Lock request was aborted (timeout) or failed
41+
// Execute callback anyway in degraded mode to ensure operation completes
42+
debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock');
43+
return await cb();
44+
}
6345
}
6446

65-
if (await lock.acquireLock(key, 5000)) {
47+
// Fallback for non-secure contexts using localStorage-based locking
48+
if (await lock.acquireLock(key, LOCK_TIMEOUT_MS + 1)) {
6649
try {
6750
return await cb();
6851
} finally {
6952
await lock.releaseLock(key);
7053
}
7154
}
7255

73-
return false;
56+
// Lock acquisition timed out - execute callback anyway in degraded mode
57+
debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock');
58+
return await cb();
7459
};
7560

7661
return { acquireLockAndRun };

0 commit comments

Comments
 (0)