diff --git a/.changeset/managed-protect-check-gate.md b/.changeset/managed-protect-check-gate.md new file mode 100644 index 00000000000..e18be7533b8 --- /dev/null +++ b/.changeset/managed-protect-check-gate.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +clerk-js now resolves Clerk Protect challenges (`protect_check`) automatically in apps built on custom flows: when a sign-in or sign-up call is gated, the challenge runs in a Clerk-managed modal (or inline, when a `
` placement element is present) and the original call resolves with the post-challenge state. Prebuilt components keep their existing inline challenge experience. No action is required from applications; the behavior activates only for instances where Clerk Protect challenges are enabled. diff --git a/.changeset/protect-check-marker-constant.md b/.changeset/protect-check-marker-constant.md new file mode 100644 index 00000000000..9e0d914c6c1 --- /dev/null +++ b/.changeset/protect-check-marker-constant.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Add the internal `PROTECT_CHECK_ELEMENT_ID` constant for the Protect challenge placement marker. Internal change; no public API changes. diff --git a/packages/clerk-js/src/core/__tests__/fraudProtection.protectCheck.test.ts b/packages/clerk-js/src/core/__tests__/fraudProtection.protectCheck.test.ts new file mode 100644 index 00000000000..0ea8f65bf18 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/fraudProtection.protectCheck.test.ts @@ -0,0 +1,69 @@ +import { PROTECT_CHECK_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; +import type { ProtectCheckJSON } from '@clerk/shared/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { FapiResponseJSON } from '../fapiClient'; +import { FraudProtection } from '../fraudProtection'; +import type { Clerk } from '../resources/internal'; + +vi.mock('@clerk/shared/internal/clerk-js/protectCheckLifecycle', async importOriginal => ({ + ...(await importOriginal()), + executeProtectCheckWithTimeout: vi.fn(), +})); + +import { executeProtectCheckWithTimeout } from '@clerk/shared/internal/clerk-js/protectCheckLifecycle'; + +const mockExecute = vi.mocked(executeProtectCheckWithTimeout); + +const gatedPayload = (): FapiResponseJSON => + ({ + response: { + object: 'sign_in', + id: 'si_wired', + status: 'needs_protect_check', + protect_check: { + status: 'pending', + token: 'challenge-token', + sdk_url: 'https://protect.example.com/sdk.js', + } satisfies ProtectCheckJSON, + }, + }) as FapiResponseJSON; + +afterEach(() => { + document.body.innerHTML = ''; + mockExecute.mockReset(); +}); + +describe('FraudProtection × ProtectCheckGate wiring', () => { + it('resolves a gated payload through the gate when a raw fetch is provided', async () => { + // Inline marker host: keeps the wiring test free of modal plumbing. + const marker = document.createElement('div'); + marker.id = PROTECT_CHECK_ELEMENT_ID; + document.body.appendChild(marker); + + mockExecute.mockResolvedValue('proof-wired'); + const resolved = { response: { object: 'sign_in', id: 'si_wired', status: 'complete', protect_check: null } }; + const rawFetch = vi.fn(() => Promise.resolve(resolved as FapiResponseJSON)); + + const result = await FraudProtection.getInstance().execute( + {} as unknown as Clerk, + () => Promise.resolve(gatedPayload()), + rawFetch, + ); + + expect(result).toBe(resolved); + expect(rawFetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: '/client/sign_ins/si_wired/protect_check', + body: { proof_token: 'proof-wired' }, + }); + }); + + it('returns payloads untouched when no raw fetch is provided (non-resource callers)', async () => { + const payload = gatedPayload(); + await expect( + FraudProtection.getInstance().execute({} as unknown as Clerk, () => Promise.resolve(payload)), + ).resolves.toBe(payload); + expect(mockExecute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/protectCheckGate.test.ts b/packages/clerk-js/src/core/__tests__/protectCheckGate.test.ts new file mode 100644 index 00000000000..2a17689995c --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/protectCheckGate.test.ts @@ -0,0 +1,437 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { ProtectCheckJSON } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FapiResponseJSON } from '../fapiClient'; +import type { RawResourceFetch } from '../protectCheckGate'; +import { + findPendingProtectCheck, + PROTECT_CHECK_MODAL_CONTAINER_ID, + PROTECT_CHECK_MODAL_WRAPPER_ID, + ProtectCheckGate, +} from '../protectCheckGate'; +import type { Clerk } from '../resources/internal'; + +vi.mock('@clerk/shared/internal/clerk-js/protectCheckLifecycle', async importOriginal => ({ + ...(await importOriginal()), + executeProtectCheckWithTimeout: vi.fn(), +})); + +import { executeProtectCheckWithTimeout } from '@clerk/shared/internal/clerk-js/protectCheckLifecycle'; + +const mockExecute = vi.mocked(executeProtectCheckWithTimeout); + +const checkJSON = (overrides: Partial = {}): ProtectCheckJSON => ({ + status: 'pending', + token: 'challenge-token', + sdk_url: 'https://protect.example.com/sdk.js', + ...overrides, +}); + +const signInPayload = (protect_check: ProtectCheckJSON | null, id = 'si_1'): FapiResponseJSON => + ({ + response: { + object: 'sign_in', + id, + status: protect_check ? 'needs_protect_check' : 'needs_first_factor', + protect_check, + }, + }) as FapiResponseJSON; + +const signUpPayload = (protect_check: ProtectCheckJSON | null, id = 'su_1'): FapiResponseJSON => + ({ + response: { object: 'sign_up', id, status: 'missing_requirements', protect_check }, + }) as FapiResponseJSON; + +const alreadyResolvedError = () => + new ClerkAPIResponseError('Already resolved', { + data: [{ code: 'protect_check_already_resolved', message: 'Already resolved', long_message: '' }], + status: 400, + clerkTraceId: 'trace_123', + }); + +/** + * Fake modal host: `open` mounts the wrapper + container ids the gate queries, `close` removes + * them — the contract the ui package's ProtectCheckModal will fulfil. + */ +const makeClerk = () => { + const open = vi.fn(() => { + const wrapper = document.createElement('div'); + wrapper.id = PROTECT_CHECK_MODAL_WRAPPER_ID; + wrapper.style.visibility = 'hidden'; + const container = document.createElement('div'); + container.id = PROTECT_CHECK_MODAL_CONTAINER_ID; + wrapper.appendChild(container); + document.body.appendChild(wrapper); + return Promise.resolve(); + }); + const close = vi.fn(() => { + document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.remove(); + return Promise.resolve(); + }); + return { + clerk: { + __internal_openProtectCheckModal: open, + __internal_closeProtectCheckModal: close, + } as unknown as Clerk, + open, + close, + }; +}; + +const makeRawFetch = (handlers: { + onPatch?: (path: string, body: unknown) => FapiResponseJSON | Promise>; + onGet?: (path: string) => FapiResponseJSON | Promise>; +}) => + vi.fn(async (init: { method: 'GET' | 'PATCH'; path: string; body?: unknown }) => { + if (init.method === 'PATCH') { + if (!handlers.onPatch) { + throw new Error(`unexpected PATCH ${init.path}`); + } + return handlers.onPatch(init.path, init.body); + } + if (!handlers.onGet) { + throw new Error(`unexpected GET ${init.path}`); + } + return handlers.onGet(init.path); + }) as unknown as RawResourceFetch & ReturnType; + +beforeEach(() => { + mockExecute.mockReset(); +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('findPendingProtectCheck', () => { + it('detects a pending check on a direct sign-in response', () => { + expect(findPendingProtectCheck(signInPayload(checkJSON()))).toEqual({ + flow: 'signIn', + id: 'si_1', + check: { + status: 'pending', + token: 'challenge-token', + sdkUrl: 'https://protect.example.com/sdk.js', + expiresAt: undefined, + uiHints: undefined, + }, + }); + }); + + it('detects a pending check on a direct sign-up response', () => { + expect(findPendingProtectCheck(signUpPayload(checkJSON()))?.flow).toBe('signUp'); + }); + + it.each([ + ['null payload', null], + ['non-auth response', { response: { object: 'client', id: 'c_1' } } as FapiResponseJSON], + ['no protect_check', signInPayload(null)], + ['completed protect_check', signInPayload(checkJSON({ status: 'completed' as ProtectCheckJSON['status'] }))], + [ + 'client-nested check only (belongs to another call)', + { + response: { + object: 'client', + id: 'c_1', + sign_in: { object: 'sign_in', id: 'si_1', protect_check: checkJSON() }, + }, + } as unknown as FapiResponseJSON, + ], + ])('ignores %s', (_label, payload) => { + expect(findPendingProtectCheck(payload)).toBeNull(); + }); +}); + +describe('ProtectCheckGate.process', () => { + it('passes non-gated payloads through untouched', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + const payload = signInPayload(null); + const rawFetch = makeRawFetch({}); + + await expect(gate.process(clerk, payload, () => Promise.resolve(payload), rawFetch)).resolves.toBe(payload); + expect(open).not.toHaveBeenCalled(); + expect(rawFetch).not.toHaveBeenCalled(); + }); + + it('passes gated payloads through untouched while a host is registered for the flow', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + const payload = signInPayload(checkJSON()); + const dispose = gate.registerHost('signIn'); + + await expect(gate.process(clerk, payload, () => Promise.resolve(payload), makeRawFetch({}))).resolves.toBe(payload); + expect(open).not.toHaveBeenCalled(); + + dispose(); + dispose(); // double-dispose must not underflow + expect(gate.hasRegisteredHost('signIn')).toBe(false); + }); + + it('a registered sign-up host does not suppress sign-in handling', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + gate.registerHost('signUp'); + mockExecute.mockResolvedValue('proof-1'); + const resolved = signInPayload(null); + const rawFetch = makeRawFetch({ onPatch: () => resolved }); + + await expect( + gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch), + ).resolves.toBe(resolved); + expect(open).toHaveBeenCalledTimes(1); + }); + + it('resolves a gated sign-in through the managed modal: execute → PATCH → post-challenge payload', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open, close } = makeClerk(); + mockExecute.mockResolvedValue('proof-1'); + const resolved = signInPayload(null); + const rawFetch = makeRawFetch({ onPatch: () => resolved }); + + const result = await gate.process( + clerk, + signInPayload(checkJSON()), + () => Promise.resolve(signInPayload(null)), + rawFetch, + ); + + expect(result).toBe(resolved); + expect(open).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ token: 'challenge-token', sdkUrl: 'https://protect.example.com/sdk.js' }), + expect.any(HTMLElement), + expect.objectContaining({ setWidgetVisible: expect.any(Function) }), + ); + expect((mockExecute.mock.calls[0][1] as HTMLElement).id).toBe(PROTECT_CHECK_MODAL_CONTAINER_ID); + expect(rawFetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: '/client/sign_ins/si_1/protect_check', + body: { proof_token: 'proof-1' }, + }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('uses the sign-up endpoints for gated sign-ups', async () => { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + mockExecute.mockResolvedValue('proof-su'); + const resolved = signUpPayload(null); + const rawFetch = makeRawFetch({ onPatch: () => resolved }); + + await gate.process(clerk, signUpPayload(checkJSON()), () => Promise.resolve(signUpPayload(null)), rawFetch); + + expect(rawFetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: '/client/sign_ups/su_1/protect_check', + body: { proof_token: 'proof-su' }, + }); + }); + + it('runs inline into the clerk-protect-check placement marker instead of the modal', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + const marker = document.createElement('div'); + marker.id = 'clerk-protect-check'; + document.body.appendChild(marker); + mockExecute.mockResolvedValue('proof-1'); + const resolved = signInPayload(null); + const rawFetch = makeRawFetch({ onPatch: () => resolved }); + + await gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch); + + expect(open).not.toHaveBeenCalled(); + expect(mockExecute.mock.calls[0][1]).toBe(marker); + }); + + it('loops chained challenges inside one host session', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open, close } = makeClerk(); + mockExecute.mockResolvedValueOnce('proof-1').mockResolvedValueOnce('proof-2'); + const chained = signInPayload(checkJSON({ token: 'challenge-token-2' })); + const resolved = signInPayload(null); + let patchCount = 0; + const rawFetch = makeRawFetch({ onPatch: () => (++patchCount === 1 ? chained : resolved) }); + + const result = await gate.process( + clerk, + signInPayload(checkJSON()), + () => Promise.resolve(signInPayload(null)), + rawFetch, + ); + + expect(result).toBe(resolved); + expect(mockExecute).toHaveBeenCalledTimes(2); + expect(mockExecute.mock.calls[1][0]).toEqual(expect.objectContaining({ token: 'challenge-token-2' })); + expect(open).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('gives up on a never-ending challenge chain and closes the host', async () => { + const gate = new ProtectCheckGate(); + const { clerk, close } = makeClerk(); + let n = 0; + mockExecute.mockImplementation(() => Promise.resolve(`proof-${n}`)); + const rawFetch = makeRawFetch({ onPatch: () => signInPayload(checkJSON({ token: `challenge-token-${++n}` })) }); + + await expect( + gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch), + ).rejects.toMatchObject({ code: 'protect_check_execution_failed' }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('treats protect_check_already_resolved as soft success: reloads and returns the live payload', async () => { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + mockExecute.mockResolvedValue('proof-1'); + const live = signInPayload(null); + const rawFetch = makeRawFetch({ + onPatch: () => { + throw alreadyResolvedError(); + }, + onGet: () => live, + }); + + const result = await gate.process( + clerk, + signInPayload(checkJSON()), + () => Promise.resolve(signInPayload(null)), + rawFetch, + ); + + expect(result).toBe(live); + expect(rawFetch).toHaveBeenCalledWith( + { method: 'GET', path: '/client/sign_ins/si_1' }, + { forceUpdateClient: true }, + ); + }); + + it('reloads an expired challenge before running and uses the re-minted check', async () => { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + mockExecute.mockResolvedValue('proof-fresh'); + const reMinted = signInPayload(checkJSON({ token: 'challenge-token-fresh', expires_at: Date.now() + 60_000 })); + const resolved = signInPayload(null); + const rawFetch = makeRawFetch({ onGet: () => reMinted, onPatch: () => resolved }); + + const result = await gate.process( + clerk, + signInPayload(checkJSON({ expires_at: Date.now() - 1_000 })), + () => Promise.resolve(signInPayload(null)), + rawFetch, + ); + + expect(result).toBe(resolved); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockExecute.mock.calls[0][0]).toEqual(expect.objectContaining({ token: 'challenge-token-fresh' })); + }); + + it('fails with protect_check_timed_out when the server keeps returning an expired challenge', async () => { + const gate = new ProtectCheckGate(); + const { clerk, close } = makeClerk(); + const rawFetch = makeRawFetch({ onGet: () => signInPayload(checkJSON({ expires_at: Date.now() - 1_000 })) }); + + await expect( + gate.process( + clerk, + signInPayload(checkJSON({ expires_at: Date.now() - 1_000 })), + () => Promise.resolve(signInPayload(null)), + rawFetch, + ), + ).rejects.toMatchObject({ code: 'protect_check_timed_out' }); + expect(mockExecute).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('propagates challenge failures and closes the host', async () => { + const gate = new ProtectCheckGate(); + const { clerk, close } = makeClerk(); + mockExecute.mockRejectedValue( + Object.assign(new Error('load failed'), { code: 'protect_check_script_load_failed' }), + ); + + await expect( + gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), makeRawFetch({})), + ).rejects.toMatchObject({ code: 'protect_check_script_load_failed' }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('single-flights concurrent gated calls: second waits, then replays instead of opening a second host', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + let resolveProof: (token: string) => void = () => undefined; + mockExecute.mockImplementationOnce( + () => + new Promise(resolve => { + resolveProof = resolve; + }), + ); + const resolved = signInPayload(null); + const rawFetch = makeRawFetch({ onPatch: () => resolved }); + + const first = gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch); + await vi.waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + + const replaidPayload = signInPayload(null, 'si_2'); + const replay = vi.fn(() => Promise.resolve(replaidPayload)); + const second = gate.process(clerk, signInPayload(checkJSON(), 'si_2'), replay, rawFetch); + + resolveProof('proof-1'); + await expect(first).resolves.toBe(resolved); + await expect(second).resolves.toBe(replaidPayload); + expect(replay).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledTimes(1); + }); + + it('flips the modal wrapper visible when the script announces its widget', async () => { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + let capturedSetWidgetVisible: ((visible: boolean) => Promise) | undefined; + mockExecute.mockImplementation(async (_check, _container, opts) => { + capturedSetWidgetVisible = opts?.setWidgetVisible; + await opts?.setWidgetVisible?.(true); + return 'proof-1'; + }); + const resolved = signInPayload(null); + const rawFetch = makeRawFetch({ + onPatch: () => { + // Wrapper must already be visible by the time the proof is submitted. + expect(document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.style.visibility).toBe('visible'); + return resolved; + }, + }); + + await gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch); + expect(capturedSetWidgetVisible).toBeDefined(); + }); + + it('reveals a still-running modal after the delay so long solves are not an invisible frozen page', async () => { + vi.useFakeTimers(); + try { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + let resolveProof: (token: string) => void = () => undefined; + mockExecute.mockImplementationOnce( + () => + new Promise(resolve => { + resolveProof = resolve; + }), + ); + const resolved = signInPayload(null); + const rawFetch = makeRawFetch({ onPatch: () => resolved }); + + const run = gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch); + await vi.waitFor(() => expect(mockExecute).toHaveBeenCalled()); + expect(document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.style.visibility).toBe('hidden'); + + await vi.advanceTimersByTimeAsync(500); + expect(document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.style.visibility).toBe('visible'); + + resolveProof('proof-1'); + await expect(run).resolves.toBe(resolved); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 0622261d9af..4abaf229116 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -192,6 +192,7 @@ import { createCheckoutInstance } from './modules/checkout/instance'; import { OAuthApplication } from './modules/oauthApplication'; import { Protect } from './protect'; import { protectAssertionParams } from './protectAssertion'; +import { ProtectCheckGate } from './protectCheckGate'; import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal'; import { State } from './state'; @@ -962,6 +963,24 @@ export class Clerk implements ClerkInterface { return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('blankCaptcha')); }; + public __internal_openProtectCheckModal = (): Promise => { + this.assertComponentsReady(this.#clerkUI); + return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.openModal('protectCheck', {})); + }; + + public __internal_closeProtectCheckModal = (): Promise => { + this.assertComponentsReady(this.#clerkUI); + return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('protectCheck')); + }; + + /** + * Lets a mounted surface that renders Protect challenges itself (prebuilt components, the + * inline marker component) suspend managed challenge handling for its flow. Returns a disposer. + */ + public __internal_registerProtectCheckHost = (flow: 'signIn' | 'signUp'): (() => void) => { + return ProtectCheckGate.getInstance().registerHost(flow); + }; + public __internal_loadStripeJs = async () => { if (__BUILD_DISABLE_RHC__) { clerkUnsupportedEnvironmentWarning('Stripe'); diff --git a/packages/clerk-js/src/core/fraudProtection.ts b/packages/clerk-js/src/core/fraudProtection.ts index 8dcac4aebed..73688ceefda 100644 --- a/packages/clerk-js/src/core/fraudProtection.ts +++ b/packages/clerk-js/src/core/fraudProtection.ts @@ -1,6 +1,8 @@ import { ClerkRuntimeError, isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; import { CaptchaChallenge } from '../utils/captcha/CaptchaChallenge'; +import type { RawResourceFetch } from './protectCheckGate'; +import { ProtectCheckGate } from './protectCheckGate'; import type { Clerk } from './resources/internal'; import { Client } from './resources/internal'; @@ -25,7 +27,22 @@ export class FraudProtection { ) {} // TODO @userland-errors: - public async execute Promise, R = Awaited>>(clerk: Clerk, cb: T): Promise { + public async execute Promise, R = Awaited>>( + clerk: Clerk, + cb: T, + rawFetch?: RawResourceFetch, + ): Promise { + // Managed Protect challenges ride successful payloads (HTTP 200 + pending `protect_check`), + // unlike the legacy captcha's error path, so every path that returns a result — including + // the post-captcha replays below — funnels through this gate check. + const run = async (): Promise => { + const result = await cb(); + if (!rawFetch) { + return result; + } + return (await ProtectCheckGate.getInstance().process(clerk, result, cb, rawFetch)) as R; + }; + // TODO @userland-errors: if (this.captchaAttemptsExceeded()) { throw new ClerkRuntimeError( @@ -39,7 +56,7 @@ export class FraudProtection { await this.inflightException; } - return await cb(); + return await run(); } catch (e) { if (!isClerkAPIResponseError(e)) { throw e; @@ -60,7 +77,7 @@ export class FraudProtection { await this.inflightException; // If this is resolved, it means the request finally resolved with 200 // so we can replay the original request - return await cb(); + return await run(); } // Otherwise, create a new placeholder promise to prevent other exceptions from being handled @@ -82,7 +99,7 @@ export class FraudProtection { this.inflightException = null; } - return await cb(); + return await run(); } } diff --git a/packages/clerk-js/src/core/protectCheckGate.ts b/packages/clerk-js/src/core/protectCheckGate.ts new file mode 100644 index 00000000000..bf8f5c65202 --- /dev/null +++ b/packages/clerk-js/src/core/protectCheckGate.ts @@ -0,0 +1,307 @@ +import { waitForElement } from '@clerk/shared/dom'; +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { ERROR_CODES, PROTECT_CHECK_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; +import type { ProtectCheckJSON, ProtectCheckResource } from '@clerk/shared/types'; + +import type { FapiResponseJSON } from './fapiClient'; +import type { Clerk } from './resources/internal'; + +export const PROTECT_CHECK_MODAL_WRAPPER_ID = 'cl-modal-protect-check-wrapper'; +export const PROTECT_CHECK_MODAL_CONTAINER_ID = 'cl-modal-protect-check-container'; + +/** + * The managed modal opens invisible so a challenge that resolves without interaction never + * flashes UI (same posture as the captcha modal). Unlike captcha, a challenge can legitimately + * run for a while (proof-of-transfer), so a still-running check reveals the modal after this + * delay instead of leaving the page frozen with nothing visible. + */ +const MODAL_REVEAL_DELAY_MS = 500; + +/** + * Chained challenges are an SDK-side loop (the PATCH response may carry a fresh check). A + * server bug that chains forever must not trap the user in the modal. + */ +const MAX_CHAINED_CHALLENGES = 5; + +/** + * Rounds of await-another-session-then-replay per gated call. Replays re-enter the gate, so a + * pathological server that re-gates every replay must not loop forever; past the cap the gated + * payload is returned as-is, surfacing the documented `needs_protect_check` state instead of an + * opaque failure. + */ +const MAX_GATED_ROUNDS = 3; + +type ProtectFlow = 'signIn' | 'signUp'; + +/** + * Raw resource fetch, provided by `BaseResource._fetch` so the gate's own PATCH/GET calls get + * the exact semantics of any resource call (client piggyback updates, ClerkAPIResponseError on + * 4xx) without re-entering FraudProtection. + */ +export type RawResourceFetch = ( + requestInit: { method: 'GET' | 'PATCH'; path: string; body?: unknown }, + opts?: { forceUpdateClient?: boolean }, +) => Promise | null>; + +interface GatedInfo { + flow: ProtectFlow; + id: string; + check: ProtectCheckResource; +} + +interface ChallengeHost { + container: HTMLDivElement; + setWidgetVisible?: (visible: boolean) => Promise; + close: () => void; +} + +type MaybeGatedResponse = { + object?: string; + id?: string; + protect_check?: ProtectCheckJSON | null; +}; + +function toProtectCheckResource(json: ProtectCheckJSON): ProtectCheckResource { + return { + status: json.status, + token: json.token, + sdkUrl: json.sdk_url, + expiresAt: json.expires_at, + uiHints: json.ui_hints, + }; +} + +/** + * A payload gates the calling request when its direct response is a sign-in/sign-up carrying a + * pending `protect_check`. Only the direct response is inspected: the gated call's own response + * is the authoritative signal, and reacting to the piggybacked `client` mirror would double-handle + * gates that belong to a different in-flight call. + */ +export function findPendingProtectCheck(payload: FapiResponseJSON | null): GatedInfo | null { + const response = payload?.response as MaybeGatedResponse | null | undefined; + if (!response || typeof response !== 'object') { + return null; + } + if (response.object !== 'sign_in' && response.object !== 'sign_up') { + return null; + } + if (!response.id || response.protect_check?.status !== 'pending') { + return null; + } + return { + flow: response.object === 'sign_in' ? 'signIn' : 'signUp', + id: response.id, + check: toProtectCheckResource(response.protect_check), + }; +} + +/** + * Resolves Protect challenges (`protect_check`) automatically so custom-flow apps never see the + * gate: when a resource call comes back gated, the challenge runs in a Clerk-owned host — the + * `clerk-protect-check` placement marker when the page provides one, a managed modal otherwise — + * the proof is submitted, and the post-challenge payload is returned as the original call's + * result. Prebuilt components (and any other surface that renders challenges itself) opt out by + * registering a host for their flow, in which case gated payloads pass through untouched. + * + * Mirrors `FraudProtection`'s posture for the legacy captcha: one challenge session at a time + * (concurrent gated calls wait, then replay), and the caller's promise is held for the duration. + */ +export class ProtectCheckGate { + private static instance: ProtectCheckGate; + + private hostCounts: Record = { signIn: 0, signUp: 0 }; + private inflightSession: Promise | null = null; + + public static getInstance(): ProtectCheckGate { + if (!ProtectCheckGate.instance) { + ProtectCheckGate.instance = new ProtectCheckGate(); + } + return ProtectCheckGate.instance; + } + + /** + * Declares that a mounted surface (prebuilt component, inline marker component) renders + * challenges for the given flow itself; managed handling stands down while any registration + * is live. Returns a disposer. + */ + public registerHost(flow: ProtectFlow): () => void { + this.hostCounts[flow] += 1; + let disposed = false; + return () => { + if (!disposed) { + disposed = true; + this.hostCounts[flow] -= 1; + } + }; + } + + public hasRegisteredHost(flow: ProtectFlow): boolean { + return this.hostCounts[flow] > 0; + } + + public async process(clerk: Clerk, payload: T, replay: () => Promise, rawFetch: RawResourceFetch): Promise { + let current = payload; + let rounds = 0; + + for (;;) { + const gated = findPendingProtectCheck(current as FapiResponseJSON | null); + if (!gated || this.hasRegisteredHost(gated.flow)) { + return current; + } + if (rounds >= MAX_GATED_ROUNDS) { + return current; + } + rounds += 1; + + if (this.inflightSession) { + // Another gated call owns the challenge UI. Wait it out (its failure is its caller's to + // surface), then replay: the stored proof on the attempt lets the replay pass without a + // second challenge. + await this.inflightSession.catch(() => undefined); + current = await replay(); + continue; + } + + const session = this.resolveGated(clerk, gated, rawFetch); + this.inflightSession = session.catch(() => undefined); + try { + current = (await session) as T; + } finally { + this.inflightSession = null; + } + } + } + + private async resolveGated( + clerk: Clerk, + gated: GatedInfo, + rawFetch: RawResourceFetch, + ): Promise | null> { + // Fail closed where the challenge cannot run: the gate requires a remote `import(sdk_url)` + // that no-RHC builds must not perform, and a DOM to host the widget. The guard lives here + // (not in the shared lifecycle module) because @clerk/shared compiles with the flag + // hard-coded `false`. + if (__BUILD_DISABLE_RHC__ || typeof document === 'undefined') { + throw new ClerkRuntimeError('Protect verification is not supported in this environment', { + code: ERROR_CODES.PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT, + }); + } + + const lifecycle = await import('@clerk/shared/internal/clerk-js/protectCheckLifecycle'); + const host = await this.acquireHost(clerk); + + const basePath = gated.flow === 'signIn' ? '/client/sign_ins' : '/client/sign_ups'; + const reload = () => rawFetch({ method: 'GET', path: `${basePath}/${gated.id}` }, { forceUpdateClient: true }); + const submit = (proofToken: string) => + rawFetch({ method: 'PATCH', path: `${basePath}/${gated.id}/protect_check`, body: { proof_token: proofToken } }); + + try { + let latest: FapiResponseJSON | null = null; + let check: ProtectCheckResource | null = gated.check; + let expiredReloads = 0; + let challengesRun = 0; + + while (check) { + if (lifecycle.isProtectCheckExpired(check)) { + if (expiredReloads >= lifecycle.MAX_EXPIRED_RELOADS) { + throw new ClerkRuntimeError('Protect verification expired', { + code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT, + }); + } + expiredReloads += 1; + latest = await reload(); + check = findPendingProtectCheck(latest)?.check ?? null; + continue; + } + + if (challengesRun >= MAX_CHAINED_CHALLENGES) { + throw new ClerkRuntimeError('Protect check chained challenge limit exceeded', { + code: 'protect_check_execution_failed', + }); + } + challengesRun += 1; + + const proofToken = await lifecycle.executeProtectCheckWithTimeout(check, host.container, { + setWidgetVisible: host.setWidgetVisible, + }); + + const result = await lifecycle.submitProtectCheckProof | null>({ + proofToken, + submitProtectCheck: ({ proofToken: token }) => submit(token), + reload: async () => { + latest = await reload(); + }, + getResource: () => latest, + }); + if (result.status === 'cancelled') { + break; + } + latest = result.resource; + check = findPendingProtectCheck(latest)?.check ?? null; + } + + return latest; + } finally { + host.close(); + } + } + + private async acquireHost(clerk: Clerk): Promise { + const marker = document.getElementById(PROTECT_CHECK_ELEMENT_ID); + if (marker) { + return { container: marker as HTMLDivElement, close: () => undefined }; + } + + try { + await clerk.__internal_openProtectCheckModal(); + } catch { + // Mirrors the captcha modal's components-not-ready race, but Protect cannot fail open — + // the server enforces the gate — so surface a runtime error instead of skipping. + throw new ClerkRuntimeError('Protect check UI failed to open', { + code: 'protect_check_execution_failed', + }); + } + + const container = await waitForElement(`#${PROTECT_CHECK_MODAL_CONTAINER_ID}`); + if (!container) { + void clerk.__internal_closeProtectCheckModal(); + throw new ClerkRuntimeError('Protect check UI failed to open', { + code: 'protect_check_execution_failed', + }); + } + + const setWrapperVisible = (visible: boolean) => { + const wrapper = document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID); + wrapper?.style.setProperty('visibility', visible ? 'visible' : 'hidden'); + wrapper?.style.setProperty('pointer-events', visible ? 'all' : 'none'); + }; + + // Reveal on the first of: the script announcing a visible widget, or the delay elapsing for + // a still-running (e.g. proof-of-transfer) check. A `false` counter-signal is ignored — the + // modal closes moments later on resolution, and re-hiding a revealed modal mid-submit reads + // as a glitch. + let revealed = false; + const reveal = () => { + if (!revealed) { + revealed = true; + setWrapperVisible(true); + } + }; + const revealTimer = setTimeout(reveal, MODAL_REVEAL_DELAY_MS); + + return { + container: container as HTMLDivElement, + setWidgetVisible: (visible: boolean) => { + if (visible) { + clearTimeout(revealTimer); + reveal(); + } + return Promise.resolve(); + }, + close: () => { + clearTimeout(revealTimer); + void clerk.__internal_closeProtectCheckModal(); + }, + }; + } +} diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 4ad63d5d01c..60998654a39 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -88,7 +88,14 @@ export abstract class BaseResource { requestInit: FapiRequestInit, opts: BaseFetchOptions = {}, ): Promise | null> { - return FraudProtection.getInstance().execute(this.clerk, () => this._baseFetch(requestInit, opts)); + return FraudProtection.getInstance().execute( + this.clerk, + () => this._baseFetch(requestInit, opts), + // Lets the managed Protect challenge gate issue its own PATCH/GET with full resource-call + // semantics (client piggyback updates, ClerkAPIResponseError on 4xx) without re-entering + // FraudProtection. + (init, o) => this._baseFetch(init as FapiRequestInit, o), + ); } // TODO @userland-errors: diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index c11db68f590..77c7f0069c0 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -70,3 +70,9 @@ export const SUPPORTED_FAPI_VERSION = '2026-05-12'; export const CAPTCHA_ELEMENT_ID = 'clerk-captcha'; export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha'; +/** + * Placement marker for Protect challenges, mirroring the `clerk-captcha` contract: when an + * element with this id exists, challenges render inline into it instead of the managed modal. + * The prebuilt protect-check cards use the same id for their container. + */ +export const PROTECT_CHECK_ELEMENT_ID = 'clerk-protect-check';