Skip to content
Open
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.
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"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.legacy.browser.js", "maxSize": "119KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "76KB" },
{ "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
105 changes: 105 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,111 @@ describe('request', () => {
});
});

describe('Protect params', () => {
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' }),
);
});

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

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, so non-plain bodies are left 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 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(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' }));
});

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
78 changes: 78 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,78 @@
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');
});

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

// Bad inputs degrade to "no assertion" rather than propagating; Protect must never fail a sign-in.
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 unknown 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; a rename would break silently.
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]/);
});

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();
});
});
16 changes: 16 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,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 @@ -272,6 +274,9 @@ export class Clerk implements ClerkInterface {
#listeners: Array<(emission: Resources) => void> = [];
#navigationListeners: Array<() => void> = [];
#options: ClerkOptions = {};
#protectAssertion: ProtectAssertion | undefined;
// Distinguishes never-set from cleared-with-undefined, so clearing does not fall back to the option.
#protectAssertionSet = false;
#oauthTransport: OAuthTransport | null = null;
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
Expand Down Expand Up @@ -477,6 +482,16 @@ export class Clerk implements ClerkInterface {
return this.#options[key];
}

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

/** The last value passed to `setProtectAssertion` once called, otherwise the `protectAssertion` option. */
#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 @@ -514,6 +529,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
38 changes: 37 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,38 @@ 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;
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;
}
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 +218,20 @@ 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 must merge before the body is stringified below (onBeforeRequest callbacks run
// after stringification), and ride the body rather than a header to avoid the same
// CORS preflight `_method` avoids.
if (options.getProtectParams && isProtectGatedRequest(method, requestInit.path) && isMergeableBody(body)) {
// A rejection costs the params, never the request.
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
47 changes: 47 additions & 0 deletions packages/clerk-js/src/core/protectAssertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { logger } from '@clerk/shared/logger';
import type { ProtectAssertion } from '@clerk/shared/types';

/** The request param carrying a Protect assertion; deliberately the same name as the cookie that can carry it. */
export const PROTECT_ASSERTION_PARAM = '__clerk_protect_assertion';

/**
* Resolves the configured assertion for one request. Never rejects: a failing resolver or
* invalid value yields `undefined`, because an assertion may influence a sign-in but must never prevent one.
*/
export async function resolveProtectAssertion(assertion: ProtectAssertion | undefined): Promise<string | undefined> {
if (assertion === undefined) {
return undefined;
}

let value: unknown = assertion;
if (typeof assertion === 'function') {
try {
value = await assertion();
} catch (error) {
logger.warnOnce(`Clerk: protectAssertion resolver failed, continuing without it: ${error}`);
return undefined;
}
}

// `undefined` is the documented "no assertion right now", so it is not worth a warning.
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string' || value.trim() === '') {
logger.warnOnce('Clerk: protectAssertion must be a non-empty string; ignoring it.');
return undefined;
}

return value;
}

/**
* The Protect params to merge into a sign-in or sign-up request body. Returns `undefined`
* rather than `{}` so a request with no assertion is byte-for-byte what it was before.
*/
export async function protectAssertionParams(
assertion: ProtectAssertion | undefined,
): Promise<Record<string, string> | undefined> {
const token = await resolveProtectAssertion(assertion);
return token ? { [PROTECT_ASSERTION_PARAM]: token } : undefined;
}
Loading
Loading