diff --git a/.changeset/protect-assertion-sdk-option.md b/.changeset/protect-assertion-sdk-option.md new file mode 100644 index 00000000000..cdcfeb0573e --- /dev/null +++ b/.changeset/protect-assertion-sdk-option.md @@ -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. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 95614c3de45..6a282fb032f 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -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" }, diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index 5de3432bdd5..2aff74ea8bc 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -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({ diff --git a/packages/clerk-js/src/core/__tests__/protectAssertion.test.ts b/packages/clerk-js/src/core/__tests__/protectAssertion.test.ts new file mode 100644 index 00000000000..24633257762 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/protectAssertion.test.ts @@ -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(); + }); +}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 2ce3f87c2e6..0c74df3fd83 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -104,6 +104,7 @@ import type { OrganizationResource, OrganizationSwitcherProps, PricingTableProps, + ProtectAssertion, PublicKeyCredentialCreationOptionsWithoutExtensions, PublicKeyCredentialRequestOptionsWithoutExtensions, PublicKeyCredentialWithAuthenticatorAssertionResponse, @@ -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'; @@ -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 | null = null; #touchThrottledUntil = 0; @@ -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) { @@ -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'); diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index c0595d20852..dd7c5de557a 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -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 | 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 | 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> = []; const onAfterResponseCallbacks: Array> = []; @@ -195,7 +224,23 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { requestOptions?: FapiRequestOptions, ): Promise> { 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), ...protectParams } as unknown as BodyInit; + requestInit.body = body; + } + } if (body && typeof body === 'object' && !(body instanceof FormData)) { requestInit.body = filterUndefinedValues(body); diff --git a/packages/clerk-js/src/core/protectAssertion.ts b/packages/clerk-js/src/core/protectAssertion.ts new file mode 100644 index 00000000000..9046810695c --- /dev/null +++ b/packages/clerk-js/src/core/protectAssertion.ts @@ -0,0 +1,65 @@ +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 instead: it is the same value by + * another road, and one name means one thing to search for when working out why an assertion + * did not apply. + */ +export const PROTECT_ASSERTION_PARAM = '__clerk_protect_assertion'; + +/** + * Resolves the configured assertion for one request. + * + * A function is called per request rather than once at configuration time, so an app that + * refreshes its token while the page is open does not have to re-configure Clerk for the new + * one to take effect. + * + * Nothing here can fail a sign-in. A resolver that throws, rejects, or returns something other + * than a non-empty string yields no assertion and a warning — the request proceeds without it, + * because an assertion may influence a sign-in and must never prevent one. + */ +export async function resolveProtectAssertion(assertion: ProtectAssertion | undefined): Promise { + 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 way to say "no assertion right now", so it is not worth a + // warning; anything else is a mistake the developer wants to hear about. + 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, or `undefined` when + * there is nothing to add. + * + * Returning `undefined` rather than an empty object matters: the caller only touches the body + * when there is something to put in it, so a request with no assertion is byte-for-byte the + * request that would have been sent before. + */ +export async function protectAssertionParams( + assertion: ProtectAssertion | undefined, +): Promise | undefined> { + const token = await resolveProtectAssertion(assertion); + return token ? { [PROTECT_ASSERTION_PARAM]: token } : undefined; +} diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index e017ab2ddcd..8998b3bd14a 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -45,6 +45,7 @@ import type { OrganizationResource, OrganizationSwitcherProps, PricingTableProps, + ProtectAssertion, RedirectOptions, Resources, SetActiveParams, @@ -389,6 +390,17 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return false; } + setProtectAssertion = (assertion?: ProtectAssertion): void => { + const callback = () => this.clerkjs?.setProtectAssertion(assertion); + if (this.clerkjs && this.loaded) { + callback(); + } else { + // Keyed by method name, so a second call before load replaces the first — which is the + // semantics a setter wants, and means a value set early is not lost. + this.premountMethodCalls.set('setProtectAssertion', callback); + } + }; + buildSignInUrl = (opts?: RedirectOptions): string | void => { const callback = () => this.clerkjs?.buildSignInUrl(opts) || ''; if (this.clerkjs && this.loaded) { diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index cb3999bcd70..ddd7cdf2156 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -25,6 +25,7 @@ import type { OAuthTransport } from './oauthTransport'; import type { OrganizationResource } from './organization'; import type { OrganizationCustomRoleKey } from './organizationMembership'; import type { ClerkPaginationParams } from './pagination'; +import type { ProtectAssertion } from './protectConfig'; import type { AfterMultiSessionSingleSignOutUrl, AfterSignOutUrl, @@ -293,6 +294,20 @@ export interface Clerk { */ __internal_getOption(key: K): ClerkOptions[K]; + /** + * Sets the Protect assertion attached to subsequent sign-in and sign-up requests, replacing + * any value supplied via the `protectAssertion` option. Pass `undefined` to clear it. + * + * Use this when the token is not available at `Clerk.load()` time — for example when your + * app fetches one from your backend after the page has started. Passing a function instead + * of a string has it re-read for each request, which is what you want if the token is + * refreshed while the page is open. + * + * @param assertion - A token minted by your backend, a function returning one, or + * `undefined`. + */ + setProtectAssertion: (assertion?: ProtectAssertion) => void; + /** * @internal * Primary `window.location.href` navigation chokepoint for `@clerk/clerk-js` and `@clerk/ui`. @@ -1414,6 +1429,18 @@ export type ClerkOptions = ClerkOptionsNavigation & * An object to localize your components. Will only affect [Clerk Components](https://clerk.com/docs/reference/components/overview) and not [Account Portal](https://clerk.com/docs/guides/account-portal/overview) pages. */ localization?: LocalizationResource; + /** + * A Clerk Protect assertion — a short-lived, signed token you mint from your own backend + * with the Clerk Backend API — carrying key/value pairs your Protect rules can read. Clerk + * attaches it to sign-in and sign-up requests. + * + * Pass a string if you already have one, or a function to have it re-read for each request. + * Prefer the function when a page can outlive the token: assertions are short-lived by + * design, and a string captured here stops applying once it expires. + * + * Can also be set later with `Clerk.setProtectAssertion()`. + */ + protectAssertion?: ProtectAssertion; /** * Indicates whether Clerk should poll against Clerk's backend every 5 minutes. * diff --git a/packages/shared/src/types/protectConfig.ts b/packages/shared/src/types/protectConfig.ts index 515546aa64d..7f469757a41 100644 --- a/packages/shared/src/types/protectConfig.ts +++ b/packages/shared/src/types/protectConfig.ts @@ -20,3 +20,27 @@ export interface ProtectConfigResource extends ClerkResource { loaders?: ProtectLoader[]; __internal_toSnapshot: () => ProtectConfigJSONSnapshot; } + +/** + * Returns the Protect assertion to attach to the next sign-in or sign-up request, or + * `undefined` to attach none. + * + * Called per request, so a token refreshed in the background is picked up without + * re-configuring Clerk. It must not throw, and a rejected promise is treated the same as + * `undefined`: an assertion may influence a sign-in, but never prevent one. + */ +export type ProtectAssertionResolver = () => string | undefined | Promise; + +/** + * A Protect assertion: a short-lived, signed token you mint from your own backend with the + * Clerk Backend API, carrying key/value pairs your Protect rules can read. + * + * Pass a `string` if you already have one, or a function to have it re-read for each + * sign-in or sign-up request. Prefer the function when a page can outlive the token — + * assertions are short-lived by design, and a string captured at load time stops applying + * once it expires. + * + * The assertion is an input to rules you author, never a decision on its own, and it only + * applies from the context you constrained it to when you minted it. + */ +export type ProtectAssertion = string | ProtectAssertionResolver;