From 8da6e5171fa936986b39496abe922e2c435c9e5c Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Tue, 1 Sep 2026 01:34:46 -0800 Subject: [PATCH 1/5] feat(clerk-js,shared): attach an optional server-configured session token to sign-in Backport of #9299 to Core 2. --- .changeset/protect-session-token-core2.md | 6 + .../__tests__/clerk.protect-params.test.ts | 64 ++ .../src/core/__tests__/fapiClient.test.ts | 125 +++ .../src/core/__tests__/protect.test.ts | 194 ++++ .../src/core/__tests__/protectSession.test.ts | 817 +++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 1 + packages/clerk-js/src/core/fapiClient.ts | 48 +- packages/clerk-js/src/core/protect.ts | 105 ++- packages/clerk-js/src/core/protectSession.ts | 853 ++++++++++++++++++ .../src/core/resources/ProtectConfig.ts | 3 + packages/clerk-js/vitest.setup.mts | 16 +- packages/shared/src/types/protectConfig.ts | 47 +- 12 files changed, 2248 insertions(+), 31 deletions(-) create mode 100644 .changeset/protect-session-token-core2.md create mode 100644 packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts 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/protect-session-token-core2.md b/.changeset/protect-session-token-core2.md new file mode 100644 index 00000000000..f1af513ef17 --- /dev/null +++ b/.changeset/protect-session-token-core2.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Internal improvements to Clerk Protect. No action is required, and instances that do not use Protect are unaffected. diff --git a/packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts b/packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts new file mode 100644 index 00000000000..2b5c4744c38 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Clerk } from '../clerk'; + +/** + * Pins the `getProtectParams` hook Clerk hands the FAPI client. Dropping it still compiles and + * type-checks, and silently stops sending the params. + */ + +const getRequestParams = vi.fn(); + +vi.mock('../protect', () => ({ + Protect: class { + load = vi.fn(); + getRequestParams = getRequestParams; + }, +})); + +const { capturedOptions } = vi.hoisted(() => ({ capturedOptions: { current: undefined as any } })); + +vi.mock('../fapiClient', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createFapiClient: (options: any) => { + capturedOptions.current = options; + return actual.createFapiClient(options); + }, + }; +}); + +const productionPublishableKey = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; + +const sessionParams = { __clerk_protect_token: 'v1.payload.mac', __clerk_protect_status: 'ok' }; + +/** The hook a freshly constructed Clerk handed to the FAPI client. */ +const hook = () => { + new Clerk(productionPublishableKey); + return capturedOptions.current.getProtectParams as () => Promise | undefined>; +}; + +describe('Clerk getProtectParams', () => { + beforeEach(() => { + getRequestParams.mockReset(); + capturedOptions.current = undefined; + }); + + it('is wired into the FAPI client', () => { + expect(hook()).toBeTypeOf('function'); + }); + + it('sends the session params', async () => { + getRequestParams.mockResolvedValue(sessionParams); + + await expect(hook()()).resolves.toEqual(sessionParams); + }); + + // Returning `{}` would make every sign-in body differ from what it was before the feature existed. + it('resolves to undefined when there is nothing to send', async () => { + getRequestParams.mockResolvedValue(undefined); + + await expect(hook()()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index 5de3432bdd5..9445e60842d 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -384,6 +384,131 @@ 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)}`, + }; + const expectedProtectQuery = + '__clerk_protect_token=v1.payload.mac&__clerk_protect_status=ok' + + `&__clerk_protect_cid=${protectParams.__clerk_protect_cid}`; + + 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', + '/client/sign_ups/sua_123/attempt_verification', + ])('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&${expectedProtectQuery}`); + // 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']); + }); + + // Also pins the param names against the camel-to-snake body key encoder: they are all + // lower-case, so it has nothing to rewrite. + it('populates the body even when the request had none', async () => { + await clientWithProtect.request({ path: '/client/sign_ups', method: 'POST' }); + + expect(bodyOf()).toBe(expectedProtectQuery); + }); + + it.each(['/client', '/client/sessions', '/environment', '/client/sign_insomething', '/client/sign_ins_other'])( + '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(); + }); + + // Spreading a FormData would discard the caller's payload, so non-plain bodies are left alone. + 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('leaves a string body alone', async () => { + // text/plain keeps the form-urlencoded encoder out of it. + await clientWithProtect.request({ + path: '/client/sign_ins', + method: 'POST', + body: 'raw string body', + headers: { 'content-type': 'text/plain' }, + }); + + expect(bodyOf()).toBe('raw string body'); + expect(getProtectParams).not.toHaveBeenCalled(); + }); + + // Merging into any of these would spread away the caller's payload rather than add to it. + it.each([ + ['a Blob', () => new Blob(['payload'])], + ['an array', () => [1, 2, 3]], + ['a URLSearchParams', () => new URLSearchParams({ identifier: 'nick@clerk.dev' })], + ])('leaves %s body alone', async (_label, makeBody) => { + await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: makeBody() as any }); + + expect(getProtectParams).not.toHaveBeenCalled(); + expect(String((fetch as Mock).mock.calls[0][1].body)).not.toContain('__clerk_protect'); + }); + + it('sends nothing extra when the instance contributes no params', async () => { + getProtectParams.mockResolvedValue(undefined); + + await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: { foo: 'bar' } as any }); + + expect(bodyOf()).toBe('foo=bar'); + }); + + it('still sends the request when resolving the params rejects', async () => { + getProtectParams.mockRejectedValue(new DOMException('storage is blocked', 'SecurityError')); + + // Protect can degrade a sign-in but must never fail one before it is even sent. + await expect( + clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: { foo: 'bar' } as any }), + ).resolves.toBeDefined(); + expect(bodyOf()).toBe('foo=bar'); + }); + + it('is inert when no hook is configured', async () => { + await fapiClient.request({ path: '/client/sign_ins', method: 'POST', body: { identifier: 'a' } as any }); + + expect(bodyOf()).toBe('identifier=a'); + }); + }); + 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..683c4420373 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/protect.test.ts @@ -0,0 +1,194 @@ +import type { ProtectLoader } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Protect } from '../protect'; +import { __internal_resetProtectStorage } from '../protectSession'; +import type { Environment } from '../resources'; + +const environment = (loaders: unknown[]): Environment => ({ protectConfig: { loaders } }) as unknown as Environment; + +/** + * No `src`: jsdom fetches real URLs, which fires `error` and races the events we drive here. + * `type=module` keeps it on the event-driven path regardless, which is what the served loader is. + */ +const loader = (overrides: Partial = {}): ProtectLoader => ({ + target: 'head', + type: 'script', + attributes: { 'data-cid': '{cid}', type: 'module' }, + token_timeout_ms: 200, + ...overrides, +}); + +const nowSeconds = () => Math.floor(Date.now() / 1_000); + +/** The token loader is injected under the acquisition lock, so it appears a few ticks in. */ +const injected = async (selector: string): Promise => { + for (let i = 0; i < 100; i++) { + const element = document.head.querySelector(selector); + if (element) { + return element; + } + await new Promise(resolve => setTimeout(resolve, 0)); + } + throw new Error(`nothing matched ${selector}`); +}; + +const serveInline = (element: Element, overrides: Record = {}) => { + (globalThis as unknown as Record).__clerk_specter = { + v: 3, + id: '11111111-2222-3333-4444-555555555555', + cid: element.getAttribute('data-cid'), + ready: Promise.resolve({ token: 'v1.payload.mac', exp: nowSeconds() + 43_200 }), + ...overrides, + }; + element.dispatchEvent(new Event('load')); +}; + +beforeEach(() => { + localStorage.clear(); + __internal_resetProtectStorage(); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + delete (globalThis as unknown as Record).__clerk_specter; +}); + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + __internal_resetProtectStorage(); + delete (globalThis as unknown as Record).__clerk_specter; +}); + +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: { 'data-loader': 'https://loader.example.com/ins_2abc.js' } }, + ]), + ); + + expect(document.head.querySelector('script')?.getAttribute('data-loader')).toBe( + 'https://loader.example.com/ins_2abc.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', async () => { + const protect = new Protect(); + protect.load( + environment([ + loader({ + attributes: { + // The instance id is baked into the config the server serves, not interpolated here. + 'data-src': 'https://loader.example.com/ins_2abc/{cid}/loader.js', + 'data-pid': '{pid}', + 'data-rid': '{rid}', + 'data-unknown': '{whatever}', + 'data-count': 3, + }, + }), + ]), + ); + + const element = await injected('script'); + 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('data-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('substitutes placeholders in textContent as well as attributes', async () => { + const protect = new Protect(); + protect.load(environment([loader({ text_content: 'window.__vendor_cid = "{cid}";' })])); + + const element = await injected('script'); + expect(element.textContent).toBe(`window.__vendor_cid = "${element.getAttribute('data-cid')}";`); + expect(element.textContent).not.toContain('{cid}'); + }); + + it('never interpolates {instance_id}', async () => { + const protect = new Protect(); + protect.load(environment([loader({ attributes: { 'data-src': '{instance_id}/{cid}.js' } })])); + + expect((await injected('script')).getAttribute('data-src')).toContain('{instance_id}'); + }); + + it('attaches the token once it has been acquired', async () => { + const protect = new Protect(); + protect.load(environment([loader()])); + + serveInline(await injected('script')); + + await expect(protect.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.payload.mac', + __clerk_protect_status: 'ok', + }); + }); + + it('still applies the other loaders when a token for this browser session is shared', async () => { + localStorage.setItem( + '__clerk_protect_st', + JSON.stringify({ token: 'v1.cached.mac', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26), at: Date.now() }), + ); + + const protect = new Protect(); + protect.load( + environment([ + loader({ attributes: { 'data-role': 'detection' } }), + loader({ attributes: { 'data-cid': '{cid}', 'data-role': 'token' } }), + ]), + ); + + // Acquisition happens once per browser session, so the token loader is skipped… + expect(document.head.querySelector('[data-role="token"]')).toBeNull(); + // …but the detection loader has its own job and runs on every page load regardless. + expect(document.head.querySelector('[data-role="detection"]')).not.toBeNull(); + 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([loader({ rollout: 0.1 })])); + + 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 () => { + const protect = new Protect(); + protect.load(environment([loader({ token_timeout_ms: 5_000 })])); + + (await injected('script')).dispatchEvent(new Event('error')); + + await expect(protect.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'script_error' }); + }); + + it('drops a malformed loader entry without failing the rest of the load', async () => { + const protect = new Protect(); + + // The config is server-controlled and cached; a bad entry must not take Clerk.load() down. + expect(() => protect.load(environment([null, loader({ attributes: { 'data-role': 'good' } })]))).not.toThrow(); + + expect(document.head.querySelector('[data-role="good"]')).not.toBeNull(); + }); + + it('survives a loader config that is not an array of objects at all', () => { + const protect = new Protect(); + expect(() => protect.load(environment(['nope', 42, undefined]))).not.toThrow(); + }); +}); 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..bb8056d59e9 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -0,0 +1,817 @@ +import type { ProtectLoader } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; + +import type { ApplyLoader } from '../protectSession'; +import { + __internal_resetProtectStorage, + buildCid, + CID_REGEX, + clampTimeout, + encodeBase32, + interpolatePlaceholders, + ProtectSession, +} from '../protectSession'; + +const LOADER_SRC = 'https://loader.example.com/ins_2abc/{cid}/loader.js'; + +/** + * No `src` by default: jsdom runs with `resources: 'usable'`, so a real URL is actually fetched + * and fires `error` on its own schedule, racing the events these tests need to drive themselves. + * `type=module` keeps it on the event-driven path regardless, which is what the served loader is. + */ +const loader = (overrides: Partial = {}): ProtectLoader => ({ + target: 'head', + type: 'script', + attributes: { 'data-cid': '{cid}', type: 'module' }, + token_timeout_ms: 200, + ...overrides, +}); + +const nowSeconds = () => Math.floor(Date.now() / 1_000); +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +/** + * A store entry exactly as `writeStoredToken` would have written it. Tests override only the + * field under test, so a rejection is provably about that field and not about a malformed + * fixture — every one of these cases is asserting *why* an entry was rejected. + */ +const storedEntry = (overrides: Record = {}) => + JSON.stringify({ + token: 'v1.cached.mac', + exp: nowSeconds() + 43_200, + rid: 'b'.repeat(26), + at: Date.now(), + ...overrides, + }); + +/** + * Stands in for `Protect.applyLoader`, handing the test the element the session is waiting on so + * it can play the part of the browser and fire `load` or `error`. + */ +const harness = () => { + const elements: HTMLElement[] = []; + const applyLoader: ApplyLoader = (config, placeholders) => { + const element = document.createElement(config.type || 'script'); + for (const [key, value] of Object.entries(config.attributes ?? {})) { + element.setAttribute(key, interpolatePlaceholders(String(value), placeholders)); + } + document.head.appendChild(element); + elements.push(element); + return element; + }; + + const injected = async (count = 1): Promise => { + for (let i = 0; i < 100 && elements.length < count; i++) { + await tick(); + } + return elements[count - 1]; + }; + + return { applyLoader, elements, injected }; +}; + +const session = (loaders: ProtectLoader[], tokensInvalidBefore?: number) => { + const h = harness(); + return { session: ProtectSession.create(loaders, h.applyLoader, tokensInvalidBefore), ...h }; +}; + +/** What the server does: the script body assigns the global, then the element fires `load`. */ +const serveInline = (element: HTMLElement, overrides: Record = {}) => { + (globalThis as unknown as Record).__clerk_specter = { + v: 3, + id: '11111111-2222-3333-4444-555555555555', + ready: Promise.resolve({ token: 'v1.payload.mac', exp: nowSeconds() + 43_200 }), + ...overrides, + }; + element.dispatchEvent(new Event('load')); +}; + +/** The shape served to a build that asserts no version: no cid, no `ready`. */ +const serveBaseShape = (element: HTMLElement) => { + (globalThis as unknown as Record).__clerk_specter = { + v: 1, + id: '11111111-2222-3333-4444-555555555555', + }; + element.dispatchEvent(new Event('load')); +}; + +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(); + __internal_resetProtectStorage(); + document.head.innerHTML = ''; + delete (globalThis as unknown as Record).__clerk_specter; + global.fetch = vi.fn(() => Promise.resolve(tokenResponse())) as unknown as typeof fetch; +}); + +afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + localStorage.clear(); + __internal_resetProtectStorage(); + delete (globalThis as unknown as Record).__clerk_specter; +}); + +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('{sdkver}/{cid}/{pid}/{rid}', { + cid: 'c', + pid: 'p', + rid: 'r', + sdkver: '1.2.3', + }), + ).toBe('1.2.3/c/p/r'); + }); + + it('leaves an unrecognised placeholder verbatim', () => { + // `{instance_id}` is not in the set and must not be: the instance id is the server's to place + // into the config it serves, never something the client interpolates. + expect(interpolatePlaceholders('{cid}/{nope}/{PID}/{instance_id}', { cid: 'c' })).toBe( + 'c/{nope}/{PID}/{instance_id}', + ); + }); + + it('leaves a recognised placeholder verbatim when there is no value for it', () => { + expect(interpolatePlaceholders('{cid}/{sdkver}', { cid: 'c' })).toBe('c/{sdkver}'); + }); +}); + +describe('ProtectSession.create', () => { + it('returns nothing when no loader references a placeholder', () => { + const { session: created } = session([loader({ attributes: { src: 'https://loader.example.com/loader.js' } })]); + + expect(created).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: created } = session([loader()]); + const cid = created?.placeholders().cid as string; + + expect(cid).toMatch(CID_REGEX); + expect(cid).toHaveLength(55); + expect(localStorage.getItem('__clerk_protect_pid')).toBe(created?.placeholders().pid); + }); + + it('reuses the persisted pid and mints a fresh rid per run', () => { + const first = session([loader()]).session?.placeholders(); + const second = session([loader()]).session?.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('stores nothing for a loader that only templates the SDK version', async () => { + const { session: created, elements } = session([ + loader({ attributes: { src: 'https://loader.example.com/{sdkver}/loader.js' } }), + ]); + + // The SDK version needs no minted identity, so none is planted for it. + expect(created?.placeholders().pid).toBeUndefined(); + expect(localStorage.getItem('__clerk_protect_pid')).toBeNull(); + + created?.start(); + await expect(created?.getRequestParams()).resolves.toBeUndefined(); + expect(elements).toHaveLength(0); + }); + + 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: created, elements } = session([loader()]); + created?.start(); + + await expect(created?.getRequestParams()).resolves.toEqual({ __clerk_protect_status: 'unsupported' }); + expect(elements).toHaveLength(0); + // Nothing usable to interpolate, so the loader keeps its literal placeholder. + expect(created?.placeholders().cid).toBeUndefined(); + } finally { + crypto.getRandomValues = originalGetRandomValues; + } + }); +}); + +describe('ProtectSession inline token', () => { + it('hands the correlation id to the loader it injects', async () => { + const { session: created, injected } = session([loader({ attributes: { src: LOADER_SRC } })]); + created?.start(); + + expect((await injected()).getAttribute('src')).toBe( + `https://loader.example.com/ins_2abc/${created?.placeholders().cid}/loader.js`, + ); + }); + + it('takes the token the loader was served with, and shares it through localStorage', async () => { + const { session: created, injected } = session([loader()]); + created?.start(); + + serveInline(await injected(), { cid: created?.placeholders().cid }); + + await expect(created?.getRequestParams()).resolves.toEqual({ + __clerk_protect_token: 'v1.payload.mac', + __clerk_protect_status: 'ok', + __clerk_protect_cid: created?.placeholders().cid, + }); + expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string)).toMatchObject({ + token: 'v1.payload.mac', + rid: created?.placeholders().rid, + }); + // The whole point of inline delivery: no second request. + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('ignores a token minted for someone else’s run', async () => { + const { session: created, injected } = session([loader()]); + created?.start(); + + serveInline(await injected(), { cid: buildCid('z'.repeat(26).replace(/z/g, 'a'), 'b'.repeat(26)) }); + + await expect(created?.getRequestParams()).resolves.toEqual({ + __clerk_protect_status: 'no_token', + __clerk_protect_cid: created?.placeholders().cid, + }); + }); + + it('takes the token from a classic inline script, which fires no load event', async () => { + // A `