From 7efd914030a0b7be2d900fdef28bcc1661413ac0 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 16 Oct 2026 08:28:25 -0400 Subject: [PATCH 1/2] fix(js): wait up to 10s for the Device Intelligence result by default --- .changeset/protect-token-timeout-10s.md | 5 +++++ .../src/core/__tests__/protectSession.test.ts | 12 ++++++------ packages/clerk-js/src/core/protectSession.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 .changeset/protect-token-timeout-10s.md diff --git a/.changeset/protect-token-timeout-10s.md b/.changeset/protect-token-timeout-10s.md new file mode 100644 index 00000000000..d7d0d1d1c0a --- /dev/null +++ b/.changeset/protect-token-timeout-10s.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Wait up to 10 seconds, instead of 5, for the Device Intelligence check to finish before sending a sign-in or sign-up without its result. This applies when an instance hasn't configured its own limit. Users on slow connections are less likely to have sign-ups blocked by protections that require a Device Intelligence result. diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index bb8056d59e9..3bc132be2b2 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -686,7 +686,7 @@ describe('ProtectSession deadlines', () => { const startedAt = Date.now(); await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); - // Misread, this would fall back to the 5s default and be two orders of magnitude out. + // Misread, this would fall back to the 10s default and be two orders of magnitude out. expect(Date.now() - startedAt).toBeLessThan(1_000); }); @@ -733,11 +733,11 @@ describe('ProtectSession deadlines', () => { // `tokenTimeoutMs` is server config with no upper bound of its own, so the ceiling is ours. expect(clampTimeout(120_000)).toBe(10_000); expect(clampTimeout(1_500)).toBe(1_500); - expect(clampTimeout(undefined)).toBe(5_000); - expect(clampTimeout(0)).toBe(5_000); - expect(clampTimeout(-1)).toBe(5_000); - expect(clampTimeout('soon')).toBe(5_000); - expect(clampTimeout(Number.POSITIVE_INFINITY)).toBe(5_000); + expect(clampTimeout(undefined)).toBe(10_000); + expect(clampTimeout(0)).toBe(10_000); + expect(clampTimeout(-1)).toBe(10_000); + expect(clampTimeout('soon')).toBe(10_000); + expect(clampTimeout(Number.POSITIVE_INFINITY)).toBe(10_000); }); }); diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index ee36a82b0e9..25c261e0cba 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -31,7 +31,7 @@ const INLINE_TOKEN_GLOBAL = '__clerk_specter'; /** A token this close to expiry is treated as absent, so a fresh run starts before it lapses. */ const TOKEN_REFRESH_MARGIN_MS = 60 * 1_000; /** Acquisition deadline when the instance does not configure one. */ -const DEFAULT_TOKEN_TIMEOUT_MS = 5 * 1_000; +const DEFAULT_TOKEN_TIMEOUT_MS = 10 * 1_000; /** Ceiling on the instance-configured deadline, so no server value can stall a sign-in. */ const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000; /** A stored token claiming more remaining life than this was not minted by us. */ From 6aefb67ebc66c707a6fe4286fdc775549a13b663 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 16 Oct 2026 08:36:49 -0400 Subject: [PATCH 2/2] fix(js): let a tab without the lock wait for the shared token until its deadline --- .../src/core/__tests__/protectSession.test.ts | 108 ++++++++++++++++++ packages/clerk-js/src/core/protectSession.ts | 18 ++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index 3bc132be2b2..a36ea32b15c 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -741,6 +741,114 @@ describe('ProtectSession deadlines', () => { }); }); +describe('ProtectSession default deadline', () => { + const defaultLoader = () => loader({ token_timeout_ms: undefined }); + + const track = (params: Promise | undefined) => { + const state = { settled: false }; + void params?.then(() => { + state.settled = true; + }); + return state; + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (navigator as unknown as Record).locks; + }); + + it('accepts a token that arrives after 7s', async () => { + const { session: created, elements } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + + await vi.advanceTimersByTimeAsync(7_000); + serveInline(elements[0], { cid: created?.placeholders().cid }); + + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + }); + + it('gives up at 10s when the loader never loads', async () => { + const { session: created } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + const state = track(params); + + await vi.advanceTimersByTimeAsync(9_999); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + }); + + it('holds a sign-in made mid-acquisition only for what is left of it', async () => { + const { session: created } = session([defaultLoader()]); + created?.start(); + await vi.advanceTimersByTimeAsync(6_000); + + const params = created?.getRequestParams(); + const state = track(params); + + await vi.advanceTimersByTimeAsync(3_999); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + }); + + it('never holds one sign-in past the deadline when the lock is granted late', async () => { + Object.defineProperty(navigator, 'locks', { + value: { + request: vi.fn((_key: string, _options: unknown, callback: () => Promise) => + new Promise(resolve => setTimeout(resolve, 4_900)).then(() => callback()), + ), + }, + configurable: true, + }); + + const { session: created } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + const state = track(params); + + await vi.advanceTimersByTimeAsync(9_999); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + }); + + it('keeps waiting for a leader tab after SafeLock gives up, until its own deadline', async () => { + Object.defineProperty(navigator, 'locks', { + value: { + // Never granted; rejects on SafeLock's abort the way the Web Locks API does. + request: vi.fn( + (_key: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }), + ), + }, + configurable: true, + }); + + const { session: created, elements } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + + await vi.advanceTimersByTimeAsync(7_000); + localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); + await vi.advanceTimersByTimeAsync(100); + + await expect(params).resolves.toMatchObject({ + __clerk_protect_token: 'v1.leader.mac', + __clerk_protect_status: 'ok', + }); + expect(elements).toHaveLength(0); + }); +}); + describe('ProtectSession cross-tab single flight', () => { /** * A serialising Web Locks stand-in. jsdom has no `navigator.locks`, and the `browser-tabs-lock` diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index 25c261e0cba..c3abbd749fb 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -34,6 +34,8 @@ const TOKEN_REFRESH_MARGIN_MS = 60 * 1_000; const DEFAULT_TOKEN_TIMEOUT_MS = 10 * 1_000; /** Ceiling on the instance-configured deadline, so no server value can stall a sign-in. */ const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000; +/** How often a tab that could not take the lock re-reads the store while another tab mints. */ +const STORE_POLL_INTERVAL_MS = 100; /** A stored token claiming more remaining life than this was not minted by us. */ const MAX_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1_000; /** Longest token we will hand back, so a planted store entry cannot bloat a sign-in body. */ @@ -666,6 +668,7 @@ export class ProtectSession { return 'ok'; } + const deadline = Date.now() + this.#timeoutMs; const result = await this.#lock.acquireLockAndRun(async () => { // Double-checked inside the lock. Without this re-read every tab runs the loader in turn, // which is the exact failure this design exists to prevent. @@ -680,8 +683,19 @@ export class ProtectSession { } // The lock was never held — `SafeLock` gives up waiting after ~5s so a wedged leader delays - // nobody. Whatever a leader did manage to write is still ours to use. - return this.#freshStoredToken() ? 'ok' : 'timeout'; + // nobody. Whatever a leader manages to write before our own deadline is still ours to use. + return (await this.#awaitStoredToken(deadline)) ? 'ok' : 'timeout'; + } + + async #awaitStoredToken(deadline: number): Promise { + while (!this.#freshStoredToken()) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return false; + } + await new Promise(resolve => setTimeout(resolve, Math.min(STORE_POLL_INTERVAL_MS, remaining))); + } + return true; } /** Runs the loader and takes the token it was served with. Never rejects. */