Skip to content

Commit c694bae

Browse files
committed
fix(clerk-js): Correct race condition when fetching tokens
1 parent cfaa021 commit c694bae

File tree

4 files changed

+40
-13
lines changed

4 files changed

+40
-13
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clerk/clerk-js": patch
3+
---
4+
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.
6+

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

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import { createSessionCookie } from './cookies/session';
2020
import { getCookieSuffix } from './cookieSuffix';
2121
import type { DevBrowser } from './devBrowser';
2222
import { createDevBrowser } from './devBrowser';
23-
import { SessionCookiePoller } from './SessionCookiePoller';
23+
import type { SafeLockReturn } from './safeLock';
24+
import { SafeLock } from './safeLock';
25+
import { REFRESH_SESSION_TOKEN_LOCK_KEY, SessionCookiePoller } from './SessionCookiePoller';
2426

2527
// TODO(@dimkl): make AuthCookieService singleton since it handles updating cookies using a poller
2628
// and we need to avoid updating them concurrently.
@@ -41,11 +43,12 @@ import { SessionCookiePoller } from './SessionCookiePoller';
4143
* - handleUnauthenticatedDevBrowser(): resets dev browser in case of invalid dev browser
4244
*/
4345
export class AuthCookieService {
44-
private poller: SessionCookiePoller | null = null;
45-
private clientUat: ClientUatCookieHandler;
46-
private sessionCookie: SessionCookieHandler;
4746
private activeCookie: ReturnType<typeof createCookieHandler>;
47+
private clientUat: ClientUatCookieHandler;
4848
private devBrowser: DevBrowser;
49+
private poller: SessionCookiePoller | null = null;
50+
private sessionCookie: SessionCookieHandler;
51+
private tokenRefreshLock: SafeLockReturn;
4952

5053
public static async create(
5154
clerk: Clerk,
@@ -66,6 +69,11 @@ export class AuthCookieService {
6669
private instanceType: InstanceType,
6770
private clerkEventBus: ReturnType<typeof createClerkEventBus>,
6871
) {
72+
// Create shared lock for cross-tab token refresh coordination.
73+
// This lock is used by both the poller and the focus handler to prevent
74+
// concurrent token fetches across tabs.
75+
this.tokenRefreshLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
76+
6977
// set cookie on token update
7078
eventBus.on(events.TokenUpdate, ({ token }) => {
7179
this.updateSessionCookie(token && token.getRawString());
@@ -77,14 +85,14 @@ export class AuthCookieService {
7785
this.refreshTokenOnFocus();
7886
this.startPollingForToken();
7987

80-
this.clientUat = createClientUatCookie(cookieSuffix);
81-
this.sessionCookie = createSessionCookie(cookieSuffix);
8288
this.activeCookie = createActiveContextCookie();
89+
this.clientUat = createClientUatCookie(cookieSuffix);
8390
this.devBrowser = createDevBrowser({
84-
frontendApi: clerk.frontendApi,
85-
fapiClient,
8691
cookieSuffix,
92+
fapiClient,
93+
frontendApi: clerk.frontendApi,
8794
});
95+
this.sessionCookie = createSessionCookie(cookieSuffix);
8896
}
8997

9098
public async setup() {
@@ -126,7 +134,7 @@ export class AuthCookieService {
126134

127135
public startPollingForToken() {
128136
if (!this.poller) {
129-
this.poller = new SessionCookiePoller();
137+
this.poller = new SessionCookiePoller(this.tokenRefreshLock);
130138
this.poller.startPollingForSessionToken(() => this.refreshSessionToken());
131139
}
132140
}
@@ -147,7 +155,11 @@ export class AuthCookieService {
147155
// is updated as part of the scheduled microtask. Our existing event-based mechanism to update the cookie schedules a task, and so the cookie
148156
// is updated too late and not guaranteed to be fresh before the refetch occurs.
149157
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
150-
void this.refreshSessionToken({ updateCookieImmediately: true });
158+
//
159+
// We use the shared lock to coordinate with the poller and other tabs, preventing
160+
// concurrent token fetches when multiple tabs become visible or when focus events
161+
// fire while the poller is already refreshing the token.
162+
void this.tokenRefreshLock.acquireLockAndRun(() => this.refreshSessionToken({ updateCookieImmediately: true }));
151163
}
152164
});
153165
}

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

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

3+
import type { SafeLockReturn } from './safeLock';
34
import { SafeLock } from './safeLock';
45

5-
const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
6+
export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
67
const INTERVAL_IN_MS = 5 * 1_000;
78

89
export class SessionCookiePoller {
9-
private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
10+
private lock: SafeLockReturn;
1011
private workerTimers = createWorkerTimers();
1112
private timerId: ReturnType<typeof this.workerTimers.setInterval> | null = null;
1213
// Disallows for multiple `startPollingForSessionToken()` calls before `callback` is executed.
1314
private initiated = false;
1415

16+
constructor(lock?: SafeLockReturn) {
17+
this.lock = lock ?? SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
18+
}
19+
1520
public startPollingForSessionToken(cb: () => Promise<unknown>): void {
1621
if (this.timerId || this.initiated) {
1722
return;

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import Lock from 'browser-tabs-lock';
22

3-
export function SafeLock(key: string) {
3+
export interface SafeLockReturn {
4+
acquireLockAndRun: (cb: () => Promise<unknown>) => Promise<unknown>;
5+
}
6+
7+
export function SafeLock(key: string): SafeLockReturn {
48
const lock = new Lock();
59

610
// TODO: Figure out how to fix this linting error

0 commit comments

Comments
 (0)