From 0195e03fa84450c528d1406c5d3b7c73eefd51f6 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 24 Nov 2025 16:47:17 -0600 Subject: [PATCH 1/8] fix(clerk-js): Correct race condition when fetching tokens --- .../fix-token-refresh-race-condition.md | 6 ++++ .../src/core/auth/AuthCookieService.ts | 32 +++++++++++++------ .../src/core/auth/SessionCookiePoller.ts | 9 ++++-- packages/clerk-js/src/core/auth/safeLock.ts | 6 +++- 4 files changed, 40 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-token-refresh-race-condition.md diff --git a/.changeset/fix-token-refresh-race-condition.md b/.changeset/fix-token-refresh-race-condition.md new file mode 100644 index 00000000000..527be0d877d --- /dev/null +++ b/.changeset/fix-token-refresh-race-condition.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": patch +--- + +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. + diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 51268dc6bcd..f596c2cf3ba 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -20,7 +20,9 @@ import { createSessionCookie } from './cookies/session'; import { getCookieSuffix } from './cookieSuffix'; import type { DevBrowser } from './devBrowser'; import { createDevBrowser } from './devBrowser'; -import { SessionCookiePoller } from './SessionCookiePoller'; +import type { SafeLockReturn } from './safeLock'; +import { SafeLock } from './safeLock'; +import { REFRESH_SESSION_TOKEN_LOCK_KEY, SessionCookiePoller } from './SessionCookiePoller'; // TODO(@dimkl): make AuthCookieService singleton since it handles updating cookies using a poller // and we need to avoid updating them concurrently. @@ -41,11 +43,12 @@ import { SessionCookiePoller } from './SessionCookiePoller'; * - handleUnauthenticatedDevBrowser(): resets dev browser in case of invalid dev browser */ export class AuthCookieService { - private poller: SessionCookiePoller | null = null; - private clientUat: ClientUatCookieHandler; - private sessionCookie: SessionCookieHandler; private activeCookie: ReturnType; + private clientUat: ClientUatCookieHandler; private devBrowser: DevBrowser; + private poller: SessionCookiePoller | null = null; + private sessionCookie: SessionCookieHandler; + private tokenRefreshLock: SafeLockReturn; public static async create( clerk: Clerk, @@ -66,6 +69,11 @@ export class AuthCookieService { private instanceType: InstanceType, private clerkEventBus: ReturnType, ) { + // Create shared lock for cross-tab token refresh coordination. + // This lock is used by both the poller and the focus handler to prevent + // concurrent token fetches across tabs. + this.tokenRefreshLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); + // set cookie on token update eventBus.on(events.TokenUpdate, ({ token }) => { this.updateSessionCookie(token && token.getRawString()); @@ -77,14 +85,14 @@ export class AuthCookieService { this.refreshTokenOnFocus(); this.startPollingForToken(); - this.clientUat = createClientUatCookie(cookieSuffix); - this.sessionCookie = createSessionCookie(cookieSuffix); this.activeCookie = createActiveContextCookie(); + this.clientUat = createClientUatCookie(cookieSuffix); this.devBrowser = createDevBrowser({ - frontendApi: clerk.frontendApi, - fapiClient, cookieSuffix, + fapiClient, + frontendApi: clerk.frontendApi, }); + this.sessionCookie = createSessionCookie(cookieSuffix); } public async setup() { @@ -126,7 +134,7 @@ export class AuthCookieService { public startPollingForToken() { if (!this.poller) { - this.poller = new SessionCookiePoller(); + this.poller = new SessionCookiePoller(this.tokenRefreshLock); this.poller.startPollingForSessionToken(() => this.refreshSessionToken()); } } @@ -147,7 +155,11 @@ export class AuthCookieService { // is updated as part of the scheduled microtask. Our existing event-based mechanism to update the cookie schedules a task, and so the cookie // is updated too late and not guaranteed to be fresh before the refetch occurs. // While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break. - void this.refreshSessionToken({ updateCookieImmediately: true }); + // + // We use the shared lock to coordinate with the poller and other tabs, preventing + // concurrent token fetches when multiple tabs become visible or when focus events + // fire while the poller is already refreshing the token. + void this.tokenRefreshLock.acquireLockAndRun(() => this.refreshSessionToken({ updateCookieImmediately: true })); } }); } diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index 91e8040f79d..bdc2b921f31 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -1,17 +1,22 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers'; +import type { SafeLockReturn } from './safeLock'; import { SafeLock } from './safeLock'; -const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; +export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; const INTERVAL_IN_MS = 5 * 1_000; export class SessionCookiePoller { - private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); + private lock: SafeLockReturn; private workerTimers = createWorkerTimers(); private timerId: ReturnType | null = null; // Disallows for multiple `startPollingForSessionToken()` calls before `callback` is executed. private initiated = false; + constructor(lock?: SafeLockReturn) { + this.lock = lock ?? SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); + } + public startPollingForSessionToken(cb: () => Promise): void { if (this.timerId || this.initiated) { return; diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index 405190a73ff..d28eb9a0657 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -1,6 +1,10 @@ import Lock from 'browser-tabs-lock'; -export function SafeLock(key: string) { +export interface SafeLockReturn { + acquireLockAndRun: (cb: () => Promise) => Promise; +} + +export function SafeLock(key: string): SafeLockReturn { const lock = new Lock(); // TODO: Figure out how to fix this linting error From 367863592dc6b49bbd33ef5cf6e3d707c46a59cb Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 24 Nov 2025 19:56:31 -0600 Subject: [PATCH 2/8] TSDoc and tests --- .../src/core/auth/AuthCookieService.ts | 3 + .../src/core/auth/SessionCookiePoller.ts | 18 +++ .../__tests__/SessionCookiePoller.test.ts | 146 ++++++++++++++++++ .../src/core/auth/__tests__/safeLock.test.ts | 106 +++++++++++++ packages/clerk-js/src/core/auth/safeLock.ts | 40 ++++- 5 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts create mode 100644 packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index f596c2cf3ba..575b8e1d289 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -48,6 +48,9 @@ export class AuthCookieService { private devBrowser: DevBrowser; private poller: SessionCookiePoller | null = null; private sessionCookie: SessionCookieHandler; + /** + * Shared lock for coordinating token refresh operations across tabs + */ private tokenRefreshLock: SafeLockReturn; public static async create( diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index bdc2b921f31..a4c874ed56c 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -6,6 +6,24 @@ import { SafeLock } from './safeLock'; export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; const INTERVAL_IN_MS = 5 * 1_000; +/** + * Polls for session token refresh at regular intervals with cross-tab coordination. + * + * @example + * ```typescript + * // Create a shared lock for coordination with focus handlers + * const sharedLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); + * + * // Poller uses the shared lock + * const poller = new SessionCookiePoller(sharedLock); + * poller.startPollingForSessionToken(() => refreshToken()); + * + * // Focus handler can use the same lock to prevent races + * window.addEventListener('focus', () => { + * sharedLock.acquireLockAndRun(() => refreshToken()); + * }); + * ``` + */ export class SessionCookiePoller { private lock: SafeLockReturn; private workerTimers = createWorkerTimers(); diff --git a/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts new file mode 100644 index 00000000000..c02258b026c --- /dev/null +++ b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SafeLockReturn } from '../safeLock'; +import { SessionCookiePoller } from '../SessionCookiePoller'; + +describe('SessionCookiePoller', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('shared lock coordination', () => { + it('accepts an external lock for coordination with other components', () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + poller.startPollingForSessionToken(callback); + + // Verify the shared lock is used + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback); + + poller.stopPollingForSessionToken(); + }); + + it('creates internal lock when none provided (backward compatible)', () => { + // Should not throw when no lock is provided + const poller = new SessionCookiePoller(); + expect(poller).toBeInstanceOf(SessionCookiePoller); + }); + + it('enables focus handler and poller to share the same lock', () => { + // This test demonstrates the shared lock pattern used in AuthCookieService + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise) => { + return cb(); + }), + }; + + const poller = new SessionCookiePoller(sharedLock); + const pollerCallback = vi.fn().mockResolvedValue('poller-result'); + + // Poller uses the shared lock + poller.startPollingForSessionToken(pollerCallback); + + // Simulate focus handler also using the shared lock (like AuthCookieService does) + const focusCallback = vi.fn().mockResolvedValue('focus-result'); + void sharedLock.acquireLockAndRun(focusCallback); + + // Both should use the same lock instance + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(pollerCallback); + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(focusCallback); + + poller.stopPollingForSessionToken(); + }); + }); + + describe('startPollingForSessionToken', () => { + it('executes callback immediately on start', () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + poller.startPollingForSessionToken(callback); + + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback); + + poller.stopPollingForSessionToken(); + }); + + it('prevents multiple concurrent polling sessions', () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + poller.startPollingForSessionToken(callback); + poller.startPollingForSessionToken(callback); // Second call should be ignored + + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + + poller.stopPollingForSessionToken(); + }); + }); + + describe('stopPollingForSessionToken', () => { + it('allows restart after stop', async () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + // Start and stop + poller.startPollingForSessionToken(callback); + poller.stopPollingForSessionToken(); + + // Clear mock to check restart + vi.mocked(sharedLock.acquireLockAndRun).mockClear(); + + // Should be able to start again + poller.startPollingForSessionToken(callback); + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + + poller.stopPollingForSessionToken(); + }); + }); + + describe('polling interval', () => { + it('schedules next poll after callback completes', async () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + poller.startPollingForSessionToken(callback); + + // Initial call + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + + // Wait for first interval (5 seconds) + await vi.advanceTimersByTimeAsync(5000); + + // Should have scheduled another call + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); + + poller.stopPollingForSessionToken(); + }); + }); +}); diff --git a/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts b/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts new file mode 100644 index 00000000000..e78d6ba9b19 --- /dev/null +++ b/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { SafeLockReturn } from '../safeLock'; +import { SafeLock } from '../safeLock'; + +describe('SafeLock', () => { + describe('interface contract', () => { + it('returns SafeLockReturn interface with acquireLockAndRun method', () => { + const lock = SafeLock('test-interface'); + + expect(lock).toHaveProperty('acquireLockAndRun'); + expect(typeof lock.acquireLockAndRun).toBe('function'); + }); + + it('SafeLockReturn type allows creating mock implementations', () => { + // This test verifies the type interface works correctly for mocking + const mockLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue('mock-result'), + }; + + expect(mockLock.acquireLockAndRun).toBeDefined(); + }); + }); + + describe('Web Locks API path', () => { + it('uses Web Locks API when available in secure context', async () => { + // Skip if Web Locks not available (like in jsdom without polyfill) + if (!('locks' in navigator) || !navigator.locks) { + return; + } + + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + const lock = SafeLock('test-weblocks-' + Date.now()); + const callback = vi.fn().mockResolvedValue('web-locks-result'); + + const result = await lock.acquireLockAndRun(callback); + + expect(callback).toHaveBeenCalled(); + expect(result).toBe('web-locks-result'); + // Verify cleanup happened + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + }); + }); + + describe('shared lock pattern', () => { + it('allows multiple components to share a lock via SafeLockReturn interface', async () => { + // This demonstrates how AuthCookieService shares a lock between poller and focus handler + const executionLog: string[] = []; + + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise) => { + executionLog.push('lock-acquired'); + const result = await cb(); + executionLog.push('lock-released'); + return result; + }), + }; + + // Simulate poller using the lock + await sharedLock.acquireLockAndRun(() => { + executionLog.push('poller-callback'); + return Promise.resolve('poller-done'); + }); + + // Simulate focus handler using the same lock + await sharedLock.acquireLockAndRun(() => { + executionLog.push('focus-callback'); + return Promise.resolve('focus-done'); + }); + + expect(executionLog).toEqual([ + 'lock-acquired', + 'poller-callback', + 'lock-released', + 'lock-acquired', + 'focus-callback', + 'lock-released', + ]); + }); + + it('mock lock can simulate sequential execution', async () => { + const results: string[] = []; + + // Create a mock that simulates sequential lock behavior + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise) => { + const result = await cb(); + results.push(result as string); + return result; + }), + }; + + // Both "tabs" try to refresh + const promise1 = sharedLock.acquireLockAndRun(() => Promise.resolve('tab1-result')); + const promise2 = sharedLock.acquireLockAndRun(() => Promise.resolve('tab2-result')); + + await Promise.all([promise1, promise2]); + + expect(results).toContain('tab1-result'); + expect(results).toContain('tab2-result'); + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index d28eb9a0657..1b587a76288 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -1,13 +1,45 @@ import Lock from 'browser-tabs-lock'; +/** + * Return type for SafeLock providing cross-tab lock coordination. + */ export interface SafeLockReturn { + /** + * Acquires a cross-tab lock and executes the callback while holding it. + * Other tabs attempting to acquire the same lock will wait until this callback completes. + * + * @param cb - Async callback to execute while holding the lock + * @returns The callback's return value, or `false` if lock acquisition times out + */ acquireLockAndRun: (cb: () => Promise) => Promise; } +/** + * Creates a cross-tab lock mechanism for coordinating exclusive operations across browser tabs. + * + * This is used to prevent multiple tabs from performing the same operation simultaneously, + * such as refreshing session tokens. When one tab holds the lock, other tabs will wait + * until the lock is released before proceeding. + * + * @param key - Shared identifier for the lock + * @returns SafeLockReturn with acquireLockAndRun method + * + * @example + * ```typescript + * const tokenLock = SafeLock('clerk.lock.refreshToken'); + * + * // In Tab 1: + * await tokenLock.acquireLockAndRun(async () => { + * await refreshToken(); // Only one tab executes this at a time + * }); + * + * // Tab 2 will wait for Tab 1 to finish before executing its callback + * ``` + */ export function SafeLock(key: string): SafeLockReturn { const lock = new Lock(); - // TODO: Figure out how to fix this linting error + // Release any held locks when the tab is closing to prevent deadlocks // eslint-disable-next-line @typescript-eslint/no-misused-promises window.addEventListener('beforeunload', async () => { await lock.releaseLock(key); @@ -17,13 +49,15 @@ export function SafeLock(key: string): SafeLockReturn { if ('locks' in navigator && isSecureContext) { const controller = new AbortController(); const lockTimeout = setTimeout(() => controller.abort(), 4999); + return await navigator.locks .request(key, { signal: controller.signal }, async () => { clearTimeout(lockTimeout); return await cb(); }) .catch(() => { - // browser-tabs-lock never seems to throw, so we are mirroring the behavior here + // Lock request was aborted (timeout) or failed + // Return false to indicate lock was not acquired (matches browser-tabs-lock behavior) return false; }); } @@ -35,6 +69,8 @@ export function SafeLock(key: string): SafeLockReturn { await lock.releaseLock(key); } } + + return false; }; return { acquireLockAndRun }; From 3303971f0ca911fba28b4af2efd2fce8215d07f8 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 11:37:30 -0600 Subject: [PATCH 3/8] update changeset --- .changeset/fix-token-refresh-race-condition.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/fix-token-refresh-race-condition.md b/.changeset/fix-token-refresh-race-condition.md index 527be0d877d..7e527e89cd3 100644 --- a/.changeset/fix-token-refresh-race-condition.md +++ b/.changeset/fix-token-refresh-race-condition.md @@ -3,4 +3,3 @@ --- 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. - From bd265d2483a7b59924d01ed06828de1077c2f566 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 07:14:53 -0600 Subject: [PATCH 4/8] Move locking into getToken() --- .../fix-token-refresh-race-condition.md | 3 +- .../src/core/auth/SessionCookiePoller.ts | 29 +--- .../__tests__/SessionCookiePoller.test.ts | 142 +++++++++--------- packages/clerk-js/src/core/auth/safeLock.ts | 71 ++++----- .../clerk-js/src/core/resources/Session.ts | 97 +++++++++--- 5 files changed, 174 insertions(+), 168 deletions(-) diff --git a/.changeset/fix-token-refresh-race-condition.md b/.changeset/fix-token-refresh-race-condition.md index 7e527e89cd3..3b7dd6746a8 100644 --- a/.changeset/fix-token-refresh-race-condition.md +++ b/.changeset/fix-token-refresh-race-condition.md @@ -1,5 +1,4 @@ --- "@clerk/clerk-js": patch --- - -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. +Fix race condition where multiple browser tabs could fetch session tokens simultaneously. diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index a4c874ed56c..32d3f894faf 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -1,40 +1,19 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers'; -import type { SafeLockReturn } from './safeLock'; -import { SafeLock } from './safeLock'; - -export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; const INTERVAL_IN_MS = 5 * 1_000; /** - * Polls for session token refresh at regular intervals with cross-tab coordination. - * - * @example - * ```typescript - * // Create a shared lock for coordination with focus handlers - * const sharedLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); - * - * // Poller uses the shared lock - * const poller = new SessionCookiePoller(sharedLock); - * poller.startPollingForSessionToken(() => refreshToken()); + * Polls for session token refresh at regular intervals. * - * // Focus handler can use the same lock to prevent races - * window.addEventListener('focus', () => { - * sharedLock.acquireLockAndRun(() => refreshToken()); - * }); - * ``` + * Note: Cross-tab coordination is handled within Session.getToken() itself, + * so this poller simply triggers the refresh callback without additional locking. */ export class SessionCookiePoller { - private lock: SafeLockReturn; private workerTimers = createWorkerTimers(); private timerId: ReturnType | null = null; // Disallows for multiple `startPollingForSessionToken()` calls before `callback` is executed. private initiated = false; - constructor(lock?: SafeLockReturn) { - this.lock = lock ?? SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); - } - public startPollingForSessionToken(cb: () => Promise): void { if (this.timerId || this.initiated) { return; @@ -42,7 +21,7 @@ export class SessionCookiePoller { const run = async () => { this.initiated = true; - await this.lock.acquireLockAndRun(cb); + await cb(); this.timerId = this.workerTimers.setTimeout(run, INTERVAL_IN_MS); }; diff --git a/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts index c02258b026c..71b7dbfc022 100644 --- a/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { SafeLockReturn } from '../safeLock'; import { SessionCookiePoller } from '../SessionCookiePoller'; describe('SessionCookiePoller', () => { @@ -13,108 +12,70 @@ describe('SessionCookiePoller', () => { vi.restoreAllMocks(); }); - describe('shared lock coordination', () => { - it('accepts an external lock for coordination with other components', () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); + describe('startPollingForSessionToken', () => { + it('executes callback immediately on start', async () => { + const poller = new SessionCookiePoller(); const callback = vi.fn().mockResolvedValue(undefined); poller.startPollingForSessionToken(callback); - // Verify the shared lock is used - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback); + // Flush microtasks to let the async run() execute + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(1); poller.stopPollingForSessionToken(); }); - it('creates internal lock when none provided (backward compatible)', () => { - // Should not throw when no lock is provided + it('prevents multiple concurrent polling sessions', async () => { const poller = new SessionCookiePoller(); - expect(poller).toBeInstanceOf(SessionCookiePoller); - }); - - it('enables focus handler and poller to share the same lock', () => { - // This test demonstrates the shared lock pattern used in AuthCookieService - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise) => { - return cb(); - }), - }; - - const poller = new SessionCookiePoller(sharedLock); - const pollerCallback = vi.fn().mockResolvedValue('poller-result'); + const callback = vi.fn().mockResolvedValue(undefined); - // Poller uses the shared lock - poller.startPollingForSessionToken(pollerCallback); + poller.startPollingForSessionToken(callback); + poller.startPollingForSessionToken(callback); // Second call should be ignored - // Simulate focus handler also using the shared lock (like AuthCookieService does) - const focusCallback = vi.fn().mockResolvedValue('focus-result'); - void sharedLock.acquireLockAndRun(focusCallback); + await Promise.resolve(); - // Both should use the same lock instance - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(pollerCallback); - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(focusCallback); + expect(callback).toHaveBeenCalledTimes(1); poller.stopPollingForSessionToken(); }); }); - describe('startPollingForSessionToken', () => { - it('executes callback immediately on start', () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); + describe('stopPollingForSessionToken', () => { + it('stops polling when called', async () => { + const poller = new SessionCookiePoller(); const callback = vi.fn().mockResolvedValue(undefined); poller.startPollingForSessionToken(callback); + await Promise.resolve(); - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback); + expect(callback).toHaveBeenCalledTimes(1); poller.stopPollingForSessionToken(); - }); - - it('prevents multiple concurrent polling sessions', () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); - const callback = vi.fn().mockResolvedValue(undefined); - poller.startPollingForSessionToken(callback); - poller.startPollingForSessionToken(callback); // Second call should be ignored - - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + // Advance time - callback should not be called again + await vi.advanceTimersByTimeAsync(10000); - poller.stopPollingForSessionToken(); + expect(callback).toHaveBeenCalledTimes(1); }); - }); - describe('stopPollingForSessionToken', () => { it('allows restart after stop', async () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); + const poller = new SessionCookiePoller(); const callback = vi.fn().mockResolvedValue(undefined); // Start and stop poller.startPollingForSessionToken(callback); + await Promise.resolve(); poller.stopPollingForSessionToken(); - // Clear mock to check restart - vi.mocked(sharedLock.acquireLockAndRun).mockClear(); + expect(callback).toHaveBeenCalledTimes(1); // Should be able to start again poller.startPollingForSessionToken(callback); - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(2); poller.stopPollingForSessionToken(); }); @@ -122,23 +83,58 @@ describe('SessionCookiePoller', () => { describe('polling interval', () => { it('schedules next poll after callback completes', async () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); + const poller = new SessionCookiePoller(); const callback = vi.fn().mockResolvedValue(undefined); poller.startPollingForSessionToken(callback); // Initial call - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(callback).toHaveBeenCalledTimes(1); // Wait for first interval (5 seconds) await vi.advanceTimersByTimeAsync(5000); // Should have scheduled another call - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledTimes(2); + + // Another interval + await vi.advanceTimersByTimeAsync(5000); + expect(callback).toHaveBeenCalledTimes(3); + + poller.stopPollingForSessionToken(); + }); + + it('waits for callback to complete before scheduling next poll', async () => { + const poller = new SessionCookiePoller(); + + let resolveCallback: () => void; + const callbackPromise = new Promise(resolve => { + resolveCallback = resolve; + }); + const callback = vi.fn().mockReturnValue(callbackPromise); + + poller.startPollingForSessionToken(callback); + + // Let the first call start + await Promise.resolve(); + expect(callback).toHaveBeenCalledTimes(1); + + // Advance time while callback is still running - should NOT schedule next poll + // because the callback promise hasn't resolved yet + await vi.advanceTimersByTimeAsync(5000); + + // Should still only be 1 call since previous call hasn't completed + expect(callback).toHaveBeenCalledTimes(1); + + // Complete the callback + resolveCallback!(); + await Promise.resolve(); + + // Now advance time for the next interval + await vi.advanceTimersByTimeAsync(5000); + + expect(callback).toHaveBeenCalledTimes(2); poller.stopPollingForSessionToken(); }); diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index 1b587a76288..33a5d58f4f8 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -1,42 +1,18 @@ import Lock from 'browser-tabs-lock'; -/** - * Return type for SafeLock providing cross-tab lock coordination. - */ -export interface SafeLockReturn { - /** - * Acquires a cross-tab lock and executes the callback while holding it. - * Other tabs attempting to acquire the same lock will wait until this callback completes. - * - * @param cb - Async callback to execute while holding the lock - * @returns The callback's return value, or `false` if lock acquisition times out - */ - acquireLockAndRun: (cb: () => Promise) => Promise; -} +import { debugLogger } from '@/utils/debug'; + +const LOCK_TIMEOUT_MS = 4999; /** - * Creates a cross-tab lock mechanism for coordinating exclusive operations across browser tabs. - * - * This is used to prevent multiple tabs from performing the same operation simultaneously, - * such as refreshing session tokens. When one tab holds the lock, other tabs will wait - * until the lock is released before proceeding. + * Creates a cross-tab lock for coordinating exclusive operations across browser tabs. * - * @param key - Shared identifier for the lock - * @returns SafeLockReturn with acquireLockAndRun method + * Uses Web Locks API in secure contexts (HTTPS), falling back to browser-tabs-lock + * (localStorage-based) in non-secure contexts. * - * @example - * ```typescript - * const tokenLock = SafeLock('clerk.lock.refreshToken'); - * - * // In Tab 1: - * await tokenLock.acquireLockAndRun(async () => { - * await refreshToken(); // Only one tab executes this at a time - * }); - * - * // Tab 2 will wait for Tab 1 to finish before executing its callback - * ``` + * @param key - Unique identifier for the lock (same key = same lock across all tabs) */ -export function SafeLock(key: string): SafeLockReturn { +export function SafeLock(key: string) { const lock = new Lock(); // Release any held locks when the tab is closing to prevent deadlocks @@ -45,24 +21,31 @@ export function SafeLock(key: string): SafeLockReturn { await lock.releaseLock(key); }); - const acquireLockAndRun = async (cb: () => Promise) => { + /** + * Acquires the cross-tab lock and executes the callback while holding it. + * If lock acquisition fails or times out, executes the callback anyway (degraded mode) + * to ensure the operation completes rather than failing. + */ + const acquireLockAndRun = async (cb: () => Promise): Promise => { if ('locks' in navigator && isSecureContext) { const controller = new AbortController(); - const lockTimeout = setTimeout(() => controller.abort(), 4999); + const lockTimeout = setTimeout(() => controller.abort(), LOCK_TIMEOUT_MS); - return await navigator.locks - .request(key, { signal: controller.signal }, async () => { + try { + return await navigator.locks.request(key, { signal: controller.signal }, async () => { clearTimeout(lockTimeout); return await cb(); - }) - .catch(() => { - // Lock request was aborted (timeout) or failed - // Return false to indicate lock was not acquired (matches browser-tabs-lock behavior) - return false; }); + } catch { + // Lock request was aborted (timeout) or failed + // Execute callback anyway in degraded mode to ensure operation completes + debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock'); + return await cb(); + } } - if (await lock.acquireLock(key, 5000)) { + // Fallback for non-secure contexts using localStorage-based locking + if (await lock.acquireLock(key, LOCK_TIMEOUT_MS + 1)) { try { return await cb(); } finally { @@ -70,7 +53,9 @@ export function SafeLock(key: string): SafeLockReturn { } } - return false; + // Lock acquisition timed out - execute callback anyway in degraded mode + debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock'); + return await cb(); }; return { acquireLockAndRun }; diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ee5df2c43a1..f2954608603 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -35,12 +35,34 @@ import { } from '@/utils/passkeys'; import { TokenId } from '@/utils/tokenId'; +import { SafeLock } from '../auth/safeLock'; import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { eventBus, events } from '../events'; import { SessionTokenCache } from '../tokenCache'; import { BaseResource, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; +/** + * Cache of per-tokenId locks for cross-tab coordination. + * Each unique tokenId gets its own lock, allowing different token types + * (e.g., different orgs, JWT templates) to be fetched in parallel. + */ +const tokenLocks = new Map>(); + +/** + * Gets or creates a cross-tab lock for a specific tokenId. + * Using per-tokenId locks allows different token types to be fetched in parallel + * while still preventing duplicate fetches for the same token across tabs. + */ +function getTokenLock(tokenId: string) { + let lock = tokenLocks.get(tokenId); + if (!lock) { + lock = SafeLock(`clerk.lock.getToken.${tokenId}`); + tokenLocks.set(tokenId, lock); + } + return lock; +} + export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -363,6 +385,7 @@ export class Session extends BaseResource implements SessionResource { const tokenId = this.#getCacheId(template, organizationId); + // Fast path: check cache without lock for immediate hits const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates @@ -384,39 +407,63 @@ export class Session extends BaseResource implements SessionResource { return cachedToken.getRawString() || null; } - debugLogger.info( - 'Fetching new token from API', - { - organizationId, - template, - tokenId, - }, - 'session', - ); + // Cache miss: acquire cross-tab lock before fetching to prevent duplicate API calls + // when multiple tabs try to refresh the token simultaneously. + // Using per-tokenId locks allows different token types to be fetched in parallel. + const tokenLock = getTokenLock(tokenId); + return tokenLock.acquireLockAndRun(async () => { + // Double-check cache after acquiring lock - another tab may have populated it + const cachedEntryAfterLock = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); + + if (cachedEntryAfterLock) { + debugLogger.debug( + 'Using cached token after lock (populated by another tab)', + { + tokenId, + }, + 'session', + ); + const cachedToken = await cachedEntryAfterLock.tokenResolver; + if (shouldDispatchTokenUpdate) { + eventBus.emit(events.TokenUpdate, { token: cachedToken }); + } + return cachedToken.getRawString() || null; + } + + debugLogger.info( + 'Fetching new token from API', + { + organizationId, + template, + tokenId, + }, + 'session', + ); - const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; + const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; - // TODO: update template endpoint to accept organizationId - const params: Record = template ? {} : { organizationId }; + // TODO: update template endpoint to accept organizationId + const params: Record = template ? {} : { organizationId }; - const tokenResolver = Token.create(path, params, skipCache); + const tokenResolver = Token.create(path, params, skipCache); - // Cache the promise immediately to prevent concurrent calls from triggering duplicate requests - SessionTokenCache.set({ tokenId, tokenResolver }); + // Cache the promise immediately to prevent concurrent calls from triggering duplicate requests + SessionTokenCache.set({ tokenId, tokenResolver }); - return tokenResolver.then(token => { - if (shouldDispatchTokenUpdate) { - eventBus.emit(events.TokenUpdate, { token }); + return tokenResolver.then(token => { + if (shouldDispatchTokenUpdate) { + eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt) { - this.lastActiveToken = token; - // Emits the updated session with the new token to the state listeners - eventBus.emit(events.SessionTokenResolved, null); + if (token.jwt) { + this.lastActiveToken = token; + // Emits the updated session with the new token to the state listeners + eventBus.emit(events.SessionTokenResolved, null); + } } - } - // Return null when raw string is empty to indicate that there it's signed-out - return token.getRawString() || null; + // Return null when raw string is empty to indicate that there it's signed-out + return token.getRawString() || null; + }); }); } From ef7c61e8dfe3955f3e5d8c91e7b0f90581aa0c0e Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 07:20:44 -0600 Subject: [PATCH 5/8] remove old code --- .../src/core/auth/AuthCookieService.ts | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 575b8e1d289..a801e096aaf 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -20,9 +20,7 @@ import { createSessionCookie } from './cookies/session'; import { getCookieSuffix } from './cookieSuffix'; import type { DevBrowser } from './devBrowser'; import { createDevBrowser } from './devBrowser'; -import type { SafeLockReturn } from './safeLock'; -import { SafeLock } from './safeLock'; -import { REFRESH_SESSION_TOKEN_LOCK_KEY, SessionCookiePoller } from './SessionCookiePoller'; +import { SessionCookiePoller } from './SessionCookiePoller'; // TODO(@dimkl): make AuthCookieService singleton since it handles updating cookies using a poller // and we need to avoid updating them concurrently. @@ -48,10 +46,6 @@ export class AuthCookieService { private devBrowser: DevBrowser; private poller: SessionCookiePoller | null = null; private sessionCookie: SessionCookieHandler; - /** - * Shared lock for coordinating token refresh operations across tabs - */ - private tokenRefreshLock: SafeLockReturn; public static async create( clerk: Clerk, @@ -72,11 +66,6 @@ export class AuthCookieService { private instanceType: InstanceType, private clerkEventBus: ReturnType, ) { - // Create shared lock for cross-tab token refresh coordination. - // This lock is used by both the poller and the focus handler to prevent - // concurrent token fetches across tabs. - this.tokenRefreshLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); - // set cookie on token update eventBus.on(events.TokenUpdate, ({ token }) => { this.updateSessionCookie(token && token.getRawString()); @@ -137,7 +126,7 @@ export class AuthCookieService { public startPollingForToken() { if (!this.poller) { - this.poller = new SessionCookiePoller(this.tokenRefreshLock); + this.poller = new SessionCookiePoller(); this.poller.startPollingForSessionToken(() => this.refreshSessionToken()); } } @@ -158,11 +147,7 @@ export class AuthCookieService { // is updated as part of the scheduled microtask. Our existing event-based mechanism to update the cookie schedules a task, and so the cookie // is updated too late and not guaranteed to be fresh before the refetch occurs. // While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break. - // - // We use the shared lock to coordinate with the poller and other tabs, preventing - // concurrent token fetches when multiple tabs become visible or when focus events - // fire while the poller is already refreshing the token. - void this.tokenRefreshLock.acquireLockAndRun(() => this.refreshSessionToken({ updateCookieImmediately: true })); + void this.refreshSessionToken({ updateCookieImmediately: true }); } }); } From 5ee48721e0c27861174a00b9615a306a743acaba Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 07:24:56 -0600 Subject: [PATCH 6/8] wip --- packages/clerk-js/src/core/resources/Session.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index f2954608603..ef167be7c15 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -392,13 +392,6 @@ export class Session extends BaseResource implements SessionResource { const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; if (cachedEntry) { - debugLogger.debug( - 'Using cached token (no fetch needed)', - { - tokenId, - }, - 'session', - ); const cachedToken = await cachedEntry.tokenResolver; if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); From bf4a9a328be5d5096e1df0d7d6adacaee8a398f9 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 2 Dec 2025 07:07:09 -0600 Subject: [PATCH 7/8] wip --- .changeset/fix-token-refresh-race-condition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-token-refresh-race-condition.md b/.changeset/fix-token-refresh-race-condition.md index 3b7dd6746a8..1247fb865d7 100644 --- a/.changeset/fix-token-refresh-race-condition.md +++ b/.changeset/fix-token-refresh-race-condition.md @@ -1,4 +1,4 @@ --- "@clerk/clerk-js": patch --- -Fix race condition where multiple browser tabs could fetch session tokens simultaneously. +Fix race condition where multiple browser tabs could fetch session tokens simultaneously. `getToken()` now uses a cross-tab lock to coordinate token refresh operations \ No newline at end of file From 80d9830b845c9c7a7f29bab66fbbaeeebe51780b Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 2 Dec 2025 11:28:12 -0600 Subject: [PATCH 8/8] handle callback errors --- .../clerk-js/src/core/auth/SessionCookiePoller.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index 32d3f894faf..9ad78adedc0 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -1,5 +1,7 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers'; +import { debugLogger } from '@/utils/debug'; + const INTERVAL_IN_MS = 5 * 1_000; /** @@ -21,8 +23,13 @@ export class SessionCookiePoller { const run = async () => { this.initiated = true; - await cb(); - this.timerId = this.workerTimers.setTimeout(run, INTERVAL_IN_MS); + try { + await cb(); + } catch (error) { + debugLogger.error('SessionCookiePoller callback failed', { error }, 'auth'); + } finally { + this.timerId = this.workerTimers.setTimeout(run, INTERVAL_IN_MS); + } }; void run();