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

32 changes: 22 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,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<typeof createCookieHandler>;
private clientUat: ClientUatCookieHandler;
private devBrowser: DevBrowser;
private poller: SessionCookiePoller | null = null;
private sessionCookie: SessionCookieHandler;
private tokenRefreshLock: SafeLockReturn;

public static async create(
clerk: Clerk,
Expand All @@ -66,6 +69,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 +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() {
Expand Down Expand Up @@ -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());
}
}
Expand All @@ -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 }));
}
});
}
Expand Down
9 changes: 7 additions & 2 deletions packages/clerk-js/src/core/auth/SessionCookiePoller.ts
Original file line number Diff line number Diff line change
@@ -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<typeof this.workerTimers.setInterval> | 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<unknown>): void {
if (this.timerId || this.initiated) {
return;
Expand Down
6 changes: 5 additions & 1 deletion packages/clerk-js/src/core/auth/safeLock.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import Lock from 'browser-tabs-lock';

export function SafeLock(key: string) {
export interface SafeLockReturn {
acquireLockAndRun: (cb: () => Promise<unknown>) => Promise<unknown>;
}

export function SafeLock(key: string): SafeLockReturn {
const lock = new Lock();

// TODO: Figure out how to fix this linting error
Expand Down
Loading