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
28 changes: 28 additions & 0 deletions .changeset/protect-assertion-sdk-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/react': minor
---

Add a way to supply a Clerk Protect assertion from your application, so a token minted by your own backend reaches Protect without your having to set a cookie.

A Protect assertion is a short-lived, signed token you create with the Clerk Backend API, carrying key/value pairs your Protect rules can read. Until now the only way to deliver one was the `__clerk_protect_assertion` cookie, which requires your app and Frontend API to be on the same site — true with a production CNAME setup, but not on development instances.

Pass the token to Clerk and it is attached to sign-in and sign-up requests instead:

```ts
// A token you already have.
Clerk.load({ protectAssertion: token });

// Or a function, re-read for each request.
Clerk.load({ protectAssertion: () => sessionStorage.getItem('protect_assertion') ?? undefined });

// Or set it later, once your app has fetched one.
clerk.setProtectAssertion(token);
```

Prefer the function form when a page can outlive the token. Assertions are short-lived by design, so a string captured at load time stops applying once it expires, whereas a function picks up a refreshed one.

An assertion is an input to rules you author, never a decision on its own, and it applies only from the context you constrained it to when you minted it. Nothing about it can fail a sign-in: a resolver that throws, rejects, or returns anything other than a non-empty string simply results in no assertion being attached, and the request proceeds.

The cookie continues to work unchanged. If both are present, the value supplied to the SDK wins.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{ "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.native.js", "maxSize": "75KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
114 changes: 114 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,120 @@ describe('request', () => {
});
});

describe('Protect params', () => {
// A body param rather than a header, because a custom header would trigger a CORS
// preflight — the same reason `_method` is a query param. These tests pin that the params
// reach the encoded body, and reach nothing else.
const protectParams = { __clerk_protect_assertion: 'token-abc' };
const clientWithProtect = createFapiClient({
...baseFapiClientOptions,
getProtectParams: () => Promise.resolve(protectParams),
});

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 });

expect(fetch).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
body: 'identifier=user%40example.com&__clerk_protect_assertion=token-abc',
}),
);
});

it('attaches them when the request has no body of its own', async () => {
await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST' });

expect(fetch).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({ body: '__clerk_protect_assertion=token-abc' }),
);
});

// The param name survives the body's camelCase→snake_case key encoder untouched — it is
// all lower-case, so there is nothing for that encoder to rewrite. If it ever did not
// survive, the server would see an unknown param and reject the whole request.
it('does not mangle the param name', async () => {
await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST' });

const [, init] = (fetch as Mock).mock.calls.at(-1);
expect(init.body).toBe('__clerk_protect_assertion=token-abc');
});

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');
});

// Spreading a FormData would discard the caller's payload rather than add to it, so a body
// that is not a plain object is left completely alone.
it('leaves a FormData body untouched', async () => {
const formData = new FormData();
formData.append('identifier', 'user@example.com');

await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: formData });

expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: formData }));
});

it('leaves a string body untouched', async () => {
// text/plain so the form-urlencoded encoder stays out of it; the point here is that the
// merge does not touch a body it cannot safely spread.
await clientWithProtect.request({
path: '/client/sign_ins',
method: 'POST',
body: 'raw string body',
headers: { 'content-type': 'text/plain' },
});

expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'raw string body' }));
});

// 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')),
});

await expect(
failing.request({ path: '/client/sign_ins', method: 'POST', body: { identifier: 'a' } as any }),
).resolves.toBeTruthy();

expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'identifier=a' }));
});

it('sends the request unchanged when there are no params', async () => {
const none = createFapiClient({
...baseFapiClientOptions,
getProtectParams: () => Promise.resolve(undefined),
});

await none.request({ path: '/client/sign_ins', method: 'POST', body: { identifier: 'a' } as any });

expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'identifier=a' }));
});

// Every client built before this existed passes no hook at all; it must behave exactly as
// it did.
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' }));
});
});

describe('retry logic', () => {
it('does not send retry query parameter on initial request', async () => {
await fapiClient.request({
Expand Down
86 changes: 86 additions & 0 deletions packages/clerk-js/src/core/__tests__/protectAssertion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';

import { PROTECT_ASSERTION_PARAM, protectAssertionParams, resolveProtectAssertion } from '../protectAssertion';

describe('resolveProtectAssertion', () => {
it('returns undefined when nothing is configured', async () => {
await expect(resolveProtectAssertion(undefined)).resolves.toBeUndefined();
});

it('returns a configured string as-is', async () => {
await expect(resolveProtectAssertion('token-abc')).resolves.toBe('token-abc');
});

it('calls a sync resolver', async () => {
await expect(resolveProtectAssertion(() => 'token-sync')).resolves.toBe('token-sync');
});

it('awaits an async resolver', async () => {
await expect(resolveProtectAssertion(() => Promise.resolve('token-async'))).resolves.toBe('token-async');
});

// The whole reason a function is supported: the token outlives neither the page nor its own
// expiry, so a value captured once at configuration time would silently stop applying.
it('re-reads the resolver on every call', async () => {
const resolver = vi.fn<() => string>();
resolver.mockReturnValueOnce('first').mockReturnValueOnce('second');

await expect(resolveProtectAssertion(resolver)).resolves.toBe('first');
await expect(resolveProtectAssertion(resolver)).resolves.toBe('second');
expect(resolver).toHaveBeenCalledTimes(2);
});

it('treats a resolver returning undefined as "no assertion right now"', async () => {
await expect(resolveProtectAssertion(() => undefined)).resolves.toBeUndefined();
});

// An assertion may influence a sign-in but must never prevent one, so every bad input
// degrades to "no assertion" rather than propagating.
it.each([
[
'a throwing resolver',
() => {
throw new Error('boom');
},
],
['a rejecting resolver', () => Promise.reject(new Error('boom'))],
])('never rejects for %s', async (_label, resolver) => {
await expect(resolveProtectAssertion(resolver as () => string)).resolves.toBeUndefined();
});

it.each([
['an empty string', ''],
['whitespace only', ' '],
['a number', 42],
['null', null],
['an object', { token: 'x' }],
])('ignores %s', async (_label, value) => {
await expect(resolveProtectAssertion(() => value as unknown as string)).resolves.toBeUndefined();
});
});

describe('protectAssertionParams', () => {
it('names the param the server expects', async () => {
await expect(protectAssertionParams('token-abc')).resolves.toEqual({
[PROTECT_ASSERTION_PARAM]: 'token-abc',
});
});

// The param name is a cross-repo contract with the server, and it is deliberately identical
// to the cookie that can carry the same value. It is also all lower-case + underscores, so
// the body's camelCase→snake_case encoder leaves it alone — pinned here because a rename
// would break silently, as an ignored param rather than an error.
it('uses a param name the body encoder cannot mangle', () => {
expect(PROTECT_ASSERTION_PARAM).toBe('__clerk_protect_assertion');
expect(PROTECT_ASSERTION_PARAM).toBe(PROTECT_ASSERTION_PARAM.toLowerCase());
expect(PROTECT_ASSERTION_PARAM).not.toMatch(/[A-Z]/);
});

// Returning undefined rather than {} is what keeps a request with no assertion byte-for-byte
// the request that would have been sent before this existed.
it('returns undefined when there is nothing to attach', async () => {
await expect(protectAssertionParams(undefined)).resolves.toBeUndefined();
await expect(protectAssertionParams(() => undefined)).resolves.toBeUndefined();
await expect(protectAssertionParams('')).resolves.toBeUndefined();
});
});
22 changes: 22 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import type {
OrganizationResource,
OrganizationSwitcherProps,
PricingTableProps,
ProtectAssertion,
PublicKeyCredentialCreationOptionsWithoutExtensions,
PublicKeyCredentialRequestOptionsWithoutExtensions,
PublicKeyCredentialWithAuthenticatorAssertionResponse,
Expand Down Expand Up @@ -190,6 +191,7 @@ import { Billing } from './modules/billing';
import { createCheckoutInstance } from './modules/checkout/instance';
import { OAuthApplication } from './modules/oauthApplication';
import { Protect } from './protect';
import { protectAssertionParams } from './protectAssertion';
import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal';
import { State } from './state';

Expand Down Expand Up @@ -271,6 +273,11 @@ export class Clerk implements ClerkInterface {
#listeners: Array<(emission: Resources) => void> = [];
#navigationListeners: Array<() => void> = [];
#options: ClerkOptions = {};
#protectAssertion: ProtectAssertion | undefined;
// Distinguishes "never set via setProtectAssertion" from "explicitly cleared with
// undefined". Without it, clearing would silently fall back to the `protectAssertion`
// option, and a setter call before `load()` would be overwritten by it.
#protectAssertionSet = false;
#oauthTransport: OAuthTransport | null = null;
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
Expand Down Expand Up @@ -476,6 +483,20 @@ export class Clerk implements ClerkInterface {
return this.#options[key];
}

public setProtectAssertion = (assertion?: ProtectAssertion): void => {
this.#protectAssertion = assertion;
this.#protectAssertionSet = true;
};

/**
* The assertion in force right now: whatever was last passed to `setProtectAssertion`,
* otherwise the `protectAssertion` option. Read per request, so `load()` may run before or
* after the setter without either clobbering the other.
*/
#currentProtectAssertion(): ProtectAssertion | undefined {
return this.#protectAssertionSet ? this.#protectAssertion : this.#options.protectAssertion;
}

get isSignedIn(): boolean {
const hasPendingSession = this?.session?.status === 'pending';
if (hasPendingSession) {
Expand Down Expand Up @@ -513,6 +534,7 @@ export class Clerk implements ClerkInterface {
getSessionId: () => {
return this.session?.id;
},
getProtectParams: () => protectAssertionParams(this.#currentProtectAssertion()),
proxyUrl: this.proxyUrl,
});
this.#publicEventBus.emit(clerkEvents.Status, 'loading');
Expand Down
47 changes: 46 additions & 1 deletion packages/clerk-js/src/core/fapiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,44 @@ 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 to merge into the body of a sign-in or sign-up POST, or
* `undefined` when there are none to add.
*/
getProtectParams?: () => Promise<Record<string, string | undefined> | 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<string, unknown> | undefined {
if (body === undefined) {
return true;
}
if (typeof body !== 'object' || body === null) {
return false;
}
// Spreading anything else — a Blob, a typed array, a stream — would discard the caller's payload
// rather than add to it.
const prototype = Object.getPrototypeOf(body);
return prototype === Object.prototype || prototype === null;
}

export function createFapiClient(options: FapiClientOptions): FapiClient {
const onBeforeRequestCallbacks: Array<FapiRequestCallback<unknown>> = [];
const onAfterResponseCallbacks: Array<FapiRequestCallback<unknown>> = [];
Expand Down Expand Up @@ -195,7 +224,23 @@ export function createFapiClient(options: FapiClientOptions): FapiClient {
requestOptions?: FapiRequestOptions,
): Promise<FapiResponse<T>> {
const requestInit = { ..._requestInit };
const { method = 'GET', body } = requestInit;
const { method = 'GET' } = requestInit;
let { body } = requestInit;

// Protect params ride in the form-encoded body of sign-in and sign-up POSTs. They have 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)) {
// Protect can influence a sign-in but must never fail one, so a rejection here costs the
// params and nothing else.
const protectParams = await options.getProtectParams().catch(() => undefined);
if (protectParams) {
body = { ...((body ?? {}) as Record<string, unknown>), ...protectParams } as unknown as BodyInit;
requestInit.body = body;
}
}

if (body && typeof body === 'object' && !(body instanceof FormData)) {
requestInit.body = filterUndefinedValues(body);
Expand Down
Loading
Loading