From de9b78e58308882c7b862606b24f8f593c2d48a1 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Wed, 29 Jul 2026 17:14:17 -0800 Subject: [PATCH] feat(js): attach an optional server-configured session token to sign-in Mints an opaque, random correlation id and acquires a signed Protect session token once per browser session, shared across tabs under a lock. The token, the correlation id and an acquisition status travel in the form-encoded body of sign-in and sign-up POSTs. Acquisition never blocks a sign-in: on timeout, script-load failure or a non-2xx response a structured status travels in the token's place and the request proceeds. Instances whose loader config does not reference the new placeholders keep today's behaviour and store nothing in the browser. --- .changeset/lucky-pandas-observe.md | 12 + packages/clerk-js/bundlewatch.config.json | 8 +- .../src/core/__tests__/fapiClient.test.ts | 78 +++ .../src/core/__tests__/protect.test.ts | 178 ++++++ .../src/core/__tests__/protectSession.test.ts | 375 +++++++++++++ packages/clerk-js/src/core/clerk.ts | 3 + packages/clerk-js/src/core/fapiClient.ts | 40 +- packages/clerk-js/src/core/protect.ts | 87 ++- packages/clerk-js/src/core/protectSession.ts | 529 ++++++++++++++++++ packages/shared/src/types/protectConfig.ts | 15 + 10 files changed, 1295 insertions(+), 30 deletions(-) create mode 100644 .changeset/lucky-pandas-observe.md create mode 100644 packages/clerk-js/src/core/__tests__/protect.test.ts create mode 100644 packages/clerk-js/src/core/__tests__/protectSession.test.ts create mode 100644 packages/clerk-js/src/core/protectSession.ts diff --git a/.changeset/lucky-pandas-observe.md b/.changeset/lucky-pandas-observe.md new file mode 100644 index 00000000000..9730af9663a --- /dev/null +++ b/.changeset/lucky-pandas-observe.md @@ -0,0 +1,12 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Acquire an optional Protect session token and attach it to sign-in and sign-up requests. + +On instances whose loader config references the new `{cid}` / `{pid}` / `{rid}` / `{instance_id}` placeholders, Clerk mints an opaque, random correlation id and acquires a signed session token once per browser session — shared across tabs under a lock rather than acquired once per tab. The token, along with the correlation id and an acquisition status, travels in the form-encoded body of sign-in and sign-up requests. + +Acquisition never blocks a sign-in: if the token cannot be obtained, a status code (`timeout`, `script_error`, `fetch_error`, `http_`, `unsupported`) travels in its place and the request proceeds. Instances that do not use these placeholders are unaffected, and nothing is stored in the browser for them. + +`ProtectLoader` gains two optional fields, `tokenUrl` and `tokenTimeoutMs`. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 95614c3de45..b63f09a9207 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,10 +1,10 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "549KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "75KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "74KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "77KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "120KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "318KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "77KB" }, { "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__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index 5de3432bdd5..d48bf92d231 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -384,6 +384,84 @@ describe('request', () => { }); }); + describe('protect params', () => { + const protectParams = { + __clerk_protect_token: 'v1.payload.mac', + __clerk_protect_status: 'ok', + __clerk_protect_cid: `1-${'a'.repeat(26)}-${'b'.repeat(26)}`, + }; + + let getProtectParams: Mock; + let clientWithProtect: ReturnType; + + beforeEach(() => { + getProtectParams = vi.fn().mockResolvedValue(protectParams); + clientWithProtect = createFapiClient({ ...baseFapiClientOptions, getProtectParams }); + }); + + const bodyOf = () => (fetch as Mock).mock.calls[0][1].body as string; + + it.each(['/client/sign_ins', '/client/sign_ups', '/client/sign_ins/sia_123/attempt_first_factor'])( + 'merges them into the form-encoded body of %s', + async path => { + await clientWithProtect.request({ path, method: 'POST', body: { identifier: 'nick@clerk.dev' } as any }); + + expect(bodyOf()).toBe( + `identifier=nick%40clerk.dev&__clerk_protect_token=v1.payload.mac&__clerk_protect_status=ok&__clerk_protect_cid=${protectParams.__clerk_protect_cid}`, + ); + // A signed credential must never land in the URL, which is logged all along the path. + expect((fetch as Mock).mock.calls[0][0].toString()).not.toContain('__clerk_protect'); + }, + ); + + it('adds no request headers', async () => { + await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: {} as any }); + + const headers = (fetch as Mock).mock.calls[0][1].headers as Headers; + expect([...headers.keys()]).toEqual(['content-type']); + }); + + it('populates the body even when the request had none', async () => { + await clientWithProtect.request({ path: '/client/sign_ups', method: 'POST' }); + + expect(bodyOf()).toContain('__clerk_protect_status=ok'); + }); + + it.each(['/client', '/client/sessions', '/environment', '/client/sign_insomething'])( + 'leaves %s alone', + async path => { + await clientWithProtect.request({ path, method: 'POST', body: { foo: 'bar' } as any }); + + expect(bodyOf()).toBe('foo=bar'); + expect(getProtectParams).not.toHaveBeenCalled(); + }, + ); + + it('leaves GET requests alone', async () => { + await clientWithProtect.request({ path: '/client/sign_ins', method: 'GET' }); + + expect(getProtectParams).not.toHaveBeenCalled(); + }); + + it('leaves a FormData body alone', async () => { + const formData = new FormData(); + formData.append('identifier', 'nick@clerk.dev'); + + await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: formData }); + + expect((fetch as Mock).mock.calls[0][1].body).toBe(formData); + expect(getProtectParams).not.toHaveBeenCalled(); + }); + + it('sends nothing extra when the instance does not participate', async () => { + getProtectParams.mockResolvedValue(undefined); + + await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: { foo: 'bar' } as any }); + + expect(bodyOf()).toBe('foo=bar'); + }); + }); + describe('retry logic', () => { it('does not send retry query parameter on initial request', async () => { await fapiClient.request({ diff --git a/packages/clerk-js/src/core/__tests__/protect.test.ts b/packages/clerk-js/src/core/__tests__/protect.test.ts new file mode 100644 index 00000000000..639ad1b8a33 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/protect.test.ts @@ -0,0 +1,178 @@ +import type { ProtectLoader } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Protect } from '../protect'; +import type { Environment } from '../resources'; + +const environment = (loaders: ProtectLoader[], id = ''): Environment => + ({ protectConfig: { id, loaders } }) as unknown as Environment; + +const nowSeconds = () => Math.floor(Date.now() / 1_000); + +const originalFetch = global.fetch; + +beforeEach(() => { + localStorage.clear(); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + global.fetch = vi.fn(() => + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ token: 'v1.payload.mac', exp: nowSeconds() + 43_200 }), + }), + ) as unknown as typeof fetch; +}); + +afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + localStorage.clear(); +}); + +describe('Protect.load', () => { + it('does nothing without a protect config', () => { + new Protect().load(environment([])); + expect(document.head.querySelector('script')).toBeNull(); + expect(localStorage.getItem('__clerk_protect_pid')).toBeNull(); + }); + + it('applies an untemplated loader unchanged and reports no params', async () => { + const protect = new Protect(); + protect.load( + environment([ + { target: 'head', type: 'script', attributes: { src: 'https://loader.example.com/ins_2abc/loader.js' } }, + ]), + ); + + expect(document.head.querySelector('script')?.getAttribute('src')).toBe( + 'https://loader.example.com/ins_2abc/loader.js', + ); + await expect(protect.getRequestParams()).resolves.toBeUndefined(); + expect(localStorage.getItem('__clerk_protect_pid')).toBeNull(); + }); + + it('substitutes the placeholders it recognises and leaves the rest verbatim', () => { + const protect = new Protect(); + protect.load( + environment( + [ + { + target: 'head', + type: 'script', + attributes: { + src: 'https://loader.example.com/{instance_id}/{cid}/loader.js', + 'data-pid': '{pid}', + 'data-rid': '{rid}', + 'data-unknown': '{whatever}', + 'data-count': 3, + }, + }, + ], + 'ins_2abc', + ), + ); + + const element = document.head.querySelector('script') as HTMLScriptElement; + const pid = element.getAttribute('data-pid') as string; + const rid = element.getAttribute('data-rid') as string; + + expect(pid).toMatch(/^[a-z2-7]{26}$/); + expect(rid).toMatch(/^[a-z2-7]{26}$/); + expect(element.getAttribute('src')).toBe(`https://loader.example.com/ins_2abc/1-${pid}-${rid}/loader.js`); + expect(element.getAttribute('data-unknown')).toBe('{whatever}'); + expect(element.getAttribute('data-count')).toBe('3'); + }); + + it('leaves {instance_id} verbatim when the environment does not carry one', () => { + const protect = new Protect(); + protect.load( + environment([ + { target: 'head', type: 'script', attributes: { src: 'https://loader.example.com/{instance_id}/{cid}.js' } }, + ]), + ); + + expect(document.head.querySelector('script')?.getAttribute('src')).toContain('{instance_id}'); + }); + + it('attaches the token once it has been acquired', async () => { + const protect = new Protect(); + protect.load( + environment([ + { + target: 'head', + type: 'script', + attributes: { src: 'https://loader.example.com/ins_2abc/{cid}/loader.js' }, + tokenTimeoutMs: 500, + }, + ]), + ); + + await expect(protect.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.payload.mac', + __clerk_protect_status: 'ok', + }); + }); + + it('skips the loaders entirely when a token for this browser session is already shared', async () => { + localStorage.setItem( + '__clerk_protect_st', + JSON.stringify({ token: 'v1.cached.mac', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26) }), + ); + + const protect = new Protect(); + protect.load( + environment([ + { + target: 'head', + type: 'script', + attributes: { src: 'https://loader.example.com/ins_2abc/{cid}/loader.js' }, + }, + ]), + ); + + // A token is already cached, so acquisition happens once per session, not once per tab. + expect(document.head.querySelector('script')).toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + await expect(protect.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.cached.mac' }); + }); + + it('does not apply a loader that is outside its rollout', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.9); + + const protect = new Protect(); + protect.load( + environment([ + { + rollout: 0.1, + target: 'head', + type: 'script', + attributes: { src: 'https://loader.example.com/ins_2abc/{cid}/loader.js' }, + }, + ]), + ); + + expect(document.head.querySelector('script')).toBeNull(); + // Out of rollout means Protect is off for this browser, so there is nothing to report either. + await expect(protect.getRequestParams()).resolves.toBeUndefined(); + }); + + it('reports script_error when the loader element fails to load', async () => { + (global.fetch as any).mockImplementation(() => Promise.resolve({ status: 202, json: () => Promise.resolve({}) })); + + const protect = new Protect(); + protect.load( + environment([ + { + target: 'head', + type: 'script', + attributes: { src: 'https://loader.example.com/ins_2abc/{cid}/loader.js' }, + tokenTimeoutMs: 5_000, + }, + ]), + ); + + document.head.querySelector('script')?.dispatchEvent(new Event('error')); + + await expect(protect.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'script_error' }); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts new file mode 100644 index 00000000000..429c5fb27e2 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -0,0 +1,375 @@ +import type { ProtectLoader } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; + +import { buildCid, CID_REGEX, encodeBase32, interpolatePlaceholders, ProtectSession } from '../protectSession'; + +const TOKEN_URL = 'https://loader.example.com/ins_2abc/{cid}/loader.js'; + +const loader = (overrides: Partial = {}): ProtectLoader => ({ + target: 'head', + type: 'script', + attributes: { src: TOKEN_URL }, + tokenTimeoutMs: 200, + ...overrides, +}); + +const nowSeconds = () => Math.floor(Date.now() / 1_000); + +const tokenResponse = (token = 'v1.payload.mac', expInSeconds = nowSeconds() + 43_200) => ({ + status: 200, + json: () => Promise.resolve({ token, exp: expInSeconds }), +}); + +const retryResponse = (retryInMs = 10) => ({ + status: 202, + json: () => Promise.resolve({ retry_in_ms: retryInMs }), +}); + +const errorResponse = (status: number) => ({ + status, + json: () => Promise.resolve({ status: 'unknown_cid' }), +}); + +const originalFetch = global.fetch; + +beforeEach(() => { + localStorage.clear(); + global.fetch = vi.fn(() => Promise.resolve(tokenResponse())) as unknown as typeof fetch; +}); + +afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + localStorage.clear(); +}); + +describe('encodeBase32', () => { + it('emits 26 lowercase unpadded base32 chars for 128 bits', () => { + expect(encodeBase32(new Uint8Array(16))).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaa'); + expect(encodeBase32(new Uint8Array(16).fill(0xff))).toBe('77777777777777777777777774'); + }); + + it('matches the RFC 4648 alphabet', () => { + // The canonical base32 of 0x00..0x0f is AAAQEAYEAUDAOCAJBIFQYDIOB4====== + expect(encodeBase32(Uint8Array.from({ length: 16 }, (_, i) => i))).toBe('aaaqeayeaudaocajbifqydiob4'); + }); +}); + +describe('interpolatePlaceholders', () => { + it('substitutes the closed set', () => { + expect( + interpolatePlaceholders('{instance_id}/{cid}/{pid}/{rid}', { + cid: 'c', + pid: 'p', + rid: 'r', + instance_id: 'ins_2abc', + }), + ).toBe('ins_2abc/c/p/r'); + }); + + it('leaves an unrecognised placeholder verbatim', () => { + expect(interpolatePlaceholders('{cid}/{nope}/{PID}', { cid: 'c' })).toBe('c/{nope}/{PID}'); + }); + + it('leaves a recognised placeholder verbatim when there is no value for it', () => { + expect(interpolatePlaceholders('{cid}/{instance_id}', { cid: 'c' })).toBe('c/{instance_id}'); + }); +}); + +describe('ProtectSession.create', () => { + it('returns nothing when no loader references a placeholder', () => { + const session = ProtectSession.create([ + loader({ attributes: { src: 'https://loader.example.com/ins_2abc/loader.js' } }), + ]); + + expect(session).toBeUndefined(); + // An instance not using the correlation id stores nothing in the user's browser. + expect(localStorage.getItem('__clerk_protect_pid')).toBeNull(); + }); + + it('mints a 55-char correlation id and persists only the pid', () => { + const session = ProtectSession.create([loader()]); + const cid = session?.placeholders().cid as string; + + expect(cid).toMatch(CID_REGEX); + expect(cid).toHaveLength(55); + expect(localStorage.getItem('__clerk_protect_pid')).toBe(session?.placeholders().pid); + }); + + it('reuses the persisted pid and mints a fresh rid per run', () => { + const first = ProtectSession.create([loader()])?.placeholders(); + const second = ProtectSession.create([loader()])?.placeholders(); + + expect(second?.pid).toBe(first?.pid); + expect(second?.rid).not.toBe(first?.rid); + expect(buildCid(second?.pid as string, second?.rid as string)).toBe(second?.cid); + }); + + it('substitutes the instance id when one is available', () => { + const session = ProtectSession.create([loader({ tokenUrl: '{instance_id}/token' })], 'ins_2abc'); + expect(session?.placeholders().instance_id).toBe('ins_2abc'); + }); + + it('falls back to an in-memory pid when localStorage throws', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage is blocked'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage is blocked'); + }); + + const first = ProtectSession.create([loader()]); + const second = ProtectSession.create([loader()]); + + expect(first?.placeholders().cid).toMatch(CID_REGEX); + // The in-memory fallback still shares the pid across sessions on this page. + expect(second?.placeholders().pid).toBe(first?.placeholders().pid); + + // …and acquisition still works end to end. + first?.start(); + await expect(first?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + }); + + it('reports unsupported when there is no CSPRNG', async () => { + const originalGetRandomValues = crypto.getRandomValues; + // @ts-expect-error -- deliberately removing the API to exercise the unsupported path + crypto.getRandomValues = undefined; + + try { + const session = ProtectSession.create([loader()]); + session?.start(); + + await expect(session?.getRequestParams()).resolves.toEqual({ __clerk_protect_status: 'unsupported' }); + expect(global.fetch).not.toHaveBeenCalled(); + // Nothing usable to interpolate, so the loader keeps its literal placeholder. + expect(session?.placeholders().cid).toBeUndefined(); + } finally { + crypto.getRandomValues = originalGetRandomValues; + } + }); +}); + +describe('ProtectSession token acquisition', () => { + it('derives the token endpoint from the loader URL carrying the cid', async () => { + const session = ProtectSession.create([loader()]); + const cid = session?.placeholders().cid; + session?.start(); + await session?.getRequestParams(); + + expect(global.fetch).toHaveBeenCalledWith( + `https://loader.example.com/ins_2abc/${cid}/token`, + expect.objectContaining({ credentials: 'omit' }), + ); + }); + + it('prefers an explicitly configured token endpoint', async () => { + const session = ProtectSession.create( + [loader({ tokenUrl: 'https://loader.example.com/{instance_id}/{cid}/token' })], + 'ins_2abc', + ); + const cid = session?.placeholders().cid; + session?.start(); + await session?.getRequestParams(); + + expect(global.fetch).toHaveBeenCalledWith(`https://loader.example.com/ins_2abc/${cid}/token`, expect.anything()); + }); + + it('reports ok and shares the token through localStorage', async () => { + const session = ProtectSession.create([loader()]); + session?.start(); + + await expect(session?.getRequestParams()).resolves.toEqual({ + __clerk_protect_token: 'v1.payload.mac', + __clerk_protect_status: 'ok', + __clerk_protect_cid: session?.placeholders().cid, + }); + expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string)).toMatchObject({ + token: 'v1.payload.mac', + rid: session?.placeholders().rid, + }); + }); + + it('polls through a 202 until the token is minted', async () => { + (global.fetch as Mock) + .mockImplementationOnce(() => Promise.resolve(retryResponse())) + .mockImplementationOnce(() => Promise.resolve(tokenResponse())); + + const session = ProtectSession.create([loader({ tokenTimeoutMs: 1_000 })]); + session?.start(); + + await expect(session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('reports timeout when the deadline expires with no token', async () => { + (global.fetch as Mock).mockImplementation(() => Promise.resolve(retryResponse())); + + const session = ProtectSession.create([loader({ tokenTimeoutMs: 60 })]); + session?.start(); + + await expect(session?.getRequestParams()).resolves.toEqual({ + __clerk_protect_status: 'timeout', + __clerk_protect_cid: session?.placeholders().cid, + }); + }); + + it('reports the http status when the endpoint answers non-2xx', async () => { + (global.fetch as Mock).mockImplementation(() => Promise.resolve(errorResponse(503))); + + const session = ProtectSession.create([loader()]); + session?.start(); + + await expect(session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'http_503' }); + }); + + it('reports fetch_error when the token endpoint is unreachable', async () => { + (global.fetch as Mock).mockImplementation(() => Promise.reject(new TypeError('Failed to fetch'))); + + const session = ProtectSession.create([loader()]); + session?.start(); + + await expect(session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'fetch_error' }); + }); + + it('reports fetch_error when a 200 carries no usable token', async () => { + (global.fetch as Mock).mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve({}) })); + + const session = ProtectSession.create([loader()]); + session?.start(); + + await expect(session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'fetch_error' }); + }); + + it('reports script_error when the loader element fails to load', async () => { + (global.fetch as Mock).mockImplementation(() => Promise.resolve(retryResponse())); + + const session = ProtectSession.create([loader({ tokenTimeoutMs: 5_000 })]); + const element = document.createElement('script'); + session?.observeLoaderElement(element); + session?.start(); + + element.dispatchEvent(new Event('error')); + + await expect(session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'script_error' }); + }); + + it('reuses a fresh token without a network call', async () => { + localStorage.setItem( + '__clerk_protect_st', + JSON.stringify({ token: 'v1.cached.mac', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26) }), + ); + + const session = ProtectSession.create([loader()]); + expect(session?.hasFreshToken()).toBe(true); + + session?.start(); + + await expect(session?.getRequestParams()).resolves.toEqual({ + __clerk_protect_token: 'v1.cached.mac', + __clerk_protect_status: 'ok', + // The cid names the run the token was minted for, which is not this tab's run. + __clerk_protect_cid: buildCid(session?.placeholders().pid as string, 'b'.repeat(26)), + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('ignores a stored token that has expired', async () => { + localStorage.setItem( + '__clerk_protect_st', + JSON.stringify({ token: 'v1.stale.mac', exp: nowSeconds() - 1, rid: 'b'.repeat(26) }), + ); + + const session = ProtectSession.create([loader()]); + expect(session?.hasFreshToken()).toBe(false); + + session?.start(); + await expect(session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' }); + }); + + it('reports nothing at all when the instance configures no token endpoint', async () => { + const session = ProtectSession.create([loader({ attributes: { 'data-pid': '{pid}' } })]); + + session?.start(); + await expect(session?.getRequestParams()).resolves.toBeUndefined(); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe('ProtectSession cross-tab single flight', () => { + /** + * A serialising Web Locks stand-in. jsdom has no `navigator.locks`, and the `browser-tabs-lock` + * fallback is globally mocked to always grant, so without this the double-check inside the lock + * would never be exercised. + */ + const installSerialisingLocks = () => { + let tail: Promise = Promise.resolve(); + const request = vi.fn((_key: string, _options: unknown, callback: () => Promise) => { + const result = tail.then(() => callback()); + tail = result.catch(() => undefined); + return result; + }); + Object.defineProperty(navigator, 'locks', { value: { request }, configurable: true }); + return request; + }; + + afterEach(() => { + delete (navigator as unknown as Record).locks; + }); + + it('acquires once when several tabs start together', async () => { + const request = installSerialisingLocks(); + + // The leader's run is still in flight while the other tab queues behind the lock, so the only + // thing that can stop a second run is the re-read inside the lock. + (global.fetch as Mock).mockImplementationOnce( + () => new Promise(resolve => setTimeout(() => resolve(tokenResponse()), 20)), + ); + + const sessions = [ProtectSession.create([loader()]), ProtectSession.create([loader()])]; + sessions.forEach(session => session?.start()); + + const results = await Promise.all(sessions.map(session => session?.getRequestParams())); + + expect(request).toHaveBeenCalledTimes(2); + // The second tab re-read the store inside the lock and reused the leader's token. + expect(global.fetch).toHaveBeenCalledTimes(1); + results.forEach(result => expect(result).toMatchObject({ __clerk_protect_status: 'ok' })); + }); + + it('uses whatever a leader wrote when the lock is never granted', async () => { + Object.defineProperty(navigator, 'locks', { + value: { + // Mirrors SafeLock's behaviour when its own AbortSignal fires: the callback never runs. + request: vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError'))), + }, + configurable: true, + }); + + const session = ProtectSession.create([loader()]); + session?.start(); + // Written after the pre-lock read has already missed, so this can only be picked up by the + // read that follows a failed lock acquisition. + localStorage.setItem( + '__clerk_protect_st', + JSON.stringify({ token: 'v1.leader.mac', exp: nowSeconds() + 43_200, rid: 'c'.repeat(26) }), + ); + + await expect(session?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.leader.mac', + __clerk_protect_status: 'ok', + }); + }); + + it('reports timeout when the lock is never granted and no leader succeeded', async () => { + Object.defineProperty(navigator, 'locks', { + value: { request: vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError'))) }, + configurable: true, + }); + + const session = ProtectSession.create([loader()]); + session?.start(); + + await expect(session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 9eaa2d6732c..8f985f83297 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -513,6 +513,9 @@ export class Clerk implements ClerkInterface { getSessionId: () => { return this.session?.id; }, + getProtectParams: () => { + return this.#protect?.getRequestParams() ?? Promise.resolve(undefined); + }, proxyUrl: this.proxyUrl, }); this.#publicEventBus.emit(clerkEvents.Status, 'loading'); diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index c0595d20852..1e8bc70fa25 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -65,15 +65,39 @@ export interface FapiClient { // List of paths that should not receive the session ID parameter in the URL const unauthorizedPathPrefixes = ['/client', '/waitlist']; +// The requests Protect gates. Params are attached to these and nothing else. +const protectPathPrefixes = ['/client/sign_ins', '/client/sign_ups']; + type FapiClientOptions = { frontendApi: string; domain?: string; proxyUrl?: string; instanceType: InstanceType; getSessionId: () => string | undefined; + /** + * Resolves the Protect params (`__clerk_protect_token` / `_status` / `_cid`) to merge into the + * body of a sign-in or sign-up POST, or `undefined` when this instance does not participate. + * Resolves to `undefined` before the environment has loaded, so early requests carry nothing. + */ + getProtectParams?: () => Promise | undefined>; isSatellite?: boolean; }; +function isProtectGatedRequest(method: string, path: string | undefined): boolean { + if (method === 'GET' || !path) { + return false; + } + return protectPathPrefixes.some(prefix => path === prefix || path.startsWith(`${prefix}/`)); +} + +/** Only a plain-object body (or none at all) can take extra params without changing its shape. */ +function isMergeableBody(body: unknown): body is Record | undefined { + if (body === undefined) { + return true; + } + return typeof body === 'object' && body !== null && !(body instanceof FormData) && !(body instanceof URLSearchParams); +} + export function createFapiClient(options: FapiClientOptions): FapiClient { const onBeforeRequestCallbacks: Array> = []; const onAfterResponseCallbacks: Array> = []; @@ -195,7 +219,21 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { requestOptions?: FapiRequestOptions, ): Promise> { const requestInit = { ..._requestInit }; - const { method = 'GET', body } = requestInit; + const { method = 'GET' } = requestInit; + let { body } = requestInit; + + // The Protect session token rides in the form-encoded body of sign-in and + // sign-up POSTs. It has to be merged here, before the body is stringified below — the + // onBeforeRequest callbacks run after stringification, so they cannot add a body param. A body + // param also keeps the request CORS-simple; a custom header would trigger the preflight that + // breaks cookie dropping in Safari, the same reason `_method` is a query param. + if (options.getProtectParams && isProtectGatedRequest(method, requestInit.path) && isMergeableBody(body)) { + const protectParams = await options.getProtectParams(); + if (protectParams) { + body = { ...((body ?? {}) as Record), ...protectParams } as unknown as BodyInit; + requestInit.body = body; + } + } if (body && typeof body === 'object' && !(body instanceof FormData)) { requestInit.body = filterUndefinedValues(body); diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index 569ec4e7969..2da9b9aa806 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -2,9 +2,12 @@ import { inBrowser } from '@clerk/shared/browser'; import { logger } from '@clerk/shared/logger'; import type { ProtectLoader } from '@clerk/shared/types'; +import type { ProtectPlaceholders, ProtectRequestParams } from './protectSession'; +import { interpolatePlaceholders, ProtectSession } from './protectSession'; import type { Environment } from './resources'; export class Protect { #initialized: boolean = false; + #session?: ProtectSession; load(env: Environment): void { const config = env?.protectConfig; @@ -23,31 +26,47 @@ export class Protect { // here rather than at end to mark as initialized even if load fails. this.#initialized = true; - for (const loader of config.loaders) { - try { - this.applyLoader(loader); - } catch (error) { - logger.warnOnce(`[protect] failed to apply loader: ${error}`); + // Rollout is decided before anything else, because the session is only meaningful for the + // loaders we are actually going to apply. + const loaders = config.loaders.filter(loader => inRollout(loader)); + + // Only an instance whose loaders reference the correlation id gets a session, so an instance + // not using it keeps today's behaviour and stores nothing in the browser. + this.#session = ProtectSession.create(loaders, config.id || undefined); + + // A token was already acquired in this browser session — possibly in another tab — so there + // is nothing left for the loaders to do. Reusing the shared token is the whole point: + // acquisition happens once per browser session, not once per tab per page load. + const alreadyAcquired = this.#session?.hasFreshToken() ?? false; + + if (!alreadyAcquired) { + for (const loader of loaders) { + try { + const element = this.applyLoader(loader, this.#session?.placeholders()); + if (element && this.#session?.isTokenLoader(loader)) { + this.#session.observeLoaderElement(element); + } + } catch (error) { + logger.warnOnce(`[protect] failed to apply loader: ${error}`); + } } } + + // 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. + this.#session?.start(); } - // apply individual loader - applyLoader(loader: ProtectLoader) { - // we use rollout for percentage based rollouts (as the environment file is cached) - if (loader.rollout !== undefined) { - const rollout = loader.rollout; - if (typeof rollout !== 'number' || rollout < 0) { - // invalid rollout percentage - do nothing - logger.warnOnce(`[protect] loader rollout value is invalid: ${rollout}`); - return; - } - if (rollout === 0 || Math.random() > rollout) { - // not in rollout percentage - do nothing - return; - } - } + /** + * The Protect params to attach to a sign-in or sign-up request, or `undefined` when this instance + * does not participate. Never rejects, and never blocks past the acquisition deadline. + */ + getRequestParams(): Promise { + return this.#session?.getRequestParams() ?? Promise.resolve(undefined); + } + // apply individual loader + applyLoader(loader: ProtectLoader, placeholders?: ProtectPlaceholders): HTMLElement | undefined { const type = loader.type || 'script'; const target = loader.target || 'body'; @@ -57,6 +76,8 @@ export class Protect { for (const [key, value] of Object.entries(loader.attributes)) { switch (typeof value) { case 'string': + element.setAttribute(key, placeholders ? interpolatePlaceholders(value, placeholders) : value); + break; case 'number': case 'boolean': element.setAttribute(key, String(value)); @@ -76,22 +97,38 @@ export class Protect { switch (target) { case 'head': document.head.appendChild(element); - break; + return element; case 'body': document.body.appendChild(element); - break; + return element; default: if (target?.startsWith('#')) { const targetElement = document.getElementById(target.substring(1)); if (!targetElement) { logger.warnOnce(`[protect] loader target element not found: ${target}`); - return; + return undefined; } targetElement.appendChild(element); - return; + return element; } logger.warnOnce(`[protect] loader target is invalid: ${target}`); - break; + return undefined; } } } + +// we use rollout for percentage based rollouts (as the environment file is cached) +function inRollout(loader: ProtectLoader): boolean { + if (loader.rollout === undefined) { + return true; + } + + const rollout = loader.rollout; + if (typeof rollout !== 'number' || rollout < 0) { + // invalid rollout percentage - do nothing + logger.warnOnce(`[protect] loader rollout value is invalid: ${rollout}`); + return false; + } + + return rollout !== 0 && Math.random() <= rollout; +} diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts new file mode 100644 index 00000000000..2687f83e1e9 --- /dev/null +++ b/packages/clerk-js/src/core/protectSession.ts @@ -0,0 +1,529 @@ +import type { ProtectLoader } from '@clerk/shared/types'; + +import { SafeLock } from './auth/safeLock'; + +/** + * Correlation id and Protect session token acquisition. + * + * 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 then issues a signed + * session token, which we acquire exactly once per browser session across all tabs and attach + * to sign-in and sign-up requests. + * + * Acquisition failure is never fatal: a structured status travels in the token's place so that + * a blocked or failed acquisition becomes a reportable fact rather than silence. + */ + +/** Persistent, per-app-origin client id. Opaque and random — nothing about the device. */ +const PID_STORAGE_KEY = '__clerk_protect_pid'; +/** The shared `{ token, exp, rid }` store every tab reads before it considers acquiring. */ +const TOKEN_STORAGE_KEY = '__clerk_protect_st'; +const ACQUISITION_LOCK_KEY = 'clerk.lock.protectSessionToken'; + +/** 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; +/** Bounds we hold the server-supplied `retry_in_ms` to. */ +const MIN_RETRY_DELAY_MS = 50; +const MAX_RETRY_DELAY_MS = 1_000; + +/** Lowercase RFC 4648 base32 — a `cid` has to survive case-insensitive, alphanumeric-only contexts. */ +const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567'; +/** 128 bits of base32, unpadded. */ +const BASE32_128_REGEX = /^[a-z2-7]{26}$/; +/** The full correlation id, identical to the regex FAPI validates against. */ +export const CID_REGEX = /^1-[a-z2-7]{26}-[a-z2-7]{26}$/; + +/** The closed set of placeholders `applyLoader` substitutes. Anything else is left verbatim. */ +const PLACEHOLDER_REGEX = /\{(cid|pid|rid|instance_id)\}/g; +/** Non-global twin of the above, so `test` does not carry `lastIndex` between calls. */ +const HAS_PLACEHOLDER_REGEX = /\{(?:cid|pid|rid|instance_id)\}/; + +/** + * Closed set — FAPI validates these by exact match and drops anything else, so a new value here + * is a wire change. + */ +export type ProtectStatus = 'ok' | 'timeout' | 'script_error' | 'fetch_error' | 'unsupported' | `http_${number}`; + +export type ProtectPlaceholders = { + cid?: string; + pid?: string; + rid?: string; + instance_id?: string; +}; + +/** The params attached to the form-encoded body of sign-in and sign-up POSTs. */ +export type ProtectRequestParams = { + __clerk_protect_token?: string; + __clerk_protect_status: ProtectStatus; + __clerk_protect_cid?: string; +}; + +type StoredToken = { + token: string; + /** Unix seconds, as served by the token endpoint. */ + exp: number; + rid: string; +}; + +/** + * Substitutes the closed placeholder set. A recognised placeholder with no value available, and + * any unrecognised `{…}`, is left verbatim — the server treats an unsubstituted placeholder as + * "no id" and serves normally, which is what keeps older SDK builds working against a templated + * loader config. + */ +export function interpolatePlaceholders(value: string, placeholders: ProtectPlaceholders): string { + return value.replace(PLACEHOLDER_REGEX, (match, name: keyof ProtectPlaceholders) => placeholders[name] ?? match); +} + +/** Lowercase RFC 4648 base32, unpadded. 16 bytes in, 26 chars out. */ +export function encodeBase32(bytes: Uint8Array): string { + let out = ''; + let value = 0; + let bits = 0; + + for (let i = 0; i < bytes.length; i++) { + value = (value << 8) | bytes[i]; + bits += 8; + while (bits >= 5) { + out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + out += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + } + + return out; +} + +/** 128 random bits as base32, or `null` when the platform has no CSPRNG. */ +function random128(): string | null { + const webCrypto = typeof crypto !== 'undefined' ? crypto : undefined; + if (typeof webCrypto?.getRandomValues !== 'function') { + return null; + } + + try { + return encodeBase32(webCrypto.getRandomValues(new Uint8Array(16))); + } catch { + return null; + } +} + +export function buildCid(pid: string, rid: string): string { + return `1-${pid}-${rid}`; +} + +/** + * `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. + */ +const memoryStore = new Map(); + +function localStorageOrNull(): Storage | null { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +function readStored(key: string): string | null { + try { + const storage = localStorageOrNull(); + if (storage) { + return storage.getItem(key); + } + } catch { + // blocked — the in-memory fallback is the only copy there is + } + return memoryStore.get(key) ?? null; +} + +function writeStored(key: string, value: string): void { + try { + const storage = localStorageOrNull(); + if (storage) { + storage.setItem(key, value); + return; + } + } catch { + // blocked or over quota — fall through + } + // In-memory only, for the lifetime of this page. We lose cross-tab sharing, not the sign-in. + memoryStore.set(key, value); +} + +function readOrMintPid(): string | null { + const existing = readStored(PID_STORAGE_KEY); + if (existing && BASE32_128_REGEX.test(existing)) { + return existing; + } + + const minted = random128(); + if (minted) { + writeStored(PID_STORAGE_KEY, minted); + } + return minted; +} + +/** + * Reads the shared token store. `marginMs` is how much remaining life a token needs to count as + * present: the acquisition path demands a margin so a run starts before the token lapses, while + * the request path takes anything not yet expired. + */ +function readStoredToken(marginMs: number): StoredToken | null { + const raw = readStored(TOKEN_STORAGE_KEY); + if (!raw) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const { token, exp, rid } = parsed as Record; + if (typeof token !== 'string' || !token) { + return null; + } + if (typeof exp !== 'number' || !Number.isFinite(exp)) { + return null; + } + if (typeof rid !== 'string' || !BASE32_128_REGEX.test(rid)) { + return null; + } + if (exp * 1_000 - marginMs <= Date.now()) { + return null; + } + + return { token, exp, rid }; +} + +function writeStoredToken(value: StoredToken): void { + writeStored(TOKEN_STORAGE_KEY, JSON.stringify(value)); +} + +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'; +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise(resolve => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); +} + +/** + * Resolves the token endpoint for a loader. + * + * `tokenUrl` is the explicit form: like every other part of the loader it is instance config, so a + * new placement ships without an SDK release. When it is absent we resolve `token` relative to the + * loader URL that carries the `{cid}`. Any placement where the `{cid}` is not a path segment has + * to declare `tokenUrl` explicitly. + */ +function resolveTokenUrl(loader: ProtectLoader, placeholders: ProtectPlaceholders): string | undefined { + const base = typeof document !== 'undefined' ? document.baseURI : undefined; + + if (typeof loader.tokenUrl === 'string' && loader.tokenUrl) { + try { + return new URL(interpolatePlaceholders(loader.tokenUrl, placeholders), base).href; + } catch { + return undefined; + } + } + + for (const value of Object.values(loader.attributes ?? {})) { + if (typeof value !== 'string' || !value.includes('{cid}')) { + continue; + } + try { + return new URL('token', new URL(interpolatePlaceholders(value, placeholders), base)).href; + } catch { + // not a URL-bearing attribute + } + } + + return undefined; +} + +/** Does this loader reference anything we would substitute? */ +function isTemplated(loader: ProtectLoader): boolean { + if (typeof loader.tokenUrl === 'string' && HAS_PLACEHOLDER_REGEX.test(loader.tokenUrl)) { + return true; + } + return Object.values(loader.attributes ?? {}).some( + value => typeof value === 'string' && HAS_PLACEHOLDER_REGEX.test(value), + ); +} + +export class ProtectSession { + /** `null` when the platform has no CSPRNG — we then report `unsupported`. */ + readonly #pid: string | null; + readonly #rid: string | null; + readonly #cid: string | null; + readonly #placeholders: ProtectPlaceholders; + /** The loader the token is acquired against, and whose `error` event means `script_error`. */ + readonly #tokenLoader?: ProtectLoader; + /** Absent when the instance has not configured the gate; we then report nothing at all. */ + readonly #tokenUrl?: string; + readonly #timeoutMs: number; + readonly #lock = SafeLock(ACQUISITION_LOCK_KEY); + + /** + * Memoised: one attempt per page load. Sign-ins after a failure read this instead of re-paying + * the deadline. + */ + #acquisition?: Promise; + #scriptError = false; + #pollAbort?: AbortController; + + /** + * Returns a session only when at least one loader references a placeholder — an instance not + * using the correlation id keeps today's behaviour exactly, and stores nothing in the browser. + */ + static create(loaders: ProtectLoader[], instanceId?: string): ProtectSession | undefined { + const templated = loaders.filter(isTemplated); + if (templated.length === 0) { + return undefined; + } + return new ProtectSession(templated, instanceId); + } + + private constructor(templatedLoaders: ProtectLoader[], instanceId?: string) { + this.#pid = readOrMintPid(); + this.#rid = this.#pid ? random128() : null; + this.#cid = this.#pid && this.#rid ? buildCid(this.#pid, this.#rid) : null; + + this.#placeholders = { + ...(this.#cid ? { cid: this.#cid } : {}), + ...(this.#pid ? { pid: this.#pid } : {}), + ...(this.#rid ? { rid: this.#rid } : {}), + ...(instanceId ? { instance_id: instanceId } : {}), + }; + + let tokenLoader: ProtectLoader | undefined; + let tokenUrl: string | undefined; + for (const loader of templatedLoaders) { + tokenUrl = resolveTokenUrl(loader, this.#placeholders); + if (tokenUrl) { + tokenLoader = loader; + break; + } + } + + this.#tokenLoader = tokenLoader; + this.#tokenUrl = tokenUrl; + this.#timeoutMs = + typeof tokenLoader?.tokenTimeoutMs === 'number' && tokenLoader.tokenTimeoutMs > 0 + ? tokenLoader.tokenTimeoutMs + : DEFAULT_TOKEN_TIMEOUT_MS; + } + + placeholders(): ProtectPlaceholders { + return this.#placeholders; + } + + isTokenLoader(loader: ProtectLoader): boolean { + return this.#tokenLoader === loader; + } + + /** + * A loader that fails to load means there is nothing to acquire against, so there is no point + * paying the poll deadline: abort and report `script_error`. + */ + observeLoaderElement(element: Element): void { + element.addEventListener( + 'error', + () => { + this.#scriptError = true; + this.#pollAbort?.abort(); + }, + { once: true }, + ); + } + + /** + * True when a token from this browser session is already shared with us — acquisition has + * already happened and the loader does not need injecting again. + */ + hasFreshToken(): boolean { + return this.#tokenUrl ? readStoredToken(TOKEN_REFRESH_MARGIN_MS) !== null : false; + } + + /** Fire-and-forget; started at `Clerk.load()` so a sign-in normally finds the token cached. */ + start(): void { + if (!this.#tokenUrl || this.#acquisition) { + return; + } + this.#acquisition = this.#acquire(); + } + + async getRequestParams(): Promise { + if (!this.#tokenUrl) { + // The instance interpolates the id into its loader but has not configured the token + // endpoint, so there is nothing to report. + return undefined; + } + + if (!this.#cid || !this.#pid) { + return { __clerk_protect_status: 'unsupported' }; + } + + const status = (await this.#acquisition) ?? 'timeout'; + + // Re-read: another tab may have completed a run after ours gave up. + const stored = readStoredToken(0); + if (stored) { + return { + __clerk_protect_token: stored.token, + __clerk_protect_status: 'ok', + // The cid has to name the run the token was minted for, not necessarily ours. + __clerk_protect_cid: buildCid(this.#pid, stored.rid), + }; + } + + return { + // `ok` without a token would contradict itself; the token lapsed between the run and now. + __clerk_protect_status: status === 'ok' ? 'timeout' : status, + __clerk_protect_cid: this.#cid, + }; + } + + async #acquire(): Promise { + if (!this.#cid) { + return 'unsupported'; + } + if (readStoredToken(TOKEN_REFRESH_MARGIN_MS)) { + // Step 1: valid and not near expiry. No lock, no network. + return 'ok'; + } + + const result = await this.#lock.acquireLockAndRun(async () => { + // Double-checked inside the lock. Without this re-read every tab acquires in turn, + // which is the exact failure this design exists to prevent. + if (readStoredToken(TOKEN_REFRESH_MARGIN_MS)) { + return 'ok' satisfies ProtectStatus; + } + return await this.#poll(); + }); + + if (typeof result === 'string') { + return result as ProtectStatus; + } + + // 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. + if (readStoredToken(TOKEN_REFRESH_MARGIN_MS)) { + return 'ok'; + } + return this.#scriptError ? 'script_error' : 'timeout'; + } + + /** Polls the token endpoint until the deadline. Never rejects. */ + async #poll(): Promise { + const url = this.#tokenUrl as string; + const deadline = Date.now() + this.#timeoutMs; + const controller = new AbortController(); + const deadlineTimer = setTimeout(() => controller.abort(), this.#timeoutMs); + this.#pollAbort = controller; + + try { + while (!this.#scriptError) { + if (Date.now() >= deadline) { + return 'timeout'; + } + + let response: Response; + try { + // No custom headers, no credentials: a CORS-simple GET needs no preflight. + response = await fetch(url, { credentials: 'omit', signal: controller.signal }); + } catch { + if (this.#scriptError) { + break; + } + return controller.signal.aborted ? 'timeout' : 'fetch_error'; + } + + if (response.status === 200) { + const token = await readTokenPayload(response, this.#rid as string); + if (!token) { + return 'fetch_error'; + } + writeStoredToken(token); + return 'ok'; + } + + if (response.status !== 202) { + return httpStatus(response.status); + } + + // Still pending. The server tells us how long to wait; we bound what we honour. + const delay = await readRetryDelay(response); + if (Date.now() + delay >= deadline) { + return 'timeout'; + } + await sleep(delay, controller.signal); + } + + return 'script_error'; + } finally { + clearTimeout(deadlineTimer); + this.#pollAbort = undefined; + } + } +} + +async function readTokenPayload(response: Response, rid: string): Promise { + let json: unknown; + try { + json = await response.json(); + } catch { + return null; + } + + if (!json || typeof json !== 'object') { + return null; + } + + const { token, exp } = json as Record; + if (typeof token !== 'string' || !token) { + return null; + } + if (typeof exp !== 'number' || !Number.isFinite(exp)) { + return null; + } + + return { token, exp, rid }; +} + +async function readRetryDelay(response: Response): Promise { + try { + const json = (await response.json()) as Record | null; + const retryInMs = json?.retry_in_ms; + if (typeof retryInMs === 'number' && Number.isFinite(retryInMs)) { + return Math.min(Math.max(retryInMs, MIN_RETRY_DELAY_MS), MAX_RETRY_DELAY_MS); + } + } catch { + // fall through to the default backoff + } + return MIN_RETRY_DELAY_MS * 2; +} diff --git a/packages/shared/src/types/protectConfig.ts b/packages/shared/src/types/protectConfig.ts index 515546aa64d..a7f4d4a5905 100644 --- a/packages/shared/src/types/protectConfig.ts +++ b/packages/shared/src/types/protectConfig.ts @@ -5,8 +5,23 @@ export interface ProtectLoader { rollout?: number; target: 'head' | 'body' | `#${string}`; type: string; + /** + * Attribute values may contain the placeholders `{cid}`, `{pid}`, `{rid}` and `{instance_id}`, + * which are substituted before the element is appended. An unrecognised `{…}` is left verbatim. + */ attributes?: Record; textContent?: string; + /** + * Where to acquire the Protect session token for this load. Placeholders are substituted as they + * are in `attributes`. Defaults to `token` resolved relative to the loader URL carrying the + * `{cid}`, which is correct whenever the `{cid}` appears as a path segment. + */ + tokenUrl?: string; + /** + * How long to wait for the token before giving up and reporting a status instead. Defaults to + * 5000. + */ + tokenTimeoutMs?: number; } export interface ProtectConfigJSON {