Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
14 changes: 14 additions & 0 deletions .changeset/fix-token-refresh-race-condition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@clerk/clerk-js": patch
---

Fix race condition where multiple browser tabs could fetch session tokens simultaneously.

Key changes:
- `getToken()` now uses a cross-tab lock (via Web Locks API or localStorage fallback) to coordinate token refresh operations
- Per-tokenId locking allows different token types (different orgs, JWT templates) to be fetched in parallel while preventing duplicates for the same token
- 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
- Graceful timeout handling: if lock acquisition times out (~5 seconds), the operation proceeds in degraded mode rather than failing
- Removed redundant lock from SessionCookiePoller since coordination is now handled within `getToken()` itself

This ensures all callers of `getToken()` (pollers, focus handlers, user code) automatically benefit from cross-tab coordination.
35 changes: 25 additions & 10 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -41,11 +43,15 @@ 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<typeof createCookieHandler>;
private clientUat: ClientUatCookieHandler;
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,
Expand All @@ -66,6 +72,11 @@ export class AuthCookieService {
private instanceType: InstanceType,
private clerkEventBus: ReturnType<typeof createClerkEventBus>,
) {
// 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());
Expand All @@ -77,14 +88,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() {
Expand Down Expand Up @@ -126,7 +137,7 @@ export class AuthCookieService {

public startPollingForToken() {
if (!this.poller) {
this.poller = new SessionCookiePoller();
this.poller = new SessionCookiePoller(this.tokenRefreshLock);
this.poller.startPollingForSessionToken(() => this.refreshSessionToken());
}
}
Expand All @@ -147,7 +158,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 }));
}
});
}
Expand Down
12 changes: 7 additions & 5 deletions packages/clerk-js/src/core/auth/SessionCookiePoller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { createWorkerTimers } from '@clerk/shared/workerTimers';

import { SafeLock } from './safeLock';

const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
const INTERVAL_IN_MS = 5 * 1_000;

/**
* Polls for session token refresh at regular intervals.
*
* 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 = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
private workerTimers = createWorkerTimers();
private timerId: ReturnType<typeof this.workerTimers.setInterval> | null = null;
// Disallows for multiple `startPollingForSessionToken()` calls before `callback` is executed.
Expand All @@ -19,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);
};

Expand Down
142 changes: 142 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { SessionCookiePoller } from '../SessionCookiePoller';

describe('SessionCookiePoller', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

describe('startPollingForSessionToken', () => {
it('executes callback immediately on start', async () => {
const poller = new SessionCookiePoller();
const callback = vi.fn().mockResolvedValue(undefined);

poller.startPollingForSessionToken(callback);

// Flush microtasks to let the async run() execute
await Promise.resolve();

expect(callback).toHaveBeenCalledTimes(1);

poller.stopPollingForSessionToken();
});

it('prevents multiple concurrent polling sessions', async () => {
const poller = new SessionCookiePoller();
const callback = vi.fn().mockResolvedValue(undefined);

poller.startPollingForSessionToken(callback);
poller.startPollingForSessionToken(callback); // Second call should be ignored

await Promise.resolve();

expect(callback).toHaveBeenCalledTimes(1);

poller.stopPollingForSessionToken();
});
});

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(callback).toHaveBeenCalledTimes(1);

poller.stopPollingForSessionToken();

// Advance time - callback should not be called again
await vi.advanceTimersByTimeAsync(10000);

expect(callback).toHaveBeenCalledTimes(1);
});

it('allows restart after stop', async () => {
const poller = new SessionCookiePoller();
const callback = vi.fn().mockResolvedValue(undefined);

// Start and stop
poller.startPollingForSessionToken(callback);
await Promise.resolve();
poller.stopPollingForSessionToken();

expect(callback).toHaveBeenCalledTimes(1);

// Should be able to start again
poller.startPollingForSessionToken(callback);
await Promise.resolve();

expect(callback).toHaveBeenCalledTimes(2);

poller.stopPollingForSessionToken();
});
});

describe('polling interval', () => {
it('schedules next poll after callback completes', async () => {
const poller = new SessionCookiePoller();
const callback = vi.fn().mockResolvedValue(undefined);

poller.startPollingForSessionToken(callback);

// Initial call
await Promise.resolve();
expect(callback).toHaveBeenCalledTimes(1);

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

// Should have scheduled another call
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<void>(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();
});
});
});
106 changes: 106 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) => {
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<unknown>) => {
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);
});
});
});
Loading
Loading