From 88f506faf39962dc9ce061a8475ac2d6272f42f6 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 23 Oct 2026 14:27:06 -0400 Subject: [PATCH 1/5] fix(js): account for clock skew in Device Intelligence tokens --- .../fix-device-intelligence-clock-skew.md | 5 + .../src/core/__tests__/protectSession.test.ts | 223 ++++++++++++++++++ packages/clerk-js/src/core/protectSession.ts | 83 +++++-- 3 files changed, 290 insertions(+), 21 deletions(-) create mode 100644 .changeset/fix-device-intelligence-clock-skew.md diff --git a/.changeset/fix-device-intelligence-clock-skew.md b/.changeset/fix-device-intelligence-clock-skew.md new file mode 100644 index 00000000000..a251eccf446 --- /dev/null +++ b/.changeset/fix-device-intelligence-clock-skew.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix Device Intelligence tokens being discarded when the device's clock is ahead or behind the server's clock. diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index bb8056d59e9..953b4f8a33a 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -533,6 +533,229 @@ describe('ProtectSession inline token', () => { }); }); +describe('ProtectSession token clock', () => { + const serverNow = Date.UTC(2024, 0, 1, 12); + const hour = 60 * 60 * 1_000; + const lifetimeSeconds = 43_200; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(serverNow); + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request: (_key: string, _options: unknown, callback: () => Promise) => callback() }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (navigator as unknown as Record).locks; + }); + + it.each([-48, -24, 24, 48])('accepts a fresh token with a browser clock offset of %s hours', async hours => { + vi.setSystemTime(serverNow + hours * hour); + const startedAt = Date.now(); + const { session: created, elements } = session([loader()]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }), + }); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_status: 'ok', + __clerk_protect_token: 'v1.payload.mac', + }); + expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string).exp).toBe( + startedAt / 1_000 + lifetimeSeconds, + ); + + const { session: otherTab, elements: otherElements } = session([loader()]); + expect(otherTab?.hasFreshToken()).toBe(true); + await expect(otherTab?.getRequestParams()).resolves.toEqual(await created?.getRequestParams()); + expect(otherElements).toHaveLength(0); + }); + + it('deducts loader and ready delays from the cached lifetime', async () => { + vi.setSystemTime(serverNow + 24 * hour); + const startedAt = Date.now(); + const { session: created, elements } = session([loader({ token_timeout_ms: 5_000 })]); + created?.start(); + await vi.advanceTimersByTimeAsync(2_000); + let release: (value: unknown) => void = () => undefined; + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: new Promise(resolve => { + release = resolve; + }), + }); + await vi.advanceTimersByTimeAsync(2_000); + release({ token: 'v1.payload.mac', exp: serverNow / 1_000 + 20 }); + await vi.advanceTimersByTimeAsync(0); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string).exp).toBe(startedAt / 1_000 + 20); + }); + + it('translates the expiry for a classic inline loader', async () => { + vi.setSystemTime(serverNow - 24 * hour); + const { session: created } = session([loader({ attributes: { 'data-cid': '{cid}' } })]); + (globalThis as unknown as Record).__clerk_specter = { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }), + }; + created?.start(); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + }); + + it('keeps the original clock reference if another run publishes while ready is pending', async () => { + vi.setSystemTime(serverNow + 24 * hour); + const { session: created, elements } = session([loader()]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + let release: (value: unknown) => void = () => undefined; + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: new Promise(resolve => { + release = resolve; + }), + }); + await vi.advanceTimersByTimeAsync(0); + (globalThis as unknown as Record).__clerk_specter = { cid: 'another-run', p: { t: serverNow } }; + release({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + }); + + it('does not renew a token when a cached module publishes the same result again', async () => { + vi.setSystemTime(serverNow + 24 * hour); + const { session: created, elements } = session([loader()]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + 20 }), + }); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + + await vi.advanceTimersByTimeAsync(35_000); + const next = created?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + elements[1].dispatchEvent(new Event('load')); + + await expect(next).resolves.toMatchObject({ __clerk_protect_status: 'no_token' }); + }); + + it('rejects a result when the browser clock moved backwards while loading', async () => { + vi.setSystemTime(serverNow + 24 * hour); + const { session: created, elements } = session([loader()]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + vi.setSystemTime(Date.now() - hour); + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }), + }); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'no_token' }); + }); + + it('preserves refresh and send margins after translating the expiry', async () => { + vi.setSystemTime(serverNow - 24 * hour); + const { session: created, elements } = session([loader({ token_timeout_ms: 1_000 })]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + 70 }), + }); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + expect(created?.hasFreshToken()).toBe(true); + + await vi.advanceTimersByTimeAsync(10_000); + expect(created?.hasFreshToken()).toBe(false); + await vi.advanceTimersByTimeAsync(54_000); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' }); + + await vi.advanceTimersByTimeAsync(1_000); + const expired = created?.getRequestParams(); + await vi.advanceTimersByTimeAsync(1_000); + await expect(expired).resolves.not.toHaveProperty('__clerk_protect_token'); + }); + + it.each([-1, 0, 24 * 60 * 60 + 1, Number.POSITIVE_INFINITY, Number.NaN])( + 'rejects a token with an invalid relative lifetime of %s seconds', + async lifetime => { + vi.setSystemTime(serverNow + 24 * hour); + const { session: created, elements } = session([loader()]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetime }), + }); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'no_token' }); + expect(localStorage.getItem('__clerk_protect_st')).toBeNull(); + }, + ); + + it.each([undefined, null, {}, { t: '123' }, { t: Number.NaN }, { t: Number.POSITIVE_INFINITY }, { t: -1 }])( + 'retains legacy validation when the loader clock is unavailable: %j', + async p => { + const { session: created, elements } = session([loader()]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { cid: created?.placeholders().cid, p }); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + }, + ); + + it('uses the same clock reference for an optional token endpoint', async () => { + vi.setSystemTime(serverNow + 24 * hour); + const startedAt = Date.now(); + global.fetch = vi.fn(() => + Promise.resolve(tokenResponse('v1.upgraded.mac', serverNow / 1_000 + lifetimeSeconds)), + ) as typeof fetch; + const { session: created, elements } = session([loader({ token_url: 'https://loader.example.com/{cid}/token' })]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { cid: created?.placeholders().cid, p: { t: serverNow } }); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_status: 'ok', + __clerk_protect_token: 'v1.upgraded.mac', + }); + expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string).exp).toBe( + startedAt / 1_000 + lifetimeSeconds, + ); + }); + + it('does not use a clock reference from another loader run', async () => { + vi.setSystemTime(serverNow + 24 * hour); + global.fetch = vi.fn(() => + Promise.resolve(tokenResponse('v1.upgraded.mac', serverNow / 1_000 + lifetimeSeconds)), + ) as typeof fetch; + const { session: created, elements } = session([loader({ token_url: 'https://loader.example.com/{cid}/token' })]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { cid: 'another-run', p: { t: serverNow } }); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'fetch_error' }); + }); +}); + describe('ProtectSession upgrade mint', () => { const upgrade = (overrides: Partial = {}) => loader({ token_url: 'https://loader.example.com/{cid}/token', ...overrides }); diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index ee36a82b0e9..48c4dbacc9e 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -105,13 +105,12 @@ export type ApplyLoader = (loader: ProtectLoader, placeholders: ProtectPlacehold type StoredToken = { token: string; - /** Unix seconds, as served by the token endpoint. */ + /** Expiry in seconds on this browser's clock. */ exp: number; rid: string; /** - * Local milliseconds at the moment we stored it. `exp` compares server truth against this - * browser's clock, so a skewed clock either ships lapsed tokens or discards good ones; this - * bounds both by elapsed local time, which is measured entirely on the one clock. + * Local milliseconds at the moment we stored it, bounding cache age independently of expiry + * and rejecting an entry stamped before the browser's clock moved backwards. */ at: number; /** @@ -129,6 +128,10 @@ type StoredToken = { */ type MintedToken = Omit; +type TokenClock = { localTimeMs: number; serverTimeMs: number }; + +const tokenClocks = new WeakMap(); + type LoaderOutcome = 'loaded' | 'error' | 'timeout'; /** @@ -288,14 +291,9 @@ function normalizeFloor(value: unknown): number { } /** - * The clock-skew bound. `exp` is server truth compared against this browser's clock, so a clock - * running slow keeps an entry alive long past its real expiry and a clock running fast throws - * good ones away. Elapsed time since we stored it is measured on one clock, so a constant offset - * cancels: an entry older than any token we would ever be issued is stale whatever `exp` claims, + * Elapsed cache age and a translated expiry use the same browser clock, so a constant offset + * cancels. An entry older than any token we would ever be issued is stale whatever `exp` claims, * and one stamped in the future means the clock moved backwards under us. - * - * This bounds the damage rather than removing it — a fully skew-proof check needs the server to - * send a relative lifetime instead of an absolute `exp`, which is a wire change. */ function freshByLocalClock(at: unknown): boolean { if (typeof at !== 'number' || !Number.isFinite(at)) { @@ -314,18 +312,31 @@ function freshByLocalClock(at: unknown): boolean { * directly. What it buys is that a corrupt or truncated entry starts a fresh run immediately * instead of suppressing acquisition until it expires. */ -function validateToken(token: unknown, exp: unknown, marginMs: number): { token: string; exp: number } | null { +function validateToken( + token: unknown, + exp: unknown, + marginMs: number, + clock?: TokenClock, +): { token: string; exp: number } | null { if (typeof token !== 'string' || token.length > MAX_TOKEN_LENGTH || !TOKEN_SHAPE.test(token)) { return null; } if (typeof exp !== 'number' || !Number.isFinite(exp)) { return null; } - const expMs = exp * 1_000; - if (expMs - marginMs <= Date.now() || expMs > Date.now() + MAX_TOKEN_LIFETIME_MS) { + const now = Date.now(); + let expMs = exp * 1_000; + if (clock) { + const lifetimeMs = expMs - clock.serverTimeMs; + if (lifetimeMs <= 0 || lifetimeMs > MAX_TOKEN_LIFETIME_MS || clock.localTimeMs > now) { + return null; + } + expMs = clock.localTimeMs + lifetimeMs; + } + if (expMs - marginMs <= now || expMs > now + MAX_TOKEN_LIFETIME_MS) { return null; } - return { token, exp }; + return { token, exp: expMs / 1_000 }; } /** `at` and `floor` are the writer's business, not the caller's — both stamped here, at the write. */ @@ -373,6 +384,7 @@ async function readInlineToken(cid: string, rid: string, deadline: number): Prom } const { cid: mintedFor, ready } = inline as Record; + const clock = tokenClocks.get(inline); // The global is page-visible and outlives a navigation, so a token minted for anyone else's run // is not ours to send. The base shape, which carries no cid at all, lands here too. if (mintedFor !== cid || !isThenable(ready)) { @@ -388,11 +400,36 @@ async function readInlineToken(cid: string, rid: string, deadline: number): Prom } const { token, exp } = resolved as Record; - const validated = validateToken(token, exp, 0); + const validated = validateToken(token, exp, 0, clock); // `{ status: 'no_token' }` has no token to validate and lands here, as does a malformed one. return validated ? { ...validated, rid } : 'no_token'; } +function readTokenClock(cid: string, localTimeMs?: number): TokenClock | undefined { + const inline = (globalThis as unknown as Record)[INLINE_TOKEN_GLOBAL]; + if (!inline || typeof inline !== 'object') { + return undefined; + } + const { cid: mintedFor, p } = inline as Record; + if (mintedFor !== cid) { + return undefined; + } + const existing = tokenClocks.get(inline); + if (existing || localTimeMs === undefined) { + return existing; + } + if (!p || typeof p !== 'object') { + return undefined; + } + const { t } = p as Record; + if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0) { + return undefined; + } + const clock = { localTimeMs, serverTimeMs: t }; + tokenClocks.set(inline, clock); + return clock; +} + function httpStatus(status: number): ProtectStatus { // The status enum only admits `http_1xx`-`http_5xx`; anything else would be dropped by FAPI. return status >= 100 && status <= 599 ? (`http_${status}` as ProtectStatus) : 'fetch_error'; @@ -697,7 +734,7 @@ export class ProtectSession { } if (this.#tokenUrl) { - return await this.#poll(deadline); + return await this.#poll(deadline, readTokenClock(this.#cid as string)); } const inline = await readInlineToken(this.#cid as string, this.#rid as string, deadline); @@ -719,6 +756,7 @@ export class ProtectSession { return Promise.resolve('error'); } + const startedAt = Date.now(); let element: HTMLElement | undefined; try { element = this.#applyLoader(loader, this.#placeholders); @@ -735,9 +773,12 @@ export class ProtectSession { // we ever looked. An inline module is the exception: it evaluates off a fetched graph, so its // `load` does arrive and settling early would read the global before it is written. if (!isFetching(element)) { + readTokenClock(this.#cid as string, startedAt); return Promise.resolve('loaded'); } + element.addEventListener('load', () => readTokenClock(this.#cid as string, startedAt), { once: true }); + return new Promise(resolve => { const timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - Date.now())); const settle = (outcome: LoaderOutcome) => () => { @@ -750,7 +791,7 @@ export class ProtectSession { } /** The upgrade mint: polls the token endpoint until the deadline. Never rejects. */ - async #poll(deadline: number): Promise { + async #poll(deadline: number, clock?: TokenClock): Promise { const url = this.#tokenUrl as string; const controller = new AbortController(); const deadlineTimer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now())); @@ -770,7 +811,7 @@ export class ProtectSession { } if (response.status === 200) { - const token = await readTokenPayload(response, this.#rid as string); + const token = await readTokenPayload(response, this.#rid as string, clock); if (!token) { return 'fetch_error'; } @@ -822,7 +863,7 @@ function resolveTokenUrl(loader: ProtectLoader, placeholders: ProtectPlaceholder } } -async function readTokenPayload(response: Response, rid: string): Promise { +async function readTokenPayload(response: Response, rid: string, clock?: TokenClock): Promise { let json: unknown; try { json = await response.json(); @@ -835,7 +876,7 @@ async function readTokenPayload(response: Response, rid: string): Promise; - const validated = validateToken(token, exp, 0); + const validated = validateToken(token, exp, 0, clock); return validated ? { ...validated, rid } : null; } From 791025c7af31cd2fa2441e5527cc3eb8837217c2 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 23 Oct 2026 14:29:22 -0400 Subject: [PATCH 2/5] fix(js): retry token acquisition after a clock correction --- .../src/core/__tests__/protectSession.test.ts | 32 ++++++++++++++++++- packages/clerk-js/src/core/protectSession.ts | 3 +- 2 files changed, 33 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 953b4f8a33a..5807d022bf1 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -653,7 +653,7 @@ describe('ProtectSession token clock', () => { await expect(next).resolves.toMatchObject({ __clerk_protect_status: 'no_token' }); }); - it('rejects a result when the browser clock moved backwards while loading', async () => { + it('reacquires after the browser clock moves backwards while loading', async () => { vi.setSystemTime(serverNow + 24 * hour); const { session: created, elements } = session([loader()]); created?.start(); @@ -666,6 +666,36 @@ describe('ProtectSession token clock', () => { }); await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'no_token' }); + + const retry = created?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + expect(elements).toHaveLength(2); + serveInline(elements[1], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }), + }); + await expect(retry).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + }); + + it('reacquires when correcting the browser clock invalidates the cached token', async () => { + vi.setSystemTime(serverNow + 24 * hour); + const { session: created, elements } = session([loader()]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }), + }); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + + vi.setSystemTime(serverNow); + const retry = created?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + expect(elements).toHaveLength(2); + serveInline(elements[1], { cid: created?.placeholders().cid, p: { t: serverNow } }); + await expect(retry).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); }); it('preserves refresh and send margins after translating the expiry', async () => { diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index 48c4dbacc9e..55241026f7a 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -659,7 +659,8 @@ export class ProtectSession { if (readStoredToken(this.#tokenStorageKey, TOKEN_SEND_MARGIN_MS, this.#floor)) { return; } - if (Date.now() - this.#lastAttemptAt < this.#backoffMs()) { + const elapsed = Date.now() - this.#lastAttemptAt; + if (elapsed >= 0 && elapsed < this.#backoffMs()) { return; } this.#startAcquisition(); From 5d5f67add21564660c93c33c676fe5ed12f30a50 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 23 Oct 2026 15:48:11 -0400 Subject: [PATCH 3/5] fix(js): bound cached token lifetimes across clock corrections --- .../fix-device-intelligence-clock-skew.md | 2 + packages/clerk-js/bundlewatch.config.json | 4 +- .../src/core/__tests__/protectSession.test.ts | 192 ++++++++++++++++-- packages/clerk-js/src/core/protectSession.ts | 163 ++++++++++----- 4 files changed, 296 insertions(+), 65 deletions(-) diff --git a/.changeset/fix-device-intelligence-clock-skew.md b/.changeset/fix-device-intelligence-clock-skew.md index a251eccf446..8e01106b034 100644 --- a/.changeset/fix-device-intelligence-clock-skew.md +++ b/.changeset/fix-device-intelligence-clock-skew.md @@ -3,3 +3,5 @@ --- Fix Device Intelligence tokens being discarded when the device's clock is ahead or behind the server's clock. + +Prevent device clock corrections from extending cached token lifetimes, and refresh cached results when their timing reference cannot be verified. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 1823d6b7b5b..17e43e1d98b 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -3,8 +3,8 @@ { "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.75KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "82.5KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "323.5KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "83KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" }, diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index 5807d022bf1..6f370c5c702 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -548,6 +548,7 @@ describe('ProtectSession token clock', () => { }); afterEach(() => { + vi.unstubAllGlobals(); vi.useRealTimers(); delete (navigator as unknown as Record).locks; }); @@ -653,7 +654,7 @@ describe('ProtectSession token clock', () => { await expect(next).resolves.toMatchObject({ __clerk_protect_status: 'no_token' }); }); - it('reacquires after the browser clock moves backwards while loading', async () => { + it('retains a fresh token when the browser clock moves backwards while loading', async () => { vi.setSystemTime(serverNow + 24 * hour); const { session: created, elements } = session([loader()]); created?.start(); @@ -665,21 +666,149 @@ describe('ProtectSession token clock', () => { ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }), }); - await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'no_token' }); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + expect(elements).toHaveLength(1); + }); - const retry = created?.getRequestParams(); + it('retains the original lifetime when the browser clock moves before the cached write time', async () => { + vi.setSystemTime(serverNow + 24 * hour); + const { session: created, elements } = session([loader()]); + created?.start(); await vi.advanceTimersByTimeAsync(0); - expect(elements).toHaveLength(2); - serveInline(elements[1], { + serveInline(elements[0], { cid: created?.placeholders().cid, p: { t: serverNow }, ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }), }); - await expect(retry).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + + vi.setSystemTime(serverNow); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + expect(elements).toHaveLength(1); + await vi.advanceTimersByTimeAsync(lifetimeSeconds * 1_000); + expect(created?.hasFreshToken()).toBe(false); }); - it('reacquires when correcting the browser clock invalidates the cached token', async () => { - vi.setSystemTime(serverNow + 24 * hour); + it.each([false, true])('reacquires an aged token after a partial clock correction, new session: %s', async other => { + vi.setSystemTime(serverNow + hour); + const first = session([loader()]); + first.session?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(first.elements[0], { + cid: first.session?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.original.mac', exp: serverNow / 1_000 + lifetimeSeconds }), + }); + await expect(first.session?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.original.mac', + }); + + await vi.advanceTimersByTimeAsync(2 * hour); + vi.setSystemTime(serverNow + 2 * hour); + const target = other ? session([loader()]) : first; + await vi.advanceTimersByTimeAsync(10 * hour + 1); + expect(target.session?.hasFreshToken()).toBe(false); + const refreshed = target.session?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + expect(target.elements).toHaveLength(other ? 1 : 2); + serveInline(target.elements.at(-1) as HTMLElement, { + cid: target.session?.placeholders().cid, + p: { t: serverNow + 12 * hour + 1 }, + ready: Promise.resolve({ token: 'v1.refreshed.mac', exp: serverNow / 1_000 + 2 * lifetimeSeconds }), + }); + await expect(refreshed).resolves.toMatchObject({ __clerk_protect_token: 'v1.refreshed.mac' }); + }); + + it('does not extend the send margin after a small backward correction while ready is pending', async () => { + vi.setSystemTime(serverNow + hour); + const { session: created, elements } = session([loader({ token_timeout_ms: 5_000 })]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + let release: (value: unknown) => void = () => undefined; + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: { t: serverNow }, + ready: new Promise(resolve => { + release = resolve; + }), + }); + await vi.advanceTimersByTimeAsync(2_000); + vi.setSystemTime(Date.now() - 1_000); + release({ token: 'v1.payload.mac', exp: serverNow / 1_000 + 8 }); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' }); + await vi.advanceTimersByTimeAsync(1_000); + await expect(created?.getRequestParams()).resolves.not.toHaveProperty('__clerk_protect_token'); + }); + + it.each([ + [-24, 0], + [24, 0], + [0, 1_000], + ])('refreshes an unverifiable cached clock with %s hours of skew and floor %s', async (offset, nextFloor) => { + vi.setSystemTime(serverNow + offset * hour); + const originalPerformance = performance; + const first = session([loader()]); + first.session?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(first.elements[0], { + cid: first.session?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }), + }); + await expect(first.session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' }); + + vi.setSystemTime(serverNow); + vi.stubGlobal('performance', { + timeOrigin: originalPerformance.timeOrigin + 1, + now: () => originalPerformance.now(), + }); + vi.resetModules(); + const { ProtectSession: ReloadedSession } = await import('../protectSession'); + const h = harness(); + const reloaded = ReloadedSession.create([loader()], h.applyLoader, nextFloor); + expect(reloaded?.hasFreshToken()).toBe(false); + const refreshed = reloaded?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + expect(h.elements).toHaveLength(1); + serveInline(h.elements[0], { + cid: reloaded?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.reloaded.mac', exp: serverNow / 1_000 + lifetimeSeconds }), + }); + await expect(refreshed).resolves.toMatchObject({ __clerk_protect_token: 'v1.reloaded.mac' }); + + vi.stubGlobal('performance', originalPerformance); + if (nextFloor === 0 && offset >= 0) { + await expect(first.session?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.payload.mac', + }); + expect(first.elements).toHaveLength(1); + } else if (nextFloor === 0) { + const retry = first.session?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + expect(first.elements).toHaveLength(2); + serveInline(first.elements[1], { + cid: first.session?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.refreshed.mac', exp: serverNow / 1_000 + lifetimeSeconds }), + }); + await expect(retry).resolves.toMatchObject({ __clerk_protect_token: 'v1.refreshed.mac' }); + } else { + expect(first.session?.hasFreshToken()).toBe(false); + await vi.advanceTimersByTimeAsync(30_000); + const retry = first.session?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + serveInline(first.elements[1], { cid: first.session?.placeholders().cid, p: { t: serverNow + 30_000 } }); + await expect(retry).resolves.not.toHaveProperty('__clerk_protect_token'); + expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string)).toMatchObject({ + token: 'v1.reloaded.mac', + floor: nextFloor, + }); + } + }); + + it('does not let a forward clock correction strand a cached token', async () => { + vi.setSystemTime(serverNow - 24 * hour); const { session: created, elements } = session([loader()]); created?.start(); await vi.advanceTimersByTimeAsync(0); @@ -689,13 +818,52 @@ describe('ProtectSession token clock', () => { ready: Promise.resolve({ token: 'v1.payload.mac', exp: serverNow / 1_000 + lifetimeSeconds }), }); await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + await vi.advanceTimersByTimeAsync(2 * hour); + vi.setSystemTime(serverNow + 2 * hour); + expect(created?.hasFreshToken()).toBe(false); + const refreshed = created?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + expect(elements).toHaveLength(2); + serveInline(elements[1], { + cid: created?.placeholders().cid, + p: { t: serverNow + 2 * hour }, + ready: Promise.resolve({ token: 'v1.refreshed.mac', exp: serverNow / 1_000 + 2 * 3_600 + lifetimeSeconds }), + }); + await expect(refreshed).resolves.toMatchObject({ __clerk_protect_token: 'v1.refreshed.mac' }); + }); - vi.setSystemTime(serverNow); - const retry = created?.getRequestParams(); + it.each([null, {}, { exp: 1 }, { exp: nowSeconds() + lifetimeSeconds, expiresAt: 1, timeOrigin: 1 }])( + 'rejects malformed or foreign monotonic lifetimes: %j', + lifetime => { + localStorage.setItem('__clerk_protect_st', storedEntry({ lifetime })); + expect(session([loader()]).session?.hasFreshToken()).toBe(false); + }, + ); + + it('reacquires an upgraded token after an aged clock correction', async () => { + vi.setSystemTime(serverNow + hour); + global.fetch = vi.fn(() => + Promise.resolve(tokenResponse('v1.upgraded.mac', serverNow / 1_000 + lifetimeSeconds)), + ) as typeof fetch; + const { session: created, elements } = session([loader({ token_url: 'https://loader.example.com/{cid}/token' })]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { cid: created?.placeholders().cid, p: { t: serverNow } }); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.upgraded.mac' }); + + await vi.advanceTimersByTimeAsync(2 * hour); + vi.setSystemTime(serverNow + 2 * hour); + await vi.advanceTimersByTimeAsync(10 * hour + 1); + expect(created?.hasFreshToken()).toBe(false); + (global.fetch as Mock).mockImplementation(() => + Promise.resolve(tokenResponse('v1.refreshed.mac', serverNow / 1_000 + 2 * lifetimeSeconds)), + ); + const refreshed = created?.getRequestParams(); await vi.advanceTimersByTimeAsync(0); expect(elements).toHaveLength(2); - serveInline(elements[1], { cid: created?.placeholders().cid, p: { t: serverNow } }); - await expect(retry).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + serveInline(elements[1], { cid: created?.placeholders().cid, p: { t: serverNow + 12 * hour + 1 } }); + await expect(refreshed).resolves.toMatchObject({ __clerk_protect_token: 'v1.refreshed.mac' }); + expect(global.fetch).toHaveBeenCalledTimes(2); }); it('preserves refresh and send margins after translating the expiry', async () => { diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index 55241026f7a..290697da027 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -8,8 +8,8 @@ import { SafeLock } from './auth/safeLock'; * The browser mints an opaque correlation id (`cid`) and hands it to the loader through the * element's attributes, which are per-instance server config. The server mints a signed session * token while serving that loader and returns it inline on the script's own global, so the gate - * costs no extra round trip. We acquire it exactly once per browser session across all tabs and - * attach it to sign-in and sign-up requests. + * costs no extra round trip. Translated tokens keep a per-page monotonic lifetime; other pages + * refresh them rather than trusting a clock reference they cannot verify. * * An instance that configures `tokenUrl` gets the upgrade mint instead: a `GET` for a token that * attests to work happening after the script runs. Only that path can report `fetch_error` or @@ -105,13 +105,10 @@ export type ApplyLoader = (loader: ProtectLoader, placeholders: ProtectPlacehold type StoredToken = { token: string; - /** Expiry in seconds on this browser's clock. */ + /** Local expiry for older readers; `lifetime` bounds translated tokens independently. */ exp: number; rid: string; - /** - * Local milliseconds at the moment we stored it, bounding cache age independently of expiry - * and rejecting an entry stamped before the browser's clock moved backwards. - */ + /** Local write time, bounding cache age for entries without a monotonic lifetime. */ at: number; /** * The `tokens_invalid_before` in force when this was acquired. Raising the configured value @@ -119,6 +116,7 @@ type StoredToken = { * in minutes rather than over the token's full lifetime. */ floor: number; + lifetime?: TokenLifetime; }; /** @@ -128,9 +126,12 @@ type StoredToken = { */ type MintedToken = Omit; -type TokenClock = { localTimeMs: number; serverTimeMs: number }; +type TokenClock = { localTimeMs: number; serverTimeMs: number; monotonicTimeMs: number; timeOrigin: number }; + +type TokenLifetime = { exp: number; expiresAt: number; timeOrigin: number }; const tokenClocks = new WeakMap(); +const tokenLifetimes = new Map(); type LoaderOutcome = 'loaded' | 'error' | 'timeout'; @@ -185,14 +186,15 @@ export function buildCid(pid: string, rid: string): string { } /** - * `localStorage` with an in-memory fallback. A sandboxed iframe, blocked storage or a full quota - * costs us cross-tab sharing, never a sign-in, so nothing in here throws. + * `localStorage` with an in-memory fallback. Translated tokens also retain a page-local copy so + * another page's clock reference cannot strand this page's valid token. Nothing in here throws. */ const memoryStore = new Map(); /** Test-only: the fallback outlives any single session, so a suite has to be able to clear it. */ export function __internal_resetProtectStorage(): void { memoryStore.clear(); + tokenLifetimes.clear(); } function localStorageOrNull(): Storage | null { @@ -252,37 +254,34 @@ function readOrMintPid(): string | null { * `tokens_invalid_before`; an entry acquired under an older one is stranded. */ function readStoredToken(key: string, marginMs: number, floor: number): StoredToken | null { - const raw = readStored(key); - if (!raw) { - return null; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - - if (!parsed || typeof parsed !== 'object') { - return null; - } + for (const raw of new Set([readStored(key), memoryStore.get(key)])) { + if (!raw) { + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + continue; + } + if (!parsed || typeof parsed !== 'object') { + continue; + } - const { token, exp, rid, at, floor: storedFloor } = parsed as Record; - if (typeof rid !== 'string' || !BASE32_128_REGEX.test(rid)) { - return null; - } - if (!freshByLocalClock(at)) { - return null; - } - // An entry with no floor predates the mechanism and counts as zero, so an instance that has - // never set one keeps its cached tokens rather than re-acquiring on every browser at once. - if (normalizeFloor(storedFloor) < floor) { - return null; + const { token, exp, rid, at, lifetime, floor: storedFloor } = parsed as Record; + if (typeof rid !== 'string' || !BASE32_128_REGEX.test(rid) || typeof at !== 'number' || !Number.isFinite(at)) { + continue; + } + floor = Math.max(floor, normalizeFloor(storedFloor)); + if (normalizeFloor(storedFloor) < floor || (lifetime === undefined && !freshByLocalClock(at))) { + continue; + } + const validated = validateToken(token, exp, marginMs, undefined, lifetime); + if (validated) { + return { ...validated, rid, at, floor: normalizeFloor(storedFloor) }; + } } - - const validated = validateToken(token, exp, marginMs); - return validated ? { ...validated, rid, at: at as number, floor: normalizeFloor(storedFloor) } : null; + return null; } /** A floor is unix seconds; anything else configured or stored is treated as no floor at all. */ @@ -291,9 +290,8 @@ function normalizeFloor(value: unknown): number { } /** - * Elapsed cache age and a translated expiry use the same browser clock, so a constant offset - * cancels. An entry older than any token we would ever be issued is stale whatever `exp` claims, - * and one stamped in the future means the clock moved backwards under us. + * Legacy entries use wall-clock age as an additional bound, rejecting future writes and entries + * older than the maximum token lifetime. Translated entries use their monotonic lifetime instead. */ function freshByLocalClock(at: unknown): boolean { if (typeof at !== 'number' || !Number.isFinite(at)) { @@ -317,7 +315,8 @@ function validateToken( exp: unknown, marginMs: number, clock?: TokenClock, -): { token: string; exp: number } | null { + storedLifetime?: unknown, +): { token: string; exp: number; lifetime?: TokenLifetime } | null { if (typeof token !== 'string' || token.length > MAX_TOKEN_LENGTH || !TOKEN_SHAPE.test(token)) { return null; } @@ -326,12 +325,48 @@ function validateToken( } const now = Date.now(); let expMs = exp * 1_000; + let lifetime: unknown = storedLifetime === undefined ? tokenLifetimes.get(token) : storedLifetime; if (clock) { const lifetimeMs = expMs - clock.serverTimeMs; - if (lifetimeMs <= 0 || lifetimeMs > MAX_TOKEN_LIFETIME_MS || clock.localTimeMs > now) { + if (lifetimeMs <= 0 || lifetimeMs > MAX_TOKEN_LIFETIME_MS) { return null; } expMs = clock.localTimeMs + lifetimeMs; + lifetime = { + exp: expMs / 1_000, + expiresAt: clock.monotonicTimeMs + lifetimeMs, + timeOrigin: clock.timeOrigin, + }; + } + if (lifetime !== undefined) { + if (!lifetime || typeof lifetime !== 'object') { + return null; + } + const { exp: translatedExp, expiresAt, timeOrigin } = lifetime as Record; + if (translatedExp !== expMs / 1_000) { + if (storedLifetime !== undefined) { + return null; + } + lifetime = undefined; + } else { + if ( + typeof performance === 'undefined' || + typeof expiresAt !== 'number' || + !Number.isFinite(expiresAt) || + typeof timeOrigin !== 'number' || + !Number.isFinite(timeOrigin) || + timeOrigin !== performance.timeOrigin + ) { + return null; + } + const remaining = Math.min(expiresAt - performance.now(), expMs - now); + if (!Number.isFinite(remaining) || remaining <= marginMs || remaining > MAX_TOKEN_LIFETIME_MS) { + return null; + } + const validatedLifetime = lifetime as TokenLifetime; + tokenLifetimes.set(token, validatedLifetime); + return { token, exp: expMs / 1_000, lifetime: validatedLifetime }; + } } if (expMs - marginMs <= now || expMs > now + MAX_TOKEN_LIFETIME_MS) { return null; @@ -341,7 +376,23 @@ function validateToken( /** `at` and `floor` are the writer's business, not the caller's — both stamped here, at the write. */ function writeStoredToken(key: string, value: MintedToken, floor: number): void { - writeStored(key, JSON.stringify({ ...value, at: Date.now(), floor })); + const validated = validateToken(value.token, value.exp, 0, undefined, value.lifetime); + if (!validated) { + return; + } + const raw = JSON.stringify({ ...value, ...validated, at: Date.now(), floor }); + let existingFloor = 0; + try { + const existing = JSON.parse(readStored(key) ?? 'null') as StoredToken | null; + existingFloor = normalizeFloor(existing?.floor); + } catch { + existingFloor = 0; + } + if (existingFloor > floor) { + return; + } + writeStored(key, raw); + memoryStore.set(key, raw); } /** Sentinel for "the deadline won", distinguishable from anything `ready` could resolve to. */ @@ -405,7 +456,7 @@ async function readInlineToken(cid: string, rid: string, deadline: number): Prom return validated ? { ...validated, rid } : 'no_token'; } -function readTokenClock(cid: string, localTimeMs?: number): TokenClock | undefined { +function readTokenClock(cid: string, localTimeMs?: number, monotonicTimeMs?: number): TokenClock | undefined { const inline = (globalThis as unknown as Record)[INLINE_TOKEN_GLOBAL]; if (!inline || typeof inline !== 'object') { return undefined; @@ -415,17 +466,24 @@ function readTokenClock(cid: string, localTimeMs?: number): TokenClock | undefin return undefined; } const existing = tokenClocks.get(inline); - if (existing || localTimeMs === undefined) { + if (existing || localTimeMs === undefined || monotonicTimeMs === undefined) { return existing; } if (!p || typeof p !== 'object') { return undefined; } const { t } = p as Record; - if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0) { + if ( + typeof t !== 'number' || + !Number.isFinite(t) || + t <= 0 || + !Number.isFinite(monotonicTimeMs) || + typeof performance === 'undefined' || + !Number.isFinite(performance.timeOrigin) + ) { return undefined; } - const clock = { localTimeMs, serverTimeMs: t }; + const clock = { localTimeMs, serverTimeMs: t, monotonicTimeMs, timeOrigin: performance.timeOrigin }; tokenClocks.set(inline, clock); return clock; } @@ -758,6 +816,7 @@ export class ProtectSession { } const startedAt = Date.now(); + const monotonicStartedAt = typeof performance === 'undefined' ? undefined : performance.now(); let element: HTMLElement | undefined; try { element = this.#applyLoader(loader, this.#placeholders); @@ -774,11 +833,13 @@ export class ProtectSession { // we ever looked. An inline module is the exception: it evaluates off a fetched graph, so its // `load` does arrive and settling early would read the global before it is written. if (!isFetching(element)) { - readTokenClock(this.#cid as string, startedAt); + readTokenClock(this.#cid as string, startedAt, monotonicStartedAt); return Promise.resolve('loaded'); } - element.addEventListener('load', () => readTokenClock(this.#cid as string, startedAt), { once: true }); + element.addEventListener('load', () => readTokenClock(this.#cid as string, startedAt, monotonicStartedAt), { + once: true, + }); return new Promise(resolve => { const timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - Date.now())); From 2929c76bc0ba1cdd769b2ca59b0b39fcf766f702 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 23 Oct 2026 16:16:34 -0400 Subject: [PATCH 4/5] fix(js): preserve live token sharing and acquisition floors --- packages/clerk-js/bundlewatch.config.json | 6 +- .../src/core/__tests__/protectSession.test.ts | 128 ++++++++++++++- packages/clerk-js/src/core/protect.ts | 7 +- packages/clerk-js/src/core/protectSession.ts | 154 ++++++++++++++---- 4 files changed, 254 insertions(+), 41 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 17e43e1d98b..9130c91d49b 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,8 +1,8 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "556KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "83KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" }, + { "path": "./dist/clerk.js", "maxSize": "557KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "84KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "127KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "323.5KB" }, { "path": "./dist/clerk.native.js", "maxSize": "83KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index 6f370c5c702..99af78299b1 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -798,10 +798,14 @@ describe('ProtectSession token clock', () => { await vi.advanceTimersByTimeAsync(30_000); const retry = first.session?.getRequestParams(); await vi.advanceTimersByTimeAsync(0); - serveInline(first.elements[1], { cid: first.session?.placeholders().cid, p: { t: serverNow + 30_000 } }); - await expect(retry).resolves.not.toHaveProperty('__clerk_protect_token'); + serveInline(first.elements[1], { + cid: first.session?.placeholders().cid, + p: { t: serverNow + 30_000 }, + ready: Promise.resolve({ token: 'v1.refreshed.mac', exp: serverNow / 1_000 + 30 + lifetimeSeconds }), + }); + await expect(retry).resolves.toMatchObject({ __clerk_protect_token: 'v1.refreshed.mac' }); expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string)).toMatchObject({ - token: 'v1.reloaded.mac', + token: 'v1.refreshed.mac', floor: nextFloor, }); } @@ -832,6 +836,124 @@ describe('ProtectSession token clock', () => { await expect(refreshed).resolves.toMatchObject({ __clerk_protect_token: 'v1.refreshed.mac' }); }); + it('shares a live token across page clocks without renewing its lifetime', async () => { + vi.setSystemTime(serverNow + hour); + const originalPerformance = performance; + const first = session([loader({ token_timeout_ms: 10_000 })]); + first.session?.start(); + await vi.advanceTimersByTimeAsync(5_000); + + vi.stubGlobal('performance', { + timeOrigin: originalPerformance.timeOrigin + 5_000, + now: () => originalPerformance.now() - 5_000, + }); + vi.resetModules(); + const { ProtectSession: OtherSession, __internal_resetProtectStorage: resetOtherStorage } = + await import('../protectSession'); + const addListener = vi.spyOn(window, 'addEventListener'); + const h = harness(); + const other = OtherSession.create([loader()], h.applyLoader); + const receive = addListener.mock.calls.findLast(([type]) => type === 'storage')?.[1] as EventListener; + + vi.stubGlobal('performance', originalPerformance); + serveInline(first.elements[0], { + cid: first.session?.placeholders().cid, + p: { t: serverNow }, + ready: Promise.resolve({ token: 'v1.shared.mac', exp: serverNow / 1_000 + 70 }), + }); + await expect(first.session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.shared.mac' }); + const raw = localStorage.getItem('__clerk_protect_st') as string; + + vi.stubGlobal('performance', { + timeOrigin: originalPerformance.timeOrigin + 5_000, + now: () => originalPerformance.now() - 5_000, + }); + const update = new StorageEvent('storage', { key: '__clerk_protect_st', newValue: raw, storageArea: localStorage }); + expect(other?.hasFreshToken()).toBe(false); + receive(update); + expect(other?.hasFreshToken()).toBe(true); + await expect(other?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.shared.mac' }); + expect(h.elements).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(20_000); + vi.setSystemTime(Date.now() - 10_000); + await vi.advanceTimersByTimeAsync(40_001); + receive(update); + const nearExpiry = other?.getRequestParams(); + await vi.advanceTimersByTimeAsync(1_000); + await expect(nearExpiry).resolves.not.toHaveProperty('__clerk_protect_token'); + await vi.advanceTimersByTimeAsync(5_000); + receive(update); + await expect(other?.getRequestParams()).resolves.not.toHaveProperty('__clerk_protect_token'); + resetOtherStorage(); + }); + + it.each([ + { clocked: true, upgrade: false }, + { clocked: false, upgrade: false }, + { clocked: true, upgrade: true }, + { clocked: false, upgrade: true }, + ])( + 'keeps a newer floor while pending, with loader clock $clocked and endpoint $upgrade', + async ({ clocked, upgrade }) => { + let release: (value: unknown) => void = () => undefined; + const ready = new Promise(resolve => { + release = resolve; + }); + if (upgrade) { + global.fetch = vi.fn(() => Promise.resolve({ status: 200, json: () => ready })) as unknown as typeof fetch; + } + const { session: created, elements } = session([ + loader({ token_url: upgrade ? 'https://loader.example.com/{cid}/token' : undefined }), + ]); + created?.start(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[0], { + cid: created?.placeholders().cid, + p: clocked ? { t: serverNow } : undefined, + ready, + }); + await vi.advanceTimersByTimeAsync(0); + localStorage.setItem( + '__clerk_protect_st', + storedEntry({ + token: 'v1.other.mac', + floor: 1_000, + lifetime: { + exp: nowSeconds() + lifetimeSeconds, + expiresAt: performance.now() + lifetimeSeconds * 1_000, + timeOrigin: performance.timeOrigin + 1, + }, + }), + ); + release({ token: 'v1.pending.mac', exp: serverNow / 1_000 + lifetimeSeconds }); + await expect(created?.getRequestParams()).resolves.not.toHaveProperty('__clerk_protect_token'); + expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string)).toMatchObject({ + token: 'v1.other.mac', + floor: 1_000, + }); + + await vi.advanceTimersByTimeAsync(30_000); + if (upgrade) { + (global.fetch as Mock).mockImplementation(() => + Promise.resolve(tokenResponse('v1.refreshed.mac', serverNow / 1_000 + 30 + lifetimeSeconds)), + ); + } + const retry = created?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + serveInline(elements[1], { + cid: created?.placeholders().cid, + p: clocked ? { t: serverNow + 30_000 } : undefined, + ready: Promise.resolve({ token: 'v1.refreshed.mac', exp: serverNow / 1_000 + 30 + lifetimeSeconds }), + }); + await expect(retry).resolves.toMatchObject({ __clerk_protect_token: 'v1.refreshed.mac' }); + expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string)).toMatchObject({ + token: 'v1.refreshed.mac', + floor: 1_000, + }); + }, + ); + it.each([null, {}, { exp: 1 }, { exp: nowSeconds() + lifetimeSeconds, expiresAt: 1, timeOrigin: 1 }])( 'rejects malformed or foreign monotonic lifetimes: %j', lifetime => { diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index 446f0b5e7a7..98de5977f6c 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -71,8 +71,8 @@ export class Protect { this.#session = ProtectSession.create(loaders, applyLoader, tokensInvalidBefore); for (const loader of loaders) { - // The session injects this one itself, under the acquisition lock, so exactly one tab per - // browser session runs it. Every other loader runs on every page load regardless. + // The session coordinates this loader and shares live results between open pages. + // Every other loader runs on every page load regardless. if (this.#session?.isTokenLoader(loader)) { continue; } @@ -83,8 +83,7 @@ export class Protect { } } - // Acquisition is prefetched here rather than at sign-in, so a sign-in normally finds the token - // long since cached and acquisition stays off the latency-critical path. + // Prefetch acquisition here so its deadline starts before a sign-in needs the result. this.#session?.start(); } diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index 290697da027..d04bcd87f0a 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -8,8 +8,8 @@ import { SafeLock } from './auth/safeLock'; * The browser mints an opaque correlation id (`cid`) and hands it to the loader through the * element's attributes, which are per-instance server config. The server mints a signed session * token while serving that loader and returns it inline on the script's own global, so the gate - * costs no extra round trip. Translated tokens keep a per-page monotonic lifetime; other pages - * refresh them rather than trusting a clock reference they cannot verify. + * costs no extra round trip. Translated tokens keep a monotonic lifetime; new pages refresh + * persisted clock references, while live storage updates can be shared between open pages. * * An instance that configures `tokenUrl` gets the upgrade mint instead: a `GET` for a token that * attests to work happening after the script runs. Only that path can report `fetch_error` or @@ -124,14 +124,17 @@ type StoredToken = { * write rather than carried from here, so the two states are different types and a producer * cannot forget either. */ -type MintedToken = Omit; +type MintedToken = Omit & { acquiredFloor?: number }; type TokenClock = { localTimeMs: number; serverTimeMs: number; monotonicTimeMs: number; timeOrigin: number }; type TokenLifetime = { exp: number; expiresAt: number; timeOrigin: number }; const tokenClocks = new WeakMap(); +const tokenFloors = new WeakMap(); const tokenLifetimes = new Map(); +let highestObservedFloor = 0; +let pagePerformance: Performance | undefined; type LoaderOutcome = 'loaded' | 'error' | 'timeout'; @@ -195,6 +198,11 @@ const memoryStore = new Map(); export function __internal_resetProtectStorage(): void { memoryStore.clear(); tokenLifetimes.clear(); + highestObservedFloor = 0; + pagePerformance = undefined; + if (typeof window !== 'undefined') { + window.removeEventListener('storage', receiveStoredToken); + } } function localStorageOrNull(): Storage | null { @@ -254,6 +262,7 @@ function readOrMintPid(): string | null { * `tokens_invalid_before`; an entry acquired under an older one is stranded. */ function readStoredToken(key: string, marginMs: number, floor: number): StoredToken | null { + floor = Math.max(floor, readStoredFloor(key)); for (const raw of new Set([readStored(key), memoryStore.get(key)])) { if (!raw) { continue; @@ -278,7 +287,11 @@ function readStoredToken(key: string, marginMs: number, floor: number): StoredTo } const validated = validateToken(token, exp, marginMs, undefined, lifetime); if (validated) { - return { ...validated, rid, at, floor: normalizeFloor(storedFloor) }; + const stored = { ...validated, rid, at, floor: normalizeFloor(storedFloor) }; + if (validated.lifetime) { + memoryStore.set(key, JSON.stringify(stored)); + } + return stored; } } return null; @@ -289,6 +302,18 @@ function normalizeFloor(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; } +function readStoredFloor(key: string): number { + for (const raw of new Set([readStored(key), memoryStore.get(key)])) { + try { + const entry = JSON.parse(raw ?? 'null') as StoredToken | null; + highestObservedFloor = Math.max(highestObservedFloor, normalizeFloor(entry?.floor)); + } catch { + continue; + } + } + return highestObservedFloor; +} + /** * Legacy entries use wall-clock age as an additional bound, rejecting future writes and entries * older than the maximum token lifetime. Translated entries use their monotonic lifetime instead. @@ -326,6 +351,16 @@ function validateToken( const now = Date.now(); let expMs = exp * 1_000; let lifetime: unknown = storedLifetime === undefined ? tokenLifetimes.get(token) : storedLifetime; + if (storedLifetime && typeof storedLifetime === 'object' && typeof pagePerformance !== 'undefined') { + const known = tokenLifetimes.get(token); + if ( + (storedLifetime as TokenLifetime).timeOrigin !== pagePerformance.timeOrigin && + known?.exp === exp && + known.timeOrigin === pagePerformance.timeOrigin + ) { + lifetime = known; + } + } if (clock) { const lifetimeMs = expMs - clock.serverTimeMs; if (lifetimeMs <= 0 || lifetimeMs > MAX_TOKEN_LIFETIME_MS) { @@ -350,16 +385,16 @@ function validateToken( lifetime = undefined; } else { if ( - typeof performance === 'undefined' || + typeof pagePerformance === 'undefined' || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || typeof timeOrigin !== 'number' || !Number.isFinite(timeOrigin) || - timeOrigin !== performance.timeOrigin + timeOrigin !== pagePerformance.timeOrigin ) { return null; } - const remaining = Math.min(expiresAt - performance.now(), expMs - now); + const remaining = Math.min(expiresAt - pagePerformance.now(), expMs - now); if (!Number.isFinite(remaining) || remaining <= marginMs || remaining > MAX_TOKEN_LIFETIME_MS) { return null; } @@ -376,23 +411,56 @@ function validateToken( /** `at` and `floor` are the writer's business, not the caller's — both stamped here, at the write. */ function writeStoredToken(key: string, value: MintedToken, floor: number): void { + floor = Math.max(floor, normalizeFloor(value.acquiredFloor)); const validated = validateToken(value.token, value.exp, 0, undefined, value.lifetime); if (!validated) { return; } const raw = JSON.stringify({ ...value, ...validated, at: Date.now(), floor }); - let existingFloor = 0; + if (readStoredFloor(key) > floor) { + return; + } + writeStored(key, raw); + memoryStore.set(key, raw); + highestObservedFloor = Math.max(highestObservedFloor, floor); +} + +function receiveStoredToken(event: StorageEvent): void { + if ( + event.key !== TOKEN_STORAGE_KEY || + !event.newValue || + event.storageArea !== localStorageOrNull() || + typeof pagePerformance === 'undefined' + ) { + return; + } + let entry: Record; try { - const existing = JSON.parse(readStored(key) ?? 'null') as StoredToken | null; - existingFloor = normalizeFloor(existing?.floor); + entry = JSON.parse(event.newValue); } catch { - existingFloor = 0; + return; } - if (existingFloor > floor) { + if (!entry || typeof entry !== 'object') { return; } - writeStored(key, raw); - memoryStore.set(key, raw); + highestObservedFloor = Math.max(highestObservedFloor, normalizeFloor(entry.floor)); + if (!entry.lifetime || typeof entry.lifetime !== 'object') { + return; + } + const { expiresAt, timeOrigin } = entry.lifetime as Record; + if ( + typeof expiresAt !== 'number' || + !Number.isFinite(expiresAt) || + typeof timeOrigin !== 'number' || + !Number.isFinite(timeOrigin) + ) { + return; + } + validateToken(entry.token, entry.exp, 0, undefined, { + exp: entry.exp, + expiresAt: timeOrigin + expiresAt - pagePerformance.timeOrigin, + timeOrigin: pagePerformance.timeOrigin, + }); } /** Sentinel for "the deadline won", distinguishable from anything `ready` could resolve to. */ @@ -436,6 +504,7 @@ async function readInlineToken(cid: string, rid: string, deadline: number): Prom const { cid: mintedFor, ready } = inline as Record; const clock = tokenClocks.get(inline); + const acquiredFloor = tokenFloors.get(inline); // The global is page-visible and outlives a navigation, so a token minted for anyone else's run // is not ours to send. The base shape, which carries no cid at all, lands here too. if (mintedFor !== cid || !isThenable(ready)) { @@ -453,10 +522,15 @@ async function readInlineToken(cid: string, rid: string, deadline: number): Prom const { token, exp } = resolved as Record; const validated = validateToken(token, exp, 0, clock); // `{ status: 'no_token' }` has no token to validate and lands here, as does a malformed one. - return validated ? { ...validated, rid } : 'no_token'; + return validated ? { ...validated, rid, acquiredFloor } : 'no_token'; } -function readTokenClock(cid: string, localTimeMs?: number, monotonicTimeMs?: number): TokenClock | undefined { +function readTokenClock( + cid: string, + localTimeMs?: number, + monotonicTimeMs?: number, + floor?: number, +): TokenClock | undefined { const inline = (globalThis as unknown as Record)[INLINE_TOKEN_GLOBAL]; if (!inline || typeof inline !== 'object') { return undefined; @@ -465,6 +539,9 @@ function readTokenClock(cid: string, localTimeMs?: number, monotonicTimeMs?: num if (mintedFor !== cid) { return undefined; } + if (localTimeMs !== undefined && floor !== undefined && !tokenFloors.has(inline)) { + tokenFloors.set(inline, floor); + } const existing = tokenClocks.get(inline); if (existing || localTimeMs === undefined || monotonicTimeMs === undefined) { return existing; @@ -478,12 +555,12 @@ function readTokenClock(cid: string, localTimeMs?: number, monotonicTimeMs?: num !Number.isFinite(t) || t <= 0 || !Number.isFinite(monotonicTimeMs) || - typeof performance === 'undefined' || - !Number.isFinite(performance.timeOrigin) + typeof pagePerformance === 'undefined' || + !Number.isFinite(pagePerformance.timeOrigin) ) { return undefined; } - const clock = { localTimeMs, serverTimeMs: t, monotonicTimeMs, timeOrigin: performance.timeOrigin }; + const clock = { localTimeMs, serverTimeMs: t, monotonicTimeMs, timeOrigin: pagePerformance.timeOrigin }; tokenClocks.set(inline, clock); return clock; } @@ -612,6 +689,7 @@ export class ProtectSession { } private constructor(templatedLoaders: ProtectLoader[], applyLoader: ApplyLoader, tokensInvalidBefore?: number) { + pagePerformance ??= typeof performance === 'undefined' ? undefined : performance; this.#applyLoader = applyLoader; this.#floor = normalizeFloor(tokensInvalidBefore); @@ -638,6 +716,9 @@ export class ProtectSession { this.#tokenStorageKey = TOKEN_STORAGE_KEY; this.#lock = SafeLock(ACQUISITION_LOCK_KEY); + if (typeof window !== 'undefined') { + window.addEventListener('storage', receiveStoredToken); + } } placeholders(): ProtectPlaceholders { @@ -691,8 +772,8 @@ export class ProtectSession { } /** - * True when a token from this browser session is already shared with us. Only the token loader - * is skipped on the strength of it — every other loader still runs on every page load. + * True when a cached token has a usable timing reference in this page. Only its token loader + * is skipped — every other loader still runs on every page load. */ hasFreshToken(): boolean { return this.#tokenLoader ? this.#freshStoredToken() !== null : false; @@ -783,6 +864,7 @@ export class ProtectSession { /** Runs the loader and takes the token it was served with. Never rejects. */ async #mint(): Promise { const deadline = Date.now() + this.#timeoutMs; + const acquiredFloor = Math.max(this.#floor, readStoredFloor(this.#tokenStorageKey)); const outcome = await this.#runTokenLoader(deadline); if (outcome === 'error') { @@ -793,7 +875,7 @@ export class ProtectSession { } if (this.#tokenUrl) { - return await this.#poll(deadline, readTokenClock(this.#cid as string)); + return await this.#poll(deadline, readTokenClock(this.#cid as string), acquiredFloor); } const inline = await readInlineToken(this.#cid as string, this.#rid as string, deadline); @@ -816,7 +898,8 @@ export class ProtectSession { } const startedAt = Date.now(); - const monotonicStartedAt = typeof performance === 'undefined' ? undefined : performance.now(); + const monotonicStartedAt = typeof pagePerformance === 'undefined' ? undefined : pagePerformance.now(); + const acquiredFloor = Math.max(this.#floor, readStoredFloor(this.#tokenStorageKey)); let element: HTMLElement | undefined; try { element = this.#applyLoader(loader, this.#placeholders); @@ -833,13 +916,17 @@ export class ProtectSession { // we ever looked. An inline module is the exception: it evaluates off a fetched graph, so its // `load` does arrive and settling early would read the global before it is written. if (!isFetching(element)) { - readTokenClock(this.#cid as string, startedAt, monotonicStartedAt); + readTokenClock(this.#cid as string, startedAt, monotonicStartedAt, acquiredFloor); return Promise.resolve('loaded'); } - element.addEventListener('load', () => readTokenClock(this.#cid as string, startedAt, monotonicStartedAt), { - once: true, - }); + element.addEventListener( + 'load', + () => readTokenClock(this.#cid as string, startedAt, monotonicStartedAt, acquiredFloor), + { + once: true, + }, + ); return new Promise(resolve => { const timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - Date.now())); @@ -853,7 +940,7 @@ export class ProtectSession { } /** The upgrade mint: polls the token endpoint until the deadline. Never rejects. */ - async #poll(deadline: number, clock?: TokenClock): Promise { + async #poll(deadline: number, clock?: TokenClock, acquiredFloor?: number): Promise { const url = this.#tokenUrl as string; const controller = new AbortController(); const deadlineTimer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now())); @@ -873,7 +960,7 @@ export class ProtectSession { } if (response.status === 200) { - const token = await readTokenPayload(response, this.#rid as string, clock); + const token = await readTokenPayload(response, this.#rid as string, clock, acquiredFloor); if (!token) { return 'fetch_error'; } @@ -925,7 +1012,12 @@ function resolveTokenUrl(loader: ProtectLoader, placeholders: ProtectPlaceholder } } -async function readTokenPayload(response: Response, rid: string, clock?: TokenClock): Promise { +async function readTokenPayload( + response: Response, + rid: string, + clock?: TokenClock, + acquiredFloor?: number, +): Promise { let json: unknown; try { json = await response.json(); @@ -939,7 +1031,7 @@ async function readTokenPayload(response: Response, rid: string, clock?: TokenCl const { token, exp } = json as Record; const validated = validateToken(token, exp, 0, clock); - return validated ? { ...validated, rid } : null; + return validated ? { ...validated, rid, acquiredFloor } : null; } async function readRetryDelay(response: Response): Promise { From 9985a743e06367a71d4489d7628473d798ca4915 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 23 Oct 2026 16:33:42 -0400 Subject: [PATCH 5/5] fix(js): guard storage listeners on non-browser runtimes --- .../clerk-js/src/core/__tests__/protectSession.test.ts | 10 ++++++++++ packages/clerk-js/src/core/protectSession.ts | 4 ++-- 2 files changed, 12 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 99af78299b1..68741d72566 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -166,6 +166,16 @@ describe('interpolatePlaceholders', () => { }); describe('ProtectSession.create', () => { + it('creates and resets a session when window has no event listener methods', () => { + vi.stubGlobal('window', {}); + try { + expect(() => session([loader()])).not.toThrow(); + expect(() => __internal_resetProtectStorage()).not.toThrow(); + } finally { + vi.unstubAllGlobals(); + } + }); + it('returns nothing when no loader references a placeholder', () => { const { session: created } = session([loader({ attributes: { src: 'https://loader.example.com/loader.js' } })]); diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index d04bcd87f0a..f74800607d2 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -200,7 +200,7 @@ export function __internal_resetProtectStorage(): void { tokenLifetimes.clear(); highestObservedFloor = 0; pagePerformance = undefined; - if (typeof window !== 'undefined') { + if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') { window.removeEventListener('storage', receiveStoredToken); } } @@ -716,7 +716,7 @@ export class ProtectSession { this.#tokenStorageKey = TOKEN_STORAGE_KEY; this.#lock = SafeLock(ACQUISITION_LOCK_KEY); - if (typeof window !== 'undefined') { + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { window.addEventListener('storage', receiveStoredToken); } }