Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/lucky-pandas-observe.md
Original file line number Diff line number Diff line change
@@ -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_<n>`, `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`.
8 changes: 4 additions & 4 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down
78 changes: 78 additions & 0 deletions packages/clerk-js/src/core/__tests__/fapiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createFapiClient>;

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({
Expand Down
178 changes: 178 additions & 0 deletions packages/clerk-js/src/core/__tests__/protect.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Loading
Loading