diff --git a/.changeset/protect-check-public-entry.md b/.changeset/protect-check-public-entry.md new file mode 100644 index 00000000000..91afb7a7451 --- /dev/null +++ b/.changeset/protect-check-public-entry.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Add a public `@clerk/shared/protect-check` entry point for running Clerk Protect challenges outside the prebuilt components. It exposes `executeProtectCheck` (previously only reachable via the internal `@clerk/shared/internal/clerk-js/protectCheck` subpath, which remains as an alias) together with the lifecycle helpers the prebuilt components use: `executeProtectCheckWithTimeout`, `submitProtectCheckProof`, `isProtectCheckExpired`, and the `PROTECT_CHECK_ERROR_CODES` map. diff --git a/.changeset/protect-check-ui-refactor.md b/.changeset/protect-check-ui-refactor.md new file mode 100644 index 00000000000..260893875ff --- /dev/null +++ b/.changeset/protect-check-ui-refactor.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +The Protect check cards now drive their challenge lifecycle through the shared helpers in `@clerk/shared/protect-check`. No behavioral changes. diff --git a/packages/shared/package.json b/packages/shared/package.json index 7b6eb3f7c41..6c863fc48a5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -61,6 +61,16 @@ "default": "./dist/keyless/index.js" } }, + "./protect-check": { + "import": { + "types": "./dist/protect-check/index.d.mts", + "default": "./dist/protect-check/index.mjs" + }, + "require": { + "types": "./dist/protect-check/index.d.ts", + "default": "./dist/protect-check/index.js" + } + }, "./utils": { "import": { "types": "./dist/utils/index.d.mts", diff --git a/packages/shared/src/internal/clerk-js/protectCheck.ts b/packages/shared/src/internal/clerk-js/protectCheck.ts index bcdcec43007..e9b2655ed5a 100644 --- a/packages/shared/src/internal/clerk-js/protectCheck.ts +++ b/packages/shared/src/internal/clerk-js/protectCheck.ts @@ -1,159 +1,4 @@ -import { ClerkRuntimeError } from '../../error'; -import type { ProtectCheckResource } from '../../types'; - -export interface ExecuteProtectCheckOptions { - /** - * Host-provided visibility handshake, forwarded to the script verbatim as - * `setWidgetVisible` in the init payload. The script calls it right before revealing UI in - * the container (and with `false` once its widget is done); the returned promise resolves - * only after the host has applied the change to the DOM (e.g. removed its own loading - * spinner), so the script can sequence its reveal without a frame of overlap. A script that - * knows its widget is imminent may call it immediately to avoid a spinner flash. Scripts - * must treat the field as optional — older hosts don't provide it. - */ - setWidgetVisible?: (visible: boolean) => Promise; - /** - * Signals that the caller no longer needs the proof token (component unmounted, user - * navigated away, etc.). When the signal aborts: - * - If the script has not yet been imported, `executeProtectCheck` rejects with - * `protect_check_aborted` without loading the script. - * - The signal is forwarded to the script as `{ signal }` in the second argument so - * cooperating SDKs can cancel any in-flight UI / network work. - * - Even if the script ignores the signal and resolves with a token, the helper - * re-checks `signal.aborted` after the await and rejects with `protect_check_aborted` - * so the caller never observes a "successful" abort. - * - * Scripts that don't honor the signal will continue to run; this is best-effort by design. - */ - signal?: AbortSignal; -} - -interface ScriptInitOptions { - token: string; - uiHints?: Record; - signal?: AbortSignal; - setWidgetVisible?: (visible: boolean) => Promise; -} - -type ScriptDefault = (container: HTMLDivElement, init: ScriptInitOptions) => Promise; - -/** - * Validates the `sdk_url` returned by the server before passing it to dynamic `import()`. - * - * Rejects: - * - Anything that fails URL parsing (relative paths, garbage strings) - * - Non-`https:` schemes — including `http:`, `data:`, `blob:`, `javascript:`. The server - * always returns an HTTPS URL, but the dynamic-import primitive accepts `data:`/`blob:` - * modules which would let a tampered response inject arbitrary code into the host page. - * - URLs containing credentials (`user:pass@host`) — phishing surface, no legitimate use. - * - * Throws `ClerkRuntimeError` with code `protect_check_invalid_sdk_url`. We deliberately do - * NOT silently strip an invalid `protect_check` from the resource: the gate must remain - * present so the user can't bypass it by manipulating the response. Fail-closed. - */ -function assertValidSdkUrl(sdkUrl: string): URL { - let parsed: URL; - try { - parsed = new URL(sdkUrl); - } catch { - throw new ClerkRuntimeError('Protect check sdk_url is not a valid URL', { - code: 'protect_check_invalid_sdk_url', - }); - } - if (parsed.protocol !== 'https:') { - throw new ClerkRuntimeError('Protect check sdk_url must use HTTPS', { - code: 'protect_check_invalid_sdk_url', - }); - } - if (parsed.username || parsed.password) { - throw new ClerkRuntimeError('Protect check sdk_url must not contain credentials', { - code: 'protect_check_invalid_sdk_url', - }); - } - return parsed; -} - -/** - * Loads the Protect challenge SDK from `protectCheck.sdkUrl`, hands it the container element - * and the spec-defined init payload (`token`, `uiHints`, `signal`), and returns the proof - * token the SDK produces. - * - * The SDK script must: - * - Be a valid ES module served over HTTPS - * - Have a default export of the shape `(container, { token, uiHints, signal }) => Promise` - * - Honor the `signal` to abort any pending work (best-effort) - * - * Only the minimal fields (`token`, optional `ui_hints`) are surfaced to the script — the - * full sign-up/sign-in resource is intentionally NOT passed, to minimize the trust surface - * granted to third-party Protect scripts. - * - * Failure modes are surfaced as `ClerkRuntimeError` with one of: - * - `protect_check_invalid_sdk_url` — URL fails the safety checks above - * - `protect_check_aborted` — caller aborted before or during execution - * - `protect_check_script_load_failed` — network error, CSP block, or invalid module - * - `protect_check_invalid_script` — module loaded but no callable default export - * - `protect_check_execution_failed` — the script's default export threw - */ -export async function executeProtectCheck( - protectCheck: Pick, - container: HTMLDivElement, - options: ExecuteProtectCheckOptions = {}, -): Promise { - const { signal, setWidgetVisible } = options; - const { sdkUrl, token, uiHints } = protectCheck; - - const validated = assertValidSdkUrl(sdkUrl); - - if (signal?.aborted) { - throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); - } - - let mod: Record; - try { - mod = await import(/* webpackIgnore: true */ validated.toString()); - } catch { - // Surface a generic message and deliberately omit the original error: Chromium/Firefox embed - // the sdk_url in the dynamic-import failure text, which a tampered response could plant in the UI. - throw new ClerkRuntimeError( - 'Protect check script failed to load. This is commonly caused by a Content Security ' + - 'Policy that blocks the script origin (add it to your script-src directive), a ' + - 'network error, or an invalid module.', - { code: 'protect_check_script_load_failed' }, - ); - } - - if (signal?.aborted) { - throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); - } - - if (typeof mod.default !== 'function') { - throw new ClerkRuntimeError('Protect check script does not export a default function', { - code: 'protect_check_invalid_script', - }); - } - - let proofToken: string; - try { - proofToken = await (mod.default as ScriptDefault)(container, { token, uiHints, signal, setWidgetVisible }); - } catch (err) { - // Distinguish abort-induced rejections from genuine script errors: only relabel as - // `protect_check_aborted` when the error looks like an abort (`AbortError`), otherwise - // surface the script's actual failure so production diagnostics aren't masked. - const looksLikeAbort = err instanceof Error && err.name === 'AbortError'; - if (signal?.aborted && looksLikeAbort) { - throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); - } - const original = err instanceof Error ? err.message : String(err); - throw new ClerkRuntimeError(`Protect check script execution failed: ${original}`, { - code: 'protect_check_execution_failed', - }); - } - - // The script may have ignored the signal and resolved with a token after the abort fired. - // Re-check here so callers get a consistent contract: if you aborted, you never see a token. - if (signal?.aborted) { - throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); - } - - return proofToken; -} +// Moved to the public `@clerk/shared/protect-check` entry; this subpath is kept as an alias so +// existing imports keep resolving. +export { executeProtectCheck } from '../../protect-check/executeProtectCheck'; +export type { ExecuteProtectCheckOptions } from '../../protect-check/executeProtectCheck'; diff --git a/packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts b/packages/shared/src/protect-check/__tests__/executeProtectCheck.test.ts similarity index 99% rename from packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts rename to packages/shared/src/protect-check/__tests__/executeProtectCheck.test.ts index 1d0ac1cfdb6..6da968e016b 100644 --- a/packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts +++ b/packages/shared/src/protect-check/__tests__/executeProtectCheck.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ProtectCheckResource } from '@/types'; -import { executeProtectCheck } from '../protectCheck'; +import { executeProtectCheck } from '../executeProtectCheck'; const fakeContainer = (): HTMLDivElement => ({}) as HTMLDivElement; diff --git a/packages/shared/src/protect-check/__tests__/lifecycle.test.ts b/packages/shared/src/protect-check/__tests__/lifecycle.test.ts new file mode 100644 index 00000000000..3f9345419b0 --- /dev/null +++ b/packages/shared/src/protect-check/__tests__/lifecycle.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ClerkAPIResponseError } from '@/error'; +import type { ProtectCheckResource } from '@/types'; + +import { executeProtectCheckWithTimeout, isProtectCheckExpired, submitProtectCheckProof } from '../lifecycle'; + +vi.mock('../executeProtectCheck', () => ({ + executeProtectCheck: vi.fn(), +})); + +import { executeProtectCheck } from '../executeProtectCheck'; + +const mockExecute = vi.mocked(executeProtectCheck); + +const protectCheck = (overrides: Partial = {}): ProtectCheckResource => ({ + status: 'pending', + token: 'challenge-token', + sdkUrl: 'https://protect.example.com/sdk.js', + ...overrides, +}); + +const alreadyResolvedError = () => + new ClerkAPIResponseError('Already resolved', { + data: [{ code: 'protect_check_already_resolved', message: 'Already resolved', long_message: '' }], + status: 400, + clerkTraceId: 'trace_123', + }); + +beforeEach(() => { + mockExecute.mockReset(); +}); + +describe('isProtectCheckExpired', () => { + it('is false when expiresAt is absent', () => { + expect(isProtectCheckExpired(protectCheck())).toBe(false); + }); + + it('compares expiresAt (unix milliseconds) against now', () => { + expect(isProtectCheckExpired(protectCheck({ expiresAt: Date.now() - 1_000 }))).toBe(true); + expect(isProtectCheckExpired(protectCheck({ expiresAt: Date.now() + 60_000 }))).toBe(false); + }); +}); + +describe('executeProtectCheckWithTimeout', () => { + it('clears the container before running so a previous run cannot leave a stale widget', async () => { + const container = document.createElement('div'); + container.appendChild(document.createElement('span')); + mockExecute.mockResolvedValue('proof-token'); + + await executeProtectCheckWithTimeout(protectCheck(), container); + + expect(container.childNodes.length).toBe(0); + }); + + it('resolves with the proof token and forwards the challenge to executeProtectCheck', async () => { + const container = document.createElement('div'); + mockExecute.mockResolvedValue('proof-token'); + + const check = protectCheck({ token: 'opaque', uiHints: { reason: 'device_new' } }); + await expect(executeProtectCheckWithTimeout(check, container)).resolves.toBe('proof-token'); + + expect(mockExecute).toHaveBeenCalledWith( + check, + container, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + describe('timeout', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('aborts the SDK and rejects with protect_check_timed_out when the script never settles', async () => { + const container = document.createElement('div'); + let sdkSignal: AbortSignal | undefined; + mockExecute.mockImplementation((_check, _container, opts) => { + sdkSignal = opts?.signal; + return new Promise(() => {}); // hung SDK + }); + + const promise = executeProtectCheckWithTimeout(protectCheck(), container, { timeoutMs: 1_000 }); + const assertion = expect(promise).rejects.toMatchObject({ code: 'protect_check_timed_out' }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + expect(sdkSignal?.aborted).toBe(true); + }); + + it('does not abort the caller controller on timeout', async () => { + const container = document.createElement('div'); + const caller = new AbortController(); + mockExecute.mockImplementation(() => new Promise(() => {})); + + const promise = executeProtectCheckWithTimeout(protectCheck(), container, { + signal: caller.signal, + timeoutMs: 1_000, + }); + const assertion = expect(promise).rejects.toMatchObject({ code: 'protect_check_timed_out' }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + expect(caller.signal.aborted).toBe(false); + }); + + it('swallows setWidgetVisible signals from a zombie script after timeout', async () => { + const container = document.createElement('div'); + const setWidgetVisible = vi.fn().mockResolvedValue(undefined); + let scriptSetWidgetVisible: ((visible: boolean) => Promise) | undefined; + mockExecute.mockImplementation((_check, _container, opts) => { + scriptSetWidgetVisible = opts?.setWidgetVisible; + return new Promise(() => {}); + }); + + const promise = executeProtectCheckWithTimeout(protectCheck(), container, { setWidgetVisible, timeoutMs: 1_000 }); + const assertion = expect(promise).rejects.toMatchObject({ code: 'protect_check_timed_out' }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + + await scriptSetWidgetVisible!(true); + expect(setWidgetVisible).not.toHaveBeenCalled(); + }); + + it('clears the timeout once the script settles', async () => { + const container = document.createElement('div'); + mockExecute.mockResolvedValue('proof-token'); + + await expect(executeProtectCheckWithTimeout(protectCheck(), container, { timeoutMs: 1_000 })).resolves.toBe( + 'proof-token', + ); + + expect(vi.getTimerCount()).toBe(0); + }); + }); + + it('links the caller signal into the SDK signal (one-way)', async () => { + const container = document.createElement('div'); + const caller = new AbortController(); + let sdkSignal: AbortSignal | undefined; + mockExecute.mockImplementation((_check, _container, opts) => { + sdkSignal = opts?.signal; + return new Promise(() => {}); + }); + + void executeProtectCheckWithTimeout(protectCheck(), container, { signal: caller.signal, timeoutMs: 50 }).catch( + () => {}, + ); + await vi.waitFor(() => expect(mockExecute).toHaveBeenCalled()); + expect(sdkSignal?.aborted).toBe(false); + + caller.abort(); + expect(sdkSignal?.aborted).toBe(true); + }); + + it('passes an already-aborted signal through to the SDK', async () => { + const container = document.createElement('div'); + const caller = new AbortController(); + caller.abort(); + let sdkSignal: AbortSignal | undefined; + mockExecute.mockImplementation((_check, _container, opts) => { + sdkSignal = opts?.signal; + return Promise.resolve('unused'); + }); + + await executeProtectCheckWithTimeout(protectCheck(), container, { signal: caller.signal }); + expect(sdkSignal?.aborted).toBe(true); + }); + + it('forwards visibility signals from a live run', async () => { + const container = document.createElement('div'); + const setWidgetVisible = vi.fn().mockResolvedValue(undefined); + mockExecute.mockImplementation(async (_check, _container, opts) => { + await opts?.setWidgetVisible?.(true); + return 'proof-token'; + }); + + await executeProtectCheckWithTimeout(protectCheck(), container, { setWidgetVisible }); + expect(setWidgetVisible).toHaveBeenCalledWith(true); + }); +}); + +describe('submitProtectCheckProof', () => { + it('returns the submitted resource on success', async () => { + const updated = { id: 'si_updated' }; + const submit = vi.fn().mockResolvedValue(updated); + + const result = await submitProtectCheckProof({ + proofToken: 'proof-abc', + submitProtectCheck: submit, + reload: vi.fn(), + getResource: () => ({ id: 'si_live' }), + }); + + expect(submit).toHaveBeenCalledWith({ proofToken: 'proof-abc' }); + expect(result).toEqual({ status: 'submitted', resource: updated }); + }); + + it('treats protect_check_already_resolved as soft success: reloads and returns the live resource', async () => { + const live = { id: 'si_live' }; + const reload = vi.fn().mockResolvedValue(undefined); + + const result = await submitProtectCheckProof({ + proofToken: 'proof-abc', + submitProtectCheck: vi.fn().mockRejectedValue(alreadyResolvedError()), + reload, + getResource: () => live, + }); + + expect(reload).toHaveBeenCalled(); + expect(result).toEqual({ status: 'already_resolved', resource: live }); + }); + + it('returns cancelled (and does not reload) when the caller cancelled during a failing submit', async () => { + const reload = vi.fn(); + + const result = await submitProtectCheckProof({ + proofToken: 'proof-abc', + submitProtectCheck: vi.fn().mockRejectedValue(alreadyResolvedError()), + reload, + getResource: () => ({}), + isCancelled: () => true, + }); + + expect(result).toEqual({ status: 'cancelled' }); + expect(reload).not.toHaveBeenCalled(); + }); + + it('rethrows any other submit failure untouched', async () => { + const failure = new ClerkAPIResponseError('Blocked', { + data: [{ code: 'action_blocked', message: 'Blocked', long_message: '' }], + status: 403, + clerkTraceId: 'trace_456', + }); + + await expect( + submitProtectCheckProof({ + proofToken: 'proof-abc', + submitProtectCheck: vi.fn().mockRejectedValue(failure), + reload: vi.fn(), + getResource: () => ({}), + }), + ).rejects.toBe(failure); + }); +}); diff --git a/packages/shared/src/protect-check/errors.ts b/packages/shared/src/protect-check/errors.ts new file mode 100644 index 00000000000..ccc82ea30fd --- /dev/null +++ b/packages/shared/src/protect-check/errors.ts @@ -0,0 +1,19 @@ +import { ERROR_CODES } from '../internal/clerk-js/constants'; + +/** + * Every error code the Protect check lifecycle can surface. + * + * `ALREADY_RESOLVED` arrives as a `ClerkAPIResponseError` from FAPI (the server's state has + * already moved past the gate — treat as success after a reload). The rest are client-side + * `ClerkRuntimeError` codes thrown by `executeProtectCheck` / `executeProtectCheckWithTimeout`. + */ +export const PROTECT_CHECK_ERROR_CODES = { + ALREADY_RESOLVED: ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED, + TIMED_OUT: ERROR_CODES.PROTECT_CHECK_TIMED_OUT, + UNSUPPORTED_ENVIRONMENT: ERROR_CODES.PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT, + INVALID_SDK_URL: 'protect_check_invalid_sdk_url', + ABORTED: 'protect_check_aborted', + SCRIPT_LOAD_FAILED: 'protect_check_script_load_failed', + INVALID_SCRIPT: 'protect_check_invalid_script', + EXECUTION_FAILED: 'protect_check_execution_failed', +} as const; diff --git a/packages/shared/src/protect-check/executeProtectCheck.ts b/packages/shared/src/protect-check/executeProtectCheck.ts new file mode 100644 index 00000000000..ad3dba83c1b --- /dev/null +++ b/packages/shared/src/protect-check/executeProtectCheck.ts @@ -0,0 +1,159 @@ +import { ClerkRuntimeError } from '../error'; +import type { ProtectCheckResource } from '../types'; + +export interface ExecuteProtectCheckOptions { + /** + * Host-provided visibility handshake, forwarded to the script verbatim as + * `setWidgetVisible` in the init payload. The script calls it right before revealing UI in + * the container (and with `false` once its widget is done); the returned promise resolves + * only after the host has applied the change to the DOM (e.g. removed its own loading + * spinner), so the script can sequence its reveal without a frame of overlap. A script that + * knows its widget is imminent may call it immediately to avoid a spinner flash. Scripts + * must treat the field as optional — older hosts don't provide it. + */ + setWidgetVisible?: (visible: boolean) => Promise; + /** + * Signals that the caller no longer needs the proof token (component unmounted, user + * navigated away, etc.). When the signal aborts: + * - If the script has not yet been imported, `executeProtectCheck` rejects with + * `protect_check_aborted` without loading the script. + * - The signal is forwarded to the script as `{ signal }` in the second argument so + * cooperating SDKs can cancel any in-flight UI / network work. + * - Even if the script ignores the signal and resolves with a token, the helper + * re-checks `signal.aborted` after the await and rejects with `protect_check_aborted` + * so the caller never observes a "successful" abort. + * + * Scripts that don't honor the signal will continue to run; this is best-effort by design. + */ + signal?: AbortSignal; +} + +interface ScriptInitOptions { + token: string; + uiHints?: Record; + signal?: AbortSignal; + setWidgetVisible?: (visible: boolean) => Promise; +} + +type ScriptDefault = (container: HTMLDivElement, init: ScriptInitOptions) => Promise; + +/** + * Validates the `sdk_url` returned by the server before passing it to dynamic `import()`. + * + * Rejects: + * - Anything that fails URL parsing (relative paths, garbage strings) + * - Non-`https:` schemes — including `http:`, `data:`, `blob:`, `javascript:`. The server + * always returns an HTTPS URL, but the dynamic-import primitive accepts `data:`/`blob:` + * modules which would let a tampered response inject arbitrary code into the host page. + * - URLs containing credentials (`user:pass@host`) — phishing surface, no legitimate use. + * + * Throws `ClerkRuntimeError` with code `protect_check_invalid_sdk_url`. We deliberately do + * NOT silently strip an invalid `protect_check` from the resource: the gate must remain + * present so the user can't bypass it by manipulating the response. Fail-closed. + */ +function assertValidSdkUrl(sdkUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(sdkUrl); + } catch { + throw new ClerkRuntimeError('Protect check sdk_url is not a valid URL', { + code: 'protect_check_invalid_sdk_url', + }); + } + if (parsed.protocol !== 'https:') { + throw new ClerkRuntimeError('Protect check sdk_url must use HTTPS', { + code: 'protect_check_invalid_sdk_url', + }); + } + if (parsed.username || parsed.password) { + throw new ClerkRuntimeError('Protect check sdk_url must not contain credentials', { + code: 'protect_check_invalid_sdk_url', + }); + } + return parsed; +} + +/** + * Loads the Protect challenge SDK from `protectCheck.sdkUrl`, hands it the container element + * and the spec-defined init payload (`token`, `uiHints`, `signal`), and returns the proof + * token the SDK produces. + * + * The SDK script must: + * - Be a valid ES module served over HTTPS + * - Have a default export of the shape `(container, { token, uiHints, signal }) => Promise` + * - Honor the `signal` to abort any pending work (best-effort) + * + * Only the minimal fields (`token`, optional `ui_hints`) are surfaced to the script — the + * full sign-up/sign-in resource is intentionally NOT passed, to minimize the trust surface + * granted to third-party Protect scripts. + * + * Failure modes are surfaced as `ClerkRuntimeError` with one of: + * - `protect_check_invalid_sdk_url` — URL fails the safety checks above + * - `protect_check_aborted` — caller aborted before or during execution + * - `protect_check_script_load_failed` — network error, CSP block, or invalid module + * - `protect_check_invalid_script` — module loaded but no callable default export + * - `protect_check_execution_failed` — the script's default export threw + */ +export async function executeProtectCheck( + protectCheck: Pick, + container: HTMLDivElement, + options: ExecuteProtectCheckOptions = {}, +): Promise { + const { signal, setWidgetVisible } = options; + const { sdkUrl, token, uiHints } = protectCheck; + + const validated = assertValidSdkUrl(sdkUrl); + + if (signal?.aborted) { + throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); + } + + let mod: Record; + try { + mod = await import(/* webpackIgnore: true */ validated.toString()); + } catch { + // Surface a generic message and deliberately omit the original error: Chromium/Firefox embed + // the sdk_url in the dynamic-import failure text, which a tampered response could plant in the UI. + throw new ClerkRuntimeError( + 'Protect check script failed to load. This is commonly caused by a Content Security ' + + 'Policy that blocks the script origin (add it to your script-src directive), a ' + + 'network error, or an invalid module.', + { code: 'protect_check_script_load_failed' }, + ); + } + + if (signal?.aborted) { + throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); + } + + if (typeof mod.default !== 'function') { + throw new ClerkRuntimeError('Protect check script does not export a default function', { + code: 'protect_check_invalid_script', + }); + } + + let proofToken: string; + try { + proofToken = await (mod.default as ScriptDefault)(container, { token, uiHints, signal, setWidgetVisible }); + } catch (err) { + // Distinguish abort-induced rejections from genuine script errors: only relabel as + // `protect_check_aborted` when the error looks like an abort (`AbortError`), otherwise + // surface the script's actual failure so production diagnostics aren't masked. + const looksLikeAbort = err instanceof Error && err.name === 'AbortError'; + if (signal?.aborted && looksLikeAbort) { + throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); + } + const original = err instanceof Error ? err.message : String(err); + throw new ClerkRuntimeError(`Protect check script execution failed: ${original}`, { + code: 'protect_check_execution_failed', + }); + } + + // The script may have ignored the signal and resolved with a token after the abort fired. + // Re-check here so callers get a consistent contract: if you aborted, you never see a token. + if (signal?.aborted) { + throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); + } + + return proofToken; +} diff --git a/packages/shared/src/protect-check/index.ts b/packages/shared/src/protect-check/index.ts new file mode 100644 index 00000000000..89848b015b2 --- /dev/null +++ b/packages/shared/src/protect-check/index.ts @@ -0,0 +1,11 @@ +export { executeProtectCheck } from './executeProtectCheck'; +export type { ExecuteProtectCheckOptions } from './executeProtectCheck'; +export { + executeProtectCheckWithTimeout, + isProtectCheckExpired, + MAX_EXPIRED_RELOADS, + PROTECT_CHECK_SCRIPT_TIMEOUT_MS, + submitProtectCheckProof, +} from './lifecycle'; +export type { ExecuteProtectCheckWithTimeoutOptions, SubmitProtectCheckProofResult } from './lifecycle'; +export { PROTECT_CHECK_ERROR_CODES } from './errors'; diff --git a/packages/shared/src/protect-check/lifecycle.ts b/packages/shared/src/protect-check/lifecycle.ts new file mode 100644 index 00000000000..e00640853d0 --- /dev/null +++ b/packages/shared/src/protect-check/lifecycle.ts @@ -0,0 +1,141 @@ +import { ClerkRuntimeError, isClerkAPIResponseError } from '../error'; +import type { ProtectCheckResource } from '../types'; +import { PROTECT_CHECK_ERROR_CODES } from './errors'; +import type { ExecuteProtectCheckOptions } from './executeProtectCheck'; +import { executeProtectCheck } from './executeProtectCheck'; + +/** Default upper bound on how long we wait for the challenge SDK to settle before failing loud. */ +export const PROTECT_CHECK_SCRIPT_TIMEOUT_MS = 60_000; + +/** + * A plain GET reload does not re-mint a protect_check challenge server-side, so an expired + * challenge would otherwise reload → still expired → reload again, forever. Callers that + * reload on expiry must cap their attempts at this and surface an error instead of spinning + * silently. + * + * NOTE: who re-mints an expired challenge on read (FAPI vs. re-running the gated step) is still + * being decided with the clerk_go team; this cap is the defensive floor until that lands. + */ +export const MAX_EXPIRED_RELOADS = 2; + +/** Whether the challenge expired client-side. `expiresAt` is unix milliseconds. */ +export function isProtectCheckExpired(protectCheck: Pick): boolean { + return protectCheck.expiresAt !== undefined && protectCheck.expiresAt < Date.now(); +} + +export interface ExecuteProtectCheckWithTimeoutOptions extends ExecuteProtectCheckOptions { + /** Overrides the `PROTECT_CHECK_SCRIPT_TIMEOUT_MS` default. */ + timeoutMs?: number; +} + +/** + * `executeProtectCheck` wrapped with the lifecycle guarantees a host needs to run a challenge + * safely: + * + * - The container is cleared first: this run owns it outright, so a solved or errored widget + * from a previous run can't sit under (or stack with) the new one. + * - The whole run races a timeout (default {@link PROTECT_CHECK_SCRIPT_TIMEOUT_MS}); on + * timeout the (possibly hung) SDK is aborted and a retryable `protect_check_timed_out` + * `ClerkRuntimeError` is thrown. + * - The abort contract is best-effort, so a zombie script from a timed-out run can still call + * `setWidgetVisible` late — those signals are swallowed here and never reach the caller. + * + * The caller's `signal` is linked one-way into the run: aborting it aborts the SDK, but a + * timeout does not abort the caller's controller. + */ +export async function executeProtectCheckWithTimeout( + protectCheck: Pick, + container: HTMLDivElement, + options: ExecuteProtectCheckWithTimeoutOptions = {}, +): Promise { + const { signal, setWidgetVisible, timeoutMs = PROTECT_CHECK_SCRIPT_TIMEOUT_MS } = options; + + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + const controller = new AbortController(); + const onCallerAbort = () => controller.abort(); + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener('abort', onCallerAbort, { once: true }); + } + } + + const guardedSetWidgetVisible = setWidgetVisible + ? (visible: boolean): Promise => { + if (controller.signal.aborted) { + return Promise.resolve(); + } + return setWidgetVisible(visible); + } + : undefined; + + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + executeProtectCheck(protectCheck, container, { + signal: controller.signal, + setWidgetVisible: guardedSetWidgetVisible, + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort(); + reject( + new ClerkRuntimeError('Protect verification timed out', { + code: PROTECT_CHECK_ERROR_CODES.TIMED_OUT, + }), + ); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + signal?.removeEventListener('abort', onCallerAbort); + } +} + +export type SubmitProtectCheckProofResult = + | { status: 'submitted'; resource: TResource } + /** The server had already moved past this gate; `resource` is the live resource after a reload. */ + | { status: 'already_resolved'; resource: TResource } + /** `isCancelled` reported true while recovering from a submit failure; nothing further ran. */ + | { status: 'cancelled' }; + +/** + * Submits a proof token and absorbs the one submit failure that is actually a success: + * `protect_check_already_resolved` means the server's state has already moved past this gate, + * so the resource is reloaded to clear the stale local `protectCheck` and returned for the + * caller to route on. Every other failure is rethrown untouched. + */ +export async function submitProtectCheckProof(params: { + proofToken: string; + submitProtectCheck: (params: { proofToken: string }) => Promise; + /** Reloads the underlying resource (GET) to pick up fresh server state. */ + reload: () => Promise; + /** Returns the live resource, used to route after a reload (which mutates it in place). */ + getResource: () => TResource; + /** Lets the caller bail out of the recovery path when its context has gone away. */ + isCancelled?: () => boolean; +}): Promise> { + const { proofToken, submitProtectCheck, reload, getResource, isCancelled = () => false } = params; + + let resource: TResource; + try { + resource = await submitProtectCheck({ proofToken }); + } catch (err) { + if (isCancelled()) { + return { status: 'cancelled' }; + } + if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === PROTECT_CHECK_ERROR_CODES.ALREADY_RESOLVED) { + await reload(); + return { status: 'already_resolved', resource: getResource() }; + } + throw err; + } + return { status: 'submitted', resource }; +} diff --git a/packages/shared/tsdown.config.mts b/packages/shared/tsdown.config.mts index b01ed53ab33..fcb47e7ddba 100644 --- a/packages/shared/tsdown.config.mts +++ b/packages/shared/tsdown.config.mts @@ -31,6 +31,7 @@ export default defineConfig(({ watch, env }) => { './src/dom/*.ts', './src/ui/index.ts', './src/keyless/index.ts', + './src/protect-check/index.ts', './src/internal/clerk-js/*.ts', './src/internal/clerk-js/**/*.ts', '!./src/**/*.{test,spec}.{ts,tsx}', diff --git a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx index 1c60b1187d4..8455cbd3f44 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx @@ -8,15 +8,18 @@ import { fireEvent, render } from '@/test/utils'; import { SignInProtectCheck } from '../SignInProtectCheck'; -vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({ - executeProtectCheck: vi.fn(), +// Only the script execution is mocked; `submitProtectCheckProof` stays real so the +// already-resolved recovery path is exercised end-to-end. +vi.mock('@clerk/shared/protect-check', async importOriginal => ({ + ...(await importOriginal()), + executeProtectCheckWithTimeout: vi.fn(), })); -import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck'; +import { executeProtectCheckWithTimeout } from '@clerk/shared/protect-check'; const { createFixtures } = bindCreateFixtures('SignIn'); -const mockExecute = executeProtectCheck as unknown as ReturnType; +const mockExecute = executeProtectCheckWithTimeout as unknown as ReturnType; beforeEach(() => { mockExecute.mockReset(); diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx index 00e891c7853..e686eba2b1a 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx @@ -9,15 +9,18 @@ import { fireEvent, render } from '@/test/utils'; import { SignUp } from '../index'; import { SignUpProtectCheck } from '../SignUpProtectCheck'; -vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({ - executeProtectCheck: vi.fn(), +// Only the script execution is mocked; `submitProtectCheckProof` stays real so the +// already-resolved recovery path is exercised end-to-end. +vi.mock('@clerk/shared/protect-check', async importOriginal => ({ + ...(await importOriginal()), + executeProtectCheckWithTimeout: vi.fn(), })); -import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck'; +import { executeProtectCheckWithTimeout } from '@clerk/shared/protect-check'; const { createFixtures } = bindCreateFixtures('SignUp'); -const mockExecute = executeProtectCheck as unknown as ReturnType; +const mockExecute = executeProtectCheckWithTimeout as unknown as ReturnType; beforeEach(() => { mockExecute.mockReset(); diff --git a/packages/ui/src/hooks/useProtectCheckRunner.ts b/packages/ui/src/hooks/useProtectCheckRunner.ts index 0dce8df171a..3f651503b2d 100644 --- a/packages/ui/src/hooks/useProtectCheckRunner.ts +++ b/packages/ui/src/hooks/useProtectCheckRunner.ts @@ -1,4 +1,4 @@ -import { ClerkRuntimeError, isClerkAPIResponseError } from '@clerk/shared/error'; +import { ClerkRuntimeError } from '@clerk/shared/error'; import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import type { ProtectCheckResource } from '@clerk/shared/types'; import React from 'react'; @@ -8,18 +8,12 @@ import { useCardState } from '@/ui/elements/contexts'; import { handleError } from '@/ui/utils/errorHandler'; /** - * A plain GET reload does not re-mint a protect_check challenge server-side, so an expired - * challenge would otherwise reload → still expired → reload again, forever. Cap the attempts - * and surface an error instead of spinning silently. - * - * NOTE: who re-mints an expired challenge on read (FAPI vs. re-running the gated step) is still - * being decided with the clerk_go team; this cap is the defensive floor until that lands. + * Mirrors `MAX_EXPIRED_RELOADS` from `@clerk/shared/protect-check`. Kept as a local literal + * because it is needed before (and outside) the RHC-gated dynamic import below — a static value + * import of that entry would drag the challenge loader back into no-RHC bundles. */ const MAX_EXPIRED_RELOADS = 2; -/** Upper bound on how long we wait for the challenge SDK to settle before failing loud. */ -const PROTECT_CHECK_SCRIPT_TIMEOUT_MS = 60_000; - export interface ProtectCheckRunnerParams { /** * Reads the current protect_check off the resource. Called fresh on each effect run because @@ -61,7 +55,9 @@ export interface ProtectCheckRunner { * Shared driver for the `` and `` cards. Both run the * exact same lifecycle — load + execute the Protect SDK, submit the proof token, continue the flow * — so the abort/cancel/expiry/timeout/no-RHC handling lives here once instead of being duplicated - * (and drifting) across the two components. + * (and drifting) across the two components. The framework-free parts of that lifecycle (timeout + * race, container ownership, `already_resolved` recovery) live in `@clerk/shared/protect-check`; + * this hook owns the React orchestration around them. * * Must be called from within a `CardStateProvider`. */ @@ -141,8 +137,8 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam // Fail closed in no-RHC builds (chrome extension / clerk.no-rhc.js): the gate requires a // remote `import(sdk_url)` we must not perform there. This guard MUST live in the component - // layer — `executeProtectCheck` is in `@clerk/shared`, compiled once with the flag hard-coded - // `false`, so a guard there would never trip. + // layer — `@clerk/shared/protect-check` is compiled once with the flag hard-coded `false`, + // so a guard there would never trip. if (__BUILD_DISABLE_RHC__) { failWith( ERROR_CODES.PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT, @@ -205,9 +201,11 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam // This run owns the container outright: drop anything a previous run left behind (a solved or // errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a - // second widget under a stale one. Reset visibility in the same breath — the container is - // empty by construction here, and waiting on the observer callback would leave the state - // stale for a scheduling-dependent window (especially on the MutationObserver fallback). + // second widget under a stale one — synchronously, before the chunk import below can add a + // frame of stale widget. (`executeProtectCheckWithTimeout` clears again; that's idempotent.) + // Reset visibility in the same breath — the container is empty by construction here, and + // waiting on the observer callback would leave the state stale for a scheduling-dependent + // window (especially on the MutationObserver fallback). while (container.firstChild) { container.removeChild(container.firstChild); } @@ -217,9 +215,8 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam setIsRunning(true); const runChallenge = async () => { - let timeoutId: ReturnType | undefined; try { - // Load the Protect SDK loader lazily, gated on the same compile-time flag as the + // Load the Protect check module lazily, gated on the same compile-time flag as the // fail-closed guard above. In no-RHC builds `__BUILD_DISABLE_RHC__` is `true`, so this // branch (and the dynamic `import()` below it) is dead-code-eliminated — the loader and // its remote `import(sdk_url)` are tree-shaken out of those bundles entirely rather than @@ -227,58 +224,32 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam if (__BUILD_DISABLE_RHC__) { return; } - const { executeProtectCheck } = await import('@clerk/shared/internal/clerk-js/protectCheck'); - const proofToken = await Promise.race([ - executeProtectCheck(protectCheck, container, { signal: abortController.signal, setWidgetVisible }), - new Promise((_, reject) => { - timeoutId = setTimeout(() => { - // Stop the (possibly hung) SDK and surface a retryable timeout error. - abortController.abort(); - reject( - new ClerkRuntimeError('Protect verification timed out', { - code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT, - }), - ); - }, PROTECT_CHECK_SCRIPT_TIMEOUT_MS); - }), - ]); + const { executeProtectCheckWithTimeout, submitProtectCheckProof } = await import('@clerk/shared/protect-check'); + const proofToken = await executeProtectCheckWithTimeout(protectCheck, container, { + signal: abortController.signal, + setWidgetVisible, + }); if (cancelled) { return; } - let updatedResource: TResource; - try { - updatedResource = await submitProtectCheck({ proofToken }); - } catch (err) { - if (cancelled) { - return; - } - // `protect_check_already_resolved` is retry-safe: the server's state has already moved - // past this gate. Reload to clear the stale local protectCheck, then continue routing on - // the refreshed live resource. - if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED) { - await reload(); - if (isUnmounted()) { - return; - } - await onResolved(getResource(), isUnmounted); - return; - } - throw err; - } - if (isUnmounted()) { + const result = await submitProtectCheckProof({ + proofToken, + submitProtectCheck, + reload, + getResource, + isCancelled: () => cancelled, + }); + if (result.status === 'cancelled' || isUnmounted()) { return; } - await onResolved(updatedResource, isUnmounted); + await onResolved(result.resource, isUnmounted); } catch (err: any) { if (cancelled) { return; } handleError(err, [], card.setError); } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } if (!cancelled) { isRunningRef.current = false; setIsRunning(false);