-
Notifications
You must be signed in to change notification settings - Fork 409
fix(clerk-js): Correct race condition when fetching tokens #7304
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jacekradko
wants to merge
8
commits into
main
Choose a base branch
from
fix/token-poller-race-condition
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
c694bae
fix(clerk-js): Correct race condition when fetching tokens
jacekradko b991f29
TSDoc and tests
jacekradko ca20b97
Move locking into getToken()
jacekradko 04b08a6
remove old code
jacekradko f94b214
wip
jacekradko be3c3a9
update changeset
jacekradko c8c4648
Merge branch 'main' into fix/token-poller-race-condition
jacekradko 4a6769d
Add lru map to prevent unbounded growth
jacekradko File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
142 changes: 142 additions & 0 deletions
142
packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
106
packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.