From 7dfd767b763e2fa4fcf46b8c0609145c22a39416 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 16 Oct 2026 08:39:39 -0400 Subject: [PATCH 1/2] fix(js): keep a Device Intelligence result that arrives after the deadline --- .changeset/protect-keep-late-di-token.md | 5 ++ .../src/core/__tests__/protectSession.test.ts | 84 +++++++++++++++++++ packages/clerk-js/src/core/protectSession.ts | 42 +++++++++- 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 .changeset/protect-keep-late-di-token.md diff --git a/.changeset/protect-keep-late-di-token.md b/.changeset/protect-keep-late-di-token.md new file mode 100644 index 00000000000..94be371e1bf --- /dev/null +++ b/.changeset/protect-keep-late-di-token.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Keep a Device Intelligence result that arrives after ClerkJS has stopped waiting for it, and use it for the next sign-in or sign-up instead of discarding it. Users on slow connections who retry 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..5bc6d33771c 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -741,6 +741,90 @@ describe('ProtectSession deadlines', () => { }); }); +describe('ProtectSession late token', () => { + const STORE_KEY = '__clerk_protect_st'; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const timedOut = async () => { + const h = session([loader({ token_timeout_ms: 5_000 })]); + h.session?.start(); + const first = h.session?.getRequestParams(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(first).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + return h; + }; + + it('keeps a token whose loader finishes after the deadline for the next sign-in', async () => { + const { session: created, elements } = await timedOut(); + + await vi.advanceTimersByTimeAsync(2_000); + serveInline(elements[0], { cid: created?.placeholders().cid }); + await vi.advanceTimersByTimeAsync(0); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.payload.mac', + __clerk_protect_status: 'ok', + }); + // Still inside the cooldown, so the token can only have come from the late load. + expect(elements).toHaveLength(1); + }); + + it('keeps a token whose ready settles after the deadline', async () => { + const { session: created, elements } = session([loader({ token_timeout_ms: 5_000 })]); + created?.start(); + const first = created?.getRequestParams(); + + let release: (value: unknown) => void = () => undefined; + await vi.advanceTimersByTimeAsync(1_000); + serveInline(elements[0], { + cid: created?.placeholders().cid, + ready: new Promise(resolve => { + release = resolve; + }), + }); + await vi.advanceTimersByTimeAsync(4_000); + await expect(first).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + + release({ token: 'v1.late.mac', exp: nowSeconds() + 43_200 }); + await vi.advanceTimersByTimeAsync(0); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.late.mac', + __clerk_protect_status: 'ok', + }); + }); + + it('does not replace a token another tab stored first', async () => { + const { session: created, elements } = await timedOut(); + + localStorage.setItem(STORE_KEY, storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); + serveInline(elements[0], { cid: created?.placeholders().cid }); + await vi.advanceTimersByTimeAsync(0); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.leader.mac' }); + }); + + it.each([ + ['minted for another page', { cid: 'someone-else' }], + ['carrying no token', { ready: Promise.resolve({ status: 'no_token' }) }], + ])('ignores a late result %s', async (_name, overrides) => { + const { session: created, elements } = await timedOut(); + + serveInline(elements[0], { cid: created?.placeholders().cid, ...overrides }); + await vi.advanceTimersByTimeAsync(0); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + expect(localStorage.getItem(STORE_KEY)).toBeNull(); + }); +}); + 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 ee36a82b0e9..96803e4575f 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -688,7 +688,7 @@ export class ProtectSession { async #mint(): Promise { const deadline = Date.now() + this.#timeoutMs; - const outcome = await this.#runTokenLoader(deadline); + const outcome = await this.#runTokenLoader(deadline, () => void this.#keepLateToken()); if (outcome === 'error') { return 'script_error'; } @@ -701,6 +701,9 @@ export class ProtectSession { } const inline = await readInlineToken(this.#cid as string, this.#rid as string, deadline); + if (inline === 'timeout') { + void this.#keepLateToken(); + } if (typeof inline === 'string') { return inline; } @@ -709,11 +712,25 @@ export class ProtectSession { return 'ok'; } + /** A token that lands after the deadline was still minted for this page, so keep it for the next sign-in. */ + async #keepLateToken(): Promise { + if (this.#tokenUrl) { + return; + } + const late = await readInlineToken(this.#cid as string, this.#rid as string, Date.now() + this.#timeoutMs); + // Never replace a token another tab or a newer attempt already stored. + if (typeof late === 'string' || this.#freshStoredToken()) { + return; + } + writeStoredToken(this.#tokenStorageKey, late, this.#floor); + this.#consecutiveFailures = 0; + } + /** * Injects the token loader and settles on whichever of `load`, `error` or the deadline comes * first, or immediately when the element has nothing to fetch. */ - #runTokenLoader(deadline: number): Promise { + #runTokenLoader(deadline: number, onLateLoad?: () => void): Promise { const loader = this.#tokenLoader; if (!loader) { return Promise.resolve('error'); @@ -739,12 +756,29 @@ export class ProtectSession { } return new Promise(resolve => { - const timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - Date.now())); + let timedOut = false; + const timer = setTimeout( + () => { + timedOut = true; + resolve('timeout'); + }, + Math.max(0, deadline - Date.now()), + ); const settle = (outcome: LoaderOutcome) => () => { clearTimeout(timer); resolve(outcome); }; - element.addEventListener('load', settle('loaded'), { once: true }); + element.addEventListener( + 'load', + () => { + if (timedOut) { + onLateLoad?.(); + return; + } + settle('loaded')(); + }, + { once: true }, + ); element.addEventListener('error', settle('error'), { once: true }); }); } From b5f04eaf8c9a61332f43366e6dcb5108d3748d1b Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 16 Oct 2026 09:23:02 -0400 Subject: [PATCH 2/2] fix(js): store a late Device Intelligence token under the lock and keep the better token --- packages/clerk-js/bundlewatch.config.json | 2 +- .../src/core/__tests__/protectSession.test.ts | 72 ++++++++++++++++++- packages/clerk-js/src/core/protectSession.ts | 22 ++++-- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index ce853a560ba..d0c3164a7d3 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -3,7 +3,7 @@ { "path": "./dist/clerk.js", "maxSize": "556KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "83KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.5KB" }, { "path": "./dist/clerk.native.js", "maxSize": "82KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index 5bc6d33771c..4e548411116 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -750,6 +750,7 @@ describe('ProtectSession late token', () => { afterEach(() => { vi.useRealTimers(); + delete (navigator as unknown as Record).locks; }); const timedOut = async () => { @@ -792,6 +793,8 @@ describe('ProtectSession late token', () => { await vi.advanceTimersByTimeAsync(4_000); await expect(first).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + // Well past a second deadline window, so only an unbounded late read can still catch it. + await vi.advanceTimersByTimeAsync(7_000); release({ token: 'v1.late.mac', exp: nowSeconds() + 43_200 }); await vi.advanceTimersByTimeAsync(0); @@ -801,16 +804,81 @@ describe('ProtectSession late token', () => { }); }); - it('does not replace a token another tab stored first', async () => { + it('does not replace a stored token that lasts longer', async () => { const { session: created, elements } = await timedOut(); - localStorage.setItem(STORE_KEY, storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); + localStorage.setItem( + STORE_KEY, + storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26), exp: nowSeconds() + 43_260 }), + ); serveInline(elements[0], { cid: created?.placeholders().cid }); await vi.advanceTimersByTimeAsync(0); await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.leader.mac' }); }); + it('replaces a stored token that is still usable but expires sooner', async () => { + const { session: created, elements } = await timedOut(); + + localStorage.setItem(STORE_KEY, storedEntry({ token: 'v1.old.mac', rid: 'c'.repeat(26), exp: nowSeconds() + 45 })); + serveInline(elements[0], { cid: created?.placeholders().cid }); + await vi.advanceTimersByTimeAsync(0); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' }); + }); + + it('does not replace a stored token stamped with a newer invalidation floor', async () => { + const floor = nowSeconds() - 60; + const created = session([loader({ token_timeout_ms: 5_000 })], floor); + created.session?.start(); + const first = created.session?.getRequestParams(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(first).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + + localStorage.setItem( + STORE_KEY, + storedEntry({ token: 'v1.newer.mac', rid: 'c'.repeat(26), exp: nowSeconds() + 3_600, floor: floor + 30 }), + ); + serveInline(created.elements[0], { cid: created.session?.placeholders().cid }); + await vi.advanceTimersByTimeAsync(0); + + await expect(created.session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.newer.mac' }); + }); + + it('waits for the cross-tab lock before storing a late token', async () => { + let tail: Promise = Promise.resolve(); + Object.defineProperty(navigator, 'locks', { + value: { + request: vi.fn((_key: string, _options: unknown, callback: () => Promise) => { + const result = tail.then(() => callback()); + tail = result.catch(() => undefined); + return result; + }), + }, + configurable: true, + }); + + const a = await timedOut(); + + // Another tab takes the lock and starts minting. + const b = session([loader({ token_timeout_ms: 5_000 })]); + b.session?.start(); + await vi.advanceTimersByTimeAsync(0); + expect(b.elements).toHaveLength(1); + + serveInline(a.elements[0], { cid: a.session?.placeholders().cid }); + await vi.advanceTimersByTimeAsync(0); + expect(localStorage.getItem(STORE_KEY)).toBeNull(); + + serveInline(b.elements[0], { + cid: b.session?.placeholders().cid, + ready: Promise.resolve({ token: 'v1.b.mac', exp: nowSeconds() + 43_260 }), + }); + await vi.advanceTimersByTimeAsync(0); + + expect(JSON.parse(localStorage.getItem(STORE_KEY) as string)).toMatchObject({ token: 'v1.b.mac' }); + }); + it.each([ ['minted for another page', { cid: 'someone-else' }], ['carrying no token', { ready: Promise.resolve({ status: 'no_token' }) }], diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index 96803e4575f..c9b00261dc7 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -366,7 +366,7 @@ function settleWithin(ready: PromiseLike, deadline: number): Promise { +async function readInlineToken(cid: string, rid: string, deadline?: number): Promise { const inline = (globalThis as unknown as Record)[INLINE_TOKEN_GLOBAL]; if (!inline || typeof inline !== 'object') { return 'no_token'; @@ -379,7 +379,9 @@ async function readInlineToken(cid: string, rid: string, deadline: number): Prom return 'no_token'; } - const resolved = await settleWithin(ready, deadline); + // No deadline means a late read, which waits for `ready` however long it takes. + const resolved = + deadline === undefined ? await Promise.resolve(ready).catch(() => null) : await settleWithin(ready, deadline); if (resolved === DEADLINE) { return 'timeout'; } @@ -717,13 +719,19 @@ export class ProtectSession { if (this.#tokenUrl) { return; } - const late = await readInlineToken(this.#cid as string, this.#rid as string, Date.now() + this.#timeoutMs); - // Never replace a token another tab or a newer attempt already stored. - if (typeof late === 'string' || this.#freshStoredToken()) { + const late = await readInlineToken(this.#cid as string, this.#rid as string); + if (typeof late === 'string') { return; } - writeStoredToken(this.#tokenStorageKey, late, this.#floor); - this.#consecutiveFailures = 0; + await this.#lock.acquireLockAndRun(() => { + const stored = readStoredToken(this.#tokenStorageKey, TOKEN_SEND_MARGIN_MS, this.#floor); + // Keep whichever token is better: a newer invalidation floor first, then the later expiry. + if (!stored || (stored.floor <= this.#floor && stored.exp < late.exp)) { + writeStoredToken(this.#tokenStorageKey, late, this.#floor); + this.#consecutiveFailures = 0; + } + return Promise.resolve(); + }); } /**