diff --git a/.changeset/lucky-pandas-observe.md b/.changeset/lucky-pandas-observe.md new file mode 100644 index 00000000000..f1af513ef17 --- /dev/null +++ b/.changeset/lucky-pandas-observe.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/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 2fb60167bd3..a9b53bd6c93 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": "77KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "119KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "77KB" }, + { "path": "./dist/clerk.js", "maxSize": "552KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "79KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "122KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "320KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "79KB" }, { "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__/clerk.protect-params.test.ts b/packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts new file mode 100644 index 00000000000..8344dcf1b66 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts @@ -0,0 +1,101 @@ +import type { ProtectAssertion } from '@clerk/shared/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Clerk } from '../clerk'; + +/** + * Two independent Protect features feed the single `getProtectParams` hook the FAPI client calls: + * the application-supplied assertion, and the server-configured session token. They are wired in + * the same expression, so collapsing it to either one alone still compiles, still type-checks, and + * silently stops sending the other's params — a degradation with nothing to see. These tests pin + * the hook to the union. + */ + +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' }; +const assertionParams = { __clerk_protect_assertion: 'token-abc' }; + +/** The hook a freshly constructed Clerk handed to the FAPI client. */ +const hookFor = (assertion?: ProtectAssertion) => { + const clerk = new Clerk(productionPublishableKey); + if (assertion !== undefined) { + clerk.setProtectAssertion(assertion); + } + return capturedOptions.current.getProtectParams as () => Promise | undefined>; +}; + +describe('Clerk getProtectParams', () => { + beforeEach(() => { + getRequestParams.mockReset(); + capturedOptions.current = undefined; + }); + + it('is wired into the FAPI client', () => { + expect(hookFor()).toBeTypeOf('function'); + }); + + it('unions the assertion and the session token', async () => { + getRequestParams.mockResolvedValue(sessionParams); + + await expect(hookFor('token-abc')()).resolves.toEqual({ ...assertionParams, ...sessionParams }); + }); + + it('sends the session token when no assertion is configured', async () => { + getRequestParams.mockResolvedValue(sessionParams); + + await expect(hookFor()()).resolves.toEqual(sessionParams); + }); + + it('sends the assertion when the session contributes nothing', async () => { + getRequestParams.mockResolvedValue(undefined); + + await expect(hookFor('token-abc')()).resolves.toEqual(assertionParams); + }); + + // Returning `{}` would make every sign-in body differ from what it was before the feature existed. + it('resolves to undefined when neither contributes anything', async () => { + getRequestParams.mockResolvedValue(undefined); + + await expect(hookFor()()).resolves.toBeUndefined(); + }); + + // Neither feature may take the other down with it. + it('keeps the session token when the assertion resolver throws', async () => { + getRequestParams.mockResolvedValue(sessionParams); + + await expect( + hookFor(() => { + throw new Error('boom'); + })(), + ).resolves.toEqual(sessionParams); + }); + + it('keeps the assertion when acquiring the session token rejects', async () => { + getRequestParams.mockRejectedValue(new DOMException('storage is blocked', 'SecurityError')); + + await expect(hookFor('token-abc')()).resolves.toEqual(assertionParams); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index 865ff7e866d..1c781e5a29f 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -385,67 +385,84 @@ describe('request', () => { }); describe('Protect params', () => { - const protectParams = { __clerk_protect_assertion: 'token-abc' }; - const clientWithProtect = createFapiClient({ - ...baseFapiClientOptions, - getProtectParams: () => Promise.resolve(protectParams), + // Two independent features feed this one hook — an application-supplied assertion and the + // server-configured session token — so the fixture carries params from both. + const protectParams = { + __clerk_protect_assertion: 'token-abc', + __clerk_protect_token: 'v1.payload.mac', + __clerk_protect_status: 'ok', + __clerk_protect_cid: `1-${'a'.repeat(26)}-${'b'.repeat(26)}`, + }; + const expectedProtectQuery = + '__clerk_protect_assertion=token-abc&__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 }); }); - it.each([ - ['/client/sign_ins'], - ['/client/sign_ins/sia_123/attempt_first_factor'], - ['/client/sign_ups'], - ['/client/sign_ups/sua_123/attempt_verification'], - ])('attaches them to POST %s', async path => { - await clientWithProtect.request({ path, method: 'POST', body: { identifier: 'user@example.com' } as any }); + const bodyOf = () => (fetch as Mock).mock.calls[0][1].body as string; - expect(fetch).toHaveBeenCalledWith( - expect.any(URL), - expect.objectContaining({ - body: 'identifier=user%40example.com&__clerk_protect_assertion=token-abc', - }), - ); + 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('attaches them when the request has no body of its own', async () => { - await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST' }); + it('adds no request headers', async () => { + await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: {} as any }); - expect(fetch).toHaveBeenCalledWith( - expect.any(URL), - expect.objectContaining({ body: '__clerk_protect_assertion=token-abc' }), - ); + const headers = (fetch as Mock).mock.calls[0][1].headers as Headers; + expect([...headers.keys()]).toEqual(['content-type']); }); - // All lower-case, so the camel-to-snake body key encoder has nothing to rewrite. - it('does not mangle the param name', async () => { - await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST' }); + // 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' }); - const [, init] = (fetch as Mock).mock.calls.at(-1)!; - expect(init.body).toBe('__clerk_protect_assertion=token-abc'); + expect(bodyOf()).toBe(expectedProtectQuery); }); - it.each([ - ['a GET', 'GET', '/client/sign_ins'], - ['an unrelated path', 'POST', '/client/sessions'], - ['a path that merely shares a prefix', 'POST', '/client/sign_ins_other'], - ])('does not attach them to %s', async (_label, method, path) => { - await clientWithProtect.request({ path, method: method as any, body: { a: 'b' } as any }); - - const [, init] = (fetch as Mock).mock.calls.at(-1)!; - expect(init.body ?? '').not.toContain('__clerk_protect_assertion'); + 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 untouched', async () => { + it('leaves a FormData body alone', async () => { const formData = new FormData(); - formData.append('identifier', 'user@example.com'); + formData.append('identifier', 'nick@clerk.dev'); await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: formData }); - expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: formData })); + expect((fetch as Mock).mock.calls[0][1].body).toBe(formData); + expect(getProtectParams).not.toHaveBeenCalled(); }); - it('leaves a string body untouched', async () => { + it('leaves a string body alone', async () => { // text/plain keeps the form-urlencoded encoder out of it. await clientWithProtect.request({ path: '/client/sign_ins', @@ -454,38 +471,44 @@ describe('request', () => { headers: { 'content-type': 'text/plain' }, }); - expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'raw string body' })); + expect(bodyOf()).toBe('raw string body'); + expect(getProtectParams).not.toHaveBeenCalled(); }); - // Protect may influence a sign-in but must never fail one. - it('sends the request unchanged when resolving the params rejects', async () => { - const failing = createFapiClient({ - ...baseFapiClientOptions, - getProtectParams: () => Promise.reject(new Error('boom')), - }); + // 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'); + }); - await expect( - failing.request({ path: '/client/sign_ins', method: 'POST', body: { identifier: 'a' } as any }), - ).resolves.toBeTruthy(); + it('sends nothing extra when the instance contributes no params', async () => { + getProtectParams.mockResolvedValue(undefined); - expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'identifier=a' })); - }); + await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: { foo: 'bar' } as any }); - it('sends the request unchanged when there are no params', async () => { - const none = createFapiClient({ - ...baseFapiClientOptions, - getProtectParams: () => Promise.resolve(undefined), - }); + expect(bodyOf()).toBe('foo=bar'); + }); - await none.request({ path: '/client/sign_ins', method: 'POST', body: { identifier: 'a' } as any }); + it('still sends the request when resolving the params rejects', async () => { + getProtectParams.mockRejectedValue(new DOMException('storage is blocked', 'SecurityError')); - expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'identifier=a' })); + // 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(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'identifier=a' })); + expect(bodyOf()).toBe('identifier=a'); }); }); 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 `