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

Fix race condition where multiple browser tabs could fetch session tokens simultaneously. `getToken()` now uses a cross-tab lock to coordinate token refresh operations
14 changes: 7 additions & 7 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ 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;

public static async create(
clerk: Clerk,
Expand Down Expand Up @@ -77,14 +77,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
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);
});
});
});
45 changes: 35 additions & 10 deletions packages/clerk-js/src/core/auth/safeLock.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,61 @@
import Lock from 'browser-tabs-lock';

import { debugLogger } from '@/utils/debug';

const LOCK_TIMEOUT_MS = 4999;

/**
* Creates a cross-tab lock for coordinating exclusive operations across browser tabs.
*
* Uses Web Locks API in secure contexts (HTTPS), falling back to browser-tabs-lock
* (localStorage-based) in non-secure contexts.
*
* @param key - Unique identifier for the lock (same key = same lock across all tabs)
*/
export function SafeLock(key: string) {
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);
});

const acquireLockAndRun = async (cb: () => Promise<unknown>) => {
/**
* 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 <T>(cb: () => Promise<T>): Promise<T> => {
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 () => {
const lockTimeout = setTimeout(() => controller.abort(), LOCK_TIMEOUT_MS);

try {
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
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 {
await lock.releaseLock(key);
}
}

// 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 };
Expand Down
Loading
Loading