diff --git a/.changeset/protect-check-modal.md b/.changeset/protect-check-modal.md
new file mode 100644
index 00000000000..50a6a335d95
--- /dev/null
+++ b/.changeset/protect-check-modal.md
@@ -0,0 +1,8 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/ui': minor
+'@clerk/shared': patch
+'@clerk/react': patch
+---
+
+Resolve Clerk Protect challenges in custom sign-in and sign-up flows. When a request returns a `protect_check`, clerk-js opens Clerk's Protect UI in a modal over the page, runs the challenge, submits the proof, and then lets the original call return. Nothing needs to be rendered by the application. The prebuilt `` and `` components keep handling challenges inside their own cards.
diff --git a/integration/tests/protect-check.test.ts b/integration/tests/protect-check.test.ts
index 4e230ff9dee..9874be08247 100644
--- a/integration/tests/protect-check.test.ts
+++ b/integration/tests/protect-check.test.ts
@@ -12,6 +12,13 @@ const waitForProtectCheckSubmit = (page: Page) =>
{ timeout: 30_000 },
);
+const protectCheckModalSelector = '.cl-modalContent:has(.cl-protectCheck-root)';
+
+const waitForProtectCheckModal = (page: Page) =>
+ page.waitForFunction(selector => !!document.querySelector(selector), protectCheckModalSelector, {
+ timeout: 30_000,
+ });
+
test.describe('protect check @generic', () => {
test.describe.configure({ mode: 'serial' });
@@ -41,8 +48,10 @@ test.describe('protect check @generic', () => {
const protectCheckSubmit = waitForProtectCheckSubmit(page);
await u.po.signUp.goTo();
+ const protectCheckRoute = page.waitForURL(/protect-check/, { timeout: 30_000 });
await u.po.signUp.signUpWithEmailAndPassword({ email: fakeUser.email!, password: fakeUser.password });
+ await protectCheckRoute;
expect((await protectCheckSubmit).ok()).toBe(true);
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
@@ -55,14 +64,86 @@ test.describe('protect check @generic', () => {
const protectCheckSubmit = waitForProtectCheckSubmit(page);
await u.po.signIn.goTo();
+ const protectCheckRoute = page.waitForURL(/protect-check/, { timeout: 30_000 });
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeUser.email!,
password: fakeUser.password,
waitForSession: false,
});
+ await protectCheckRoute;
expect((await protectCheckSubmit).ok()).toBe(true);
await u.po.signIn.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
});
});
+
+test.describe('protect check in custom flows @custom', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let app: Application;
+ let fakeUser: FakeUser | undefined;
+
+ test.beforeAll(async () => {
+ test.setTimeout(150_000);
+ app = await appConfigs.customFlows.reactVite.clone().commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withProtectService);
+ await app.dev();
+ });
+
+ test.afterEach(async () => {
+ await fakeUser?.deleteIfExists();
+ fakeUser = undefined;
+ });
+
+ test.afterAll(async () => {
+ await app.teardown();
+ });
+
+ test('shows the Protect modal on sign-up', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ fakeUser = u.services.users.createFakeUser(test);
+ const protectCheckSubmit = waitForProtectCheckSubmit(page);
+ const prepareVerification = page.waitForResponse(
+ response => response.request().method() === 'POST' && response.url().includes('prepare_verification'),
+ { timeout: 30_000 },
+ );
+
+ await u.page.goToRelative('/sign-up');
+ await expect(u.page.getByText('Sign up', { exact: true })).toBeVisible();
+ const protectCheckModal = waitForProtectCheckModal(page);
+ await u.po.signUp.signUp({ email: fakeUser.email!, password: fakeUser.password });
+
+ expect((await protectCheckSubmit).ok()).toBe(true);
+ await protectCheckModal;
+ await page.locator(protectCheckModalSelector).waitFor({ state: 'detached' });
+ await prepareVerification;
+ await u.page.getByRole('textbox', { name: 'code' }).fill('424242');
+ await u.po.signUp.continue();
+ await u.page.waitForURL(/protected/);
+ await u.po.expect.toBeSignedIn();
+ });
+
+ test('shows the Protect modal on sign-in', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ fakeUser = u.services.users.createFakeUser(test);
+ await u.services.users.createBapiUser(fakeUser);
+ const protectCheckSubmit = waitForProtectCheckSubmit(page);
+
+ await u.page.goToRelative('/sign-in');
+ await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
+ const protectCheckModal = waitForProtectCheckModal(page);
+ await u.po.signIn.setIdentifier(fakeUser.email!);
+ await u.po.signIn.continue();
+
+ expect((await protectCheckSubmit).ok()).toBe(true);
+ await protectCheckModal;
+ await page.locator(protectCheckModalSelector).waitFor({ state: 'detached' });
+ await u.page.getByRole('button', { name: 'email_code', exact: true }).click();
+ await u.page.getByRole('textbox', { name: 'code' }).fill('424242');
+ await u.po.signIn.continue();
+ await u.page.waitForURL(/protected/);
+ await u.po.expect.toBeSignedIn();
+ });
+});
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index a421c0f931c..1bbefe91488 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": "81KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "124.5KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" },
- { "path": "./dist/clerk.native.js", "maxSize": "80KB" },
+ { "path": "./dist/clerk.native.js", "maxSize": "82KB" },
{ "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__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts
index 0607dc183c4..ade9bcc5c02 100644
--- a/packages/clerk-js/src/core/__tests__/clerk.test.ts
+++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts
@@ -18,6 +18,7 @@ import { restoreDocument, setDocumentVisibilityState } from '@/test/document-hel
import { mockNativeRuntime } from '../../test/utils';
import { Clerk } from '../clerk';
import { eventBus, events } from '../events';
+import { ProtectCheckGate } from '../protectCheckGate';
import type { DisplayConfig, Organization } from '../resources/internal';
import { BaseResource, Client, Environment, SignIn, SignUp } from '../resources/internal';
@@ -3186,6 +3187,49 @@ describe('Clerk singleton', () => {
expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/protect-check');
});
});
+
+ it('owns both Protect flows while it routes a callback', async () => {
+ const release = vi.fn();
+ const register = vi.spyOn(ProtectCheckGate.prototype, 'register').mockReturnValue(release);
+ mockEnvironmentFetch.mockReturnValue(
+ Promise.resolve({
+ authConfig: {},
+ userSettings: mockUserSettings,
+ displayConfig: mockDisplayConfig,
+ isSingleSession: () => false,
+ isProduction: () => false,
+ isDevelopmentOrStaging: () => true,
+ onWindowLocationHost: () => false,
+ }),
+ );
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({ signedInSessions: [], signIn: new SignIn(null), signUp: new SignUp(null) }),
+ );
+
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+ await sut.handleRedirectCallback();
+
+ expect(register).toHaveBeenCalledWith(['signIn', 'signUp']);
+ expect(release).toHaveBeenCalledTimes(1);
+ register.mockRestore();
+ });
+
+ it('releases its Protect claim when routing a callback throws', async () => {
+ const release = vi.fn();
+ const register = vi.spyOn(ProtectCheckGate.prototype, 'register').mockReturnValue(release);
+ mockEnvironmentFetch.mockReturnValue(Promise.resolve({ authConfig: {}, userSettings: mockUserSettings }));
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({ signedInSessions: [], signIn: new SignIn(null), signUp: new SignUp(null) }),
+ );
+
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+ await expect(sut.handleRedirectCallback()).rejects.toThrow();
+
+ expect(release).toHaveBeenCalledTimes(1);
+ register.mockRestore();
+ });
});
describe('.handleEmailLinkVerification()', () => {
@@ -4005,6 +4049,118 @@ describe('Clerk singleton', () => {
});
});
+ describe('protect check modal', () => {
+ beforeEach(() => {
+ mockEnvironmentFetch.mockReturnValue(
+ Promise.resolve({
+ userSettings: mockUserSettings,
+ displayConfig: mockDisplayConfig,
+ isSingleSession: () => false,
+ isProduction: () => true,
+ isDevelopmentOrStaging: () => false,
+ }),
+ );
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [],
+ }),
+ );
+ });
+
+ const gatedSignIn = () => ({
+ protectCheck: { status: 'pending', token: 'tok', sdkUrl: 'https://p.example.com/sdk.js' },
+ });
+
+ it('resolves at once when Clerk was loaded without UI components', async () => {
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+
+ await expect(sut.__internal_openProtectCheckModal({ resource: gatedSignIn() as any })).resolves.toBeUndefined();
+ });
+
+ it('resolves at once and leaves the gate when the UI predates the Protect modal', async () => {
+ const openModal = vi.fn();
+ const mockClerkUICtor = vi.fn(function () {
+ return { ensureMounted: () => Promise.resolve({ openModal, closeModal: vi.fn() }) };
+ });
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load({ ...mockedLoadOptions, ui: { ClerkUI: mockClerkUICtor } });
+ const resource = gatedSignIn() as any;
+
+ await expect(sut.__internal_openProtectCheckModal({ resource })).resolves.toBeUndefined();
+ expect(openModal).not.toHaveBeenCalled();
+ expect(resource.protectCheck).not.toBeNull();
+ });
+
+ it('opens the modal and resolves once the modal reports the gate cleared', async () => {
+ const openProtectCheckModal = vi.fn();
+ const closeModal = vi.fn();
+ const mockClerkUICtor = vi.fn(function () {
+ return { ensureMounted: () => Promise.resolve({ openProtectCheckModal, closeModal }) };
+ });
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load({ ...mockedLoadOptions, ui: { ClerkUI: mockClerkUICtor } });
+ const resource = gatedSignIn() as any;
+
+ let settled = false;
+ const pending = sut.__internal_openProtectCheckModal({ resource }).then(() => {
+ settled = true;
+ });
+ await vi.waitFor(() => expect(openProtectCheckModal).toHaveBeenCalled());
+ expect(openProtectCheckModal).toHaveBeenCalledWith({
+ resource,
+ onResolved: expect.any(Function),
+ onFailed: expect.any(Function),
+ });
+ expect(settled).toBe(false);
+
+ openProtectCheckModal.mock.calls[0][0].onResolved();
+ await pending;
+ expect(closeModal).toHaveBeenCalledWith('protectCheck');
+ expect(settled).toBe(true);
+ });
+
+ it('closes the modal and rejects with the error the modal reports', async () => {
+ const openProtectCheckModal = vi.fn();
+ const closeModal = vi.fn();
+ const mockClerkUICtor = vi.fn(function () {
+ return { ensureMounted: () => Promise.resolve({ openProtectCheckModal, closeModal }) };
+ });
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load({ ...mockedLoadOptions, ui: { ClerkUI: mockClerkUICtor } });
+ const blocked = new Error('blocked');
+
+ const pending = sut.__internal_openProtectCheckModal({ resource: gatedSignIn() as any });
+ await vi.waitFor(() => expect(openProtectCheckModal).toHaveBeenCalled());
+ openProtectCheckModal.mock.calls[0][0].onFailed(blocked);
+
+ await expect(pending).rejects.toBe(blocked);
+ expect(closeModal).toHaveBeenCalledWith('protectCheck');
+ });
+
+ it('resolves gates the client carries on its sign-in and sign-up', async () => {
+ const resolve = vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+
+ await sut.__internal_resolvePendingProtectCheck();
+
+ expect(resolve).toHaveBeenCalledWith(sut, 'signIn', sut.client?.signIn);
+ expect(resolve).toHaveBeenCalledWith(sut, 'signUp', sut.client?.signUp);
+ resolve.mockRestore();
+ });
+
+ it('registers prebuilt handlers with the gate', () => {
+ const release = vi.fn();
+ const register = vi.spyOn(ProtectCheckGate.prototype, 'register').mockReturnValue(release);
+ const sut = new Clerk(productionPublishableKey);
+
+ expect(sut.__internal_registerProtectCheckHandler(['signUp'])).toBe(release);
+ expect(register).toHaveBeenCalledWith(['signUp']);
+ register.mockRestore();
+ });
+ });
+
describe('ui.ClerkUI option', () => {
beforeEach(() => {
mockEnvironmentFetch.mockReturnValue(
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 081c9665f59..422de57a0b8 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -62,6 +62,7 @@ import type {
__internal_EnableOrganizationsPromptProps,
__internal_OAuthConsentProps,
__internal_PlanDetailsProps,
+ __internal_ProtectCheckModalProps,
__internal_SubscriptionDetailsProps,
__internal_UserVerificationModalProps,
APIKeysNamespace,
@@ -108,6 +109,7 @@ import type {
OrganizationSwitcherProps,
PricingTableProps,
ProtectAssertion,
+ ProtectCheckFlow,
PublicKeyCredentialCreationOptionsWithoutExtensions,
PublicKeyCredentialRequestOptionsWithoutExtensions,
PublicKeyCredentialWithAuthenticatorAssertionResponse,
@@ -195,6 +197,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';
@@ -991,6 +994,47 @@ export class Clerk implements ClerkInterface {
.then(controls => controls.closeModal('enableOrganizationsPrompt'));
};
+ public __internal_registerProtectCheckHandler = (flows: ProtectCheckFlow[]): (() => void) => {
+ return ProtectCheckGate.getInstance().register(flows);
+ };
+
+ public __internal_resolvePendingProtectCheck = async (): Promise => {
+ if (!this.client) {
+ return;
+ }
+ const gate = ProtectCheckGate.getInstance();
+ await gate.resolve(this, 'signIn', this.client.signIn);
+ await gate.resolve(this, 'signUp', this.client.signUp);
+ };
+
+ public __internal_openProtectCheckModal = (
+ props: Pick<__internal_ProtectCheckModalProps, 'resource'>,
+ ): Promise => {
+ if (!this.#clerkUI) {
+ return Promise.resolve();
+ }
+ return this.#clerkUI
+ .then(ui => ui.ensureMounted())
+ .then(controls => {
+ if (!controls.openProtectCheckModal) {
+ return;
+ }
+ return new Promise((resolve, reject) => {
+ controls.openProtectCheckModal?.({
+ ...props,
+ onResolved: () => {
+ controls.closeModal('protectCheck');
+ resolve();
+ },
+ onFailed: error => {
+ controls.closeModal('protectCheck');
+ reject(error);
+ },
+ });
+ });
+ });
+ };
+
public __internal_openBlankCaptchaModal = (): Promise => {
this.assertComponentsReady(this.#clerkUI);
return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.openModal('blankCaptcha', {}));
@@ -2569,7 +2613,16 @@ export class Clerk implements ClerkInterface {
return this.__internal_handleResourceCallback(signInOrUp, params, customNavigate);
};
- private _handleRedirectCallback = async (
+ private _handleRedirectCallback = async (...args: Parameters): Promise => {
+ const release = ProtectCheckGate.getInstance().register(['signIn', 'signUp']);
+ try {
+ return await this._routeRedirectCallback(...args);
+ } finally {
+ release();
+ }
+ };
+
+ private _routeRedirectCallback = async (
params: ResumeAfterProtectCheckParams,
{
signIn,
diff --git a/packages/clerk-js/src/core/protectCheckGate.test.ts b/packages/clerk-js/src/core/protectCheckGate.test.ts
new file mode 100644
index 00000000000..29530c3bcd0
--- /dev/null
+++ b/packages/clerk-js/src/core/protectCheckGate.test.ts
@@ -0,0 +1,144 @@
+import { createDeferredPromise } from '@clerk/shared/utils';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ProtectCheckGate } from './protectCheckGate';
+import type { Clerk } from './resources/internal';
+
+const gated = (id = 'sia_1') =>
+ ({
+ id,
+ protectCheck: { status: 'pending', token: 'tok', sdkUrl: 'https://protect.example.com/sdk.js' },
+ }) as any;
+const clear = (id = 'sia_1') => ({ id, protectCheck: null }) as any;
+
+const mockClerk = (overrides: Partial = {}) =>
+ ({
+ __internal_openProtectCheckModal: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ }) as unknown as Clerk;
+
+describe('ProtectCheckGate', () => {
+ let gate: ProtectCheckGate;
+
+ beforeEach(() => {
+ gate = ProtectCheckGate.getInstance();
+ });
+
+ it('opens the modal for a gated resource and waits for it to resolve', async () => {
+ const deferred = createDeferredPromise();
+ const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) });
+ const resource = gated();
+
+ let settled = false;
+ const pending = gate.resolve(clerk, 'signIn', resource).then(() => {
+ settled = true;
+ });
+ await Promise.resolve();
+ expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledWith({ resource });
+ expect(settled).toBe(false);
+
+ deferred.resolve();
+ await pending;
+ expect(settled).toBe(true);
+ });
+
+ it('does nothing when the resource has no gate', async () => {
+ const clerk = mockClerk();
+ await gate.resolve(clerk, 'signUp', clear());
+ expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled();
+ });
+
+ it('leaves the gate to code that registered for its flow', async () => {
+ const clerk = mockClerk();
+ const release = gate.register(['signIn']);
+ await gate.resolve(clerk, 'signIn', gated());
+ release();
+ expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled();
+ });
+
+ it('opens the modal for a flow nobody registered for', async () => {
+ const clerk = mockClerk();
+ const release = gate.register(['signUp']);
+ await gate.resolve(clerk, 'signIn', gated());
+ release();
+ expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1);
+ });
+
+ it('counts registrations per flow and releases each one once', async () => {
+ const clerk = mockClerk();
+ const releaseCombined = gate.register(['signIn', 'signUp']);
+ const releaseSignUp = gate.register(['signUp']);
+
+ releaseCombined();
+ releaseCombined();
+ await gate.resolve(clerk, 'signIn', gated('sia_1'));
+ await gate.resolve(clerk, 'signUp', gated('sua_1'));
+ expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1);
+
+ releaseSignUp();
+ await gate.resolve(clerk, 'signUp', gated('sua_1'));
+ expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(2);
+ });
+
+ it('leaves the proof submission to whatever runs the challenge', async () => {
+ const clerk = mockClerk();
+ await gate.resolve(clerk, 'signIn', gated(), 'protect_check');
+ expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled();
+ });
+
+ it('makes a call on the same resource share the in-flight resolution', async () => {
+ const deferred = createDeferredPromise();
+ const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) });
+
+ const outer = gate.resolve(clerk, 'signIn', gated());
+ let innerSettled = false;
+ const inner = gate.resolve(clerk, 'signIn', gated()).then(() => {
+ innerSettled = true;
+ });
+ await Promise.resolve();
+ expect(innerSettled).toBe(false);
+
+ deferred.resolve();
+ await Promise.all([outer, inner]);
+ expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1);
+ });
+
+ it('shares a rejection with callers on the same resource', async () => {
+ const deferred = createDeferredPromise();
+ const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) });
+ const blocked = new Error('blocked');
+
+ const outer = gate.resolve(clerk, 'signIn', gated());
+ const inner = gate.resolve(clerk, 'signIn', gated());
+ deferred.reject(blocked);
+
+ await expect(outer).rejects.toBe(blocked);
+ await expect(inner).rejects.toBe(blocked);
+ });
+
+ it('makes a call on another resource wait, then resolve its own gate', async () => {
+ const first = createDeferredPromise();
+ const open = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValueOnce(undefined);
+ const clerk = mockClerk({ __internal_openProtectCheckModal: open });
+ const signUp = gated('sua_1');
+
+ const outer = gate.resolve(clerk, 'signIn', gated('sia_1'));
+ const other = gate.resolve(clerk, 'signUp', signUp);
+ await Promise.resolve();
+ expect(open).toHaveBeenCalledTimes(1);
+
+ first.resolve();
+ await Promise.all([outer, other]);
+ expect(open).toHaveBeenCalledTimes(2);
+ expect(open).toHaveBeenLastCalledWith({ resource: signUp });
+ });
+
+ it('releases the in-flight lock and rethrows when the modal cannot open', async () => {
+ const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockRejectedValue(new Error('no ui')) });
+ await expect(gate.resolve(clerk, 'signIn', gated())).rejects.toThrow('no ui');
+
+ const next = mockClerk();
+ await gate.resolve(next, 'signIn', gated());
+ expect(next.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/clerk-js/src/core/protectCheckGate.ts b/packages/clerk-js/src/core/protectCheckGate.ts
new file mode 100644
index 00000000000..831e45f547e
--- /dev/null
+++ b/packages/clerk-js/src/core/protectCheckGate.ts
@@ -0,0 +1,64 @@
+import type { ProtectCheckFlow, SignInResource, SignUpResource } from '@clerk/shared/types';
+
+import type { Clerk } from './resources/internal';
+
+/**
+ * Resolves a pending `protect_check` on a sign-in or sign-up resource by opening Clerk's Protect
+ * modal and waiting for the challenge to clear. One resolution runs at a time. A call on the same
+ * resource shares it, and a call on another resource waits for it before resolving its own gate.
+ * Skips the proof submission, which belongs to whatever runs the challenge, flows claimed through
+ * `register` by code that routes the gate itself, and no-RHC builds where the challenge script must
+ * not be loaded.
+ */
+export class ProtectCheckGate {
+ private static instance: ProtectCheckGate;
+
+ private inflight: { resourceId: string | undefined; promise: Promise } | null = null;
+
+ private handlers: Record = { signIn: 0, signUp: 0 };
+
+ public static getInstance(): ProtectCheckGate {
+ if (!ProtectCheckGate.instance) {
+ ProtectCheckGate.instance = new ProtectCheckGate();
+ }
+ return ProtectCheckGate.instance;
+ }
+
+ /** Claims the given flows until the returned function is called, which counts only once. */
+ public register(flows: ProtectCheckFlow[]): () => void {
+ flows.forEach(flow => (this.handlers[flow] += 1));
+ let released = false;
+ return () => {
+ if (released) {
+ return;
+ }
+ released = true;
+ flows.forEach(flow => (this.handlers[flow] -= 1));
+ };
+ }
+
+ public async resolve(
+ clerk: Clerk,
+ flow: ProtectCheckFlow,
+ resource: SignInResource | SignUpResource,
+ action?: string,
+ ): Promise {
+ if (__BUILD_DISABLE_RHC__ || action === 'protect_check') {
+ return;
+ }
+ while (this.inflight) {
+ if (this.inflight.resourceId === resource.id) {
+ return this.inflight.promise;
+ }
+ await this.inflight.promise.catch(() => {});
+ }
+ if (!resource.protectCheck || this.handlers[flow] > 0) {
+ return;
+ }
+ const promise = clerk.__internal_openProtectCheckModal({ resource }).finally(() => {
+ this.inflight = null;
+ });
+ this.inflight = { resourceId: resource.id, promise };
+ await promise;
+ }
+}
diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts
index 4ad63d5d01c..e1ff96b1061 100644
--- a/packages/clerk-js/src/core/resources/Base.ts
+++ b/packages/clerk-js/src/core/resources/Base.ts
@@ -232,7 +232,13 @@ export abstract class BaseResource {
const { action, body, method, path, signal } = params;
// TODO @userland-errors:
const json = await BaseResource._fetch({ method, path: path || this.path(action), body, signal });
- return this.fromJSON((json?.response || json) as J);
+ const resource = this.fromJSON((json?.response || json) as J);
+ await this._afterMutate(params);
+ return resource;
+ }
+
+ protected _afterMutate(_params: BaseMutateParams): Promise {
+ return Promise.resolve();
}
protected async _baseMutateBypass(params: BaseMutateParams): Promise {
diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts
index ebbaebeae07..e8ffc3c749f 100644
--- a/packages/clerk-js/src/core/resources/SignIn.ts
+++ b/packages/clerk-js/src/core/resources/SignIn.ts
@@ -100,6 +100,8 @@ import {
clerkVerifyWeb3WalletCalledBeforeCreate,
} from '../errors';
import { eventBus } from '../events';
+import { ProtectCheckGate } from '../protectCheckGate';
+import type { BaseMutateParams } from './internal';
import { BaseResource, UserData, Verification } from './internal';
/**
@@ -681,6 +683,10 @@ export class SignIn extends BaseResource implements SignInResource {
}
};
+ protected _afterMutate({ action }: BaseMutateParams): Promise {
+ return ProtectCheckGate.getInstance().resolve(SignIn.clerk, 'signIn', this, action);
+ }
+
protected fromJSON(data: SignInJSON | SignInJSONSnapshot | null): this {
if (data) {
this.id = data.id;
diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts
index ac8c1d0b5c9..fecf5de5e97 100644
--- a/packages/clerk-js/src/core/resources/SignUp.ts
+++ b/packages/clerk-js/src/core/resources/SignUp.ts
@@ -69,6 +69,8 @@ import {
clerkVerifyWeb3WalletCalledBeforeCreate,
} from '../errors';
import { eventBus } from '../events';
+import { ProtectCheckGate } from '../protectCheckGate';
+import type { BaseMutateParams } from './internal';
import { BaseResource, SignUpVerifications } from './internal';
declare global {
@@ -528,6 +530,10 @@ export class SignUp extends BaseResource implements SignUpResource {
}
};
+ protected _afterMutate({ action }: BaseMutateParams): Promise {
+ return ProtectCheckGate.getInstance().resolve(SignUp.clerk, 'signUp', this, action);
+ }
+
protected fromJSON(data: SignUpJSON | SignUpJSONSnapshot | null): this {
if (data) {
this.id = data.id;
diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
index 2af05b64bbd..0050bef0448 100644
--- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
@@ -2,6 +2,7 @@ import { createDeferredPromise } from '@clerk/shared/utils';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../../events';
+import { ProtectCheckGate } from '../../protectCheckGate';
import { signInErrorSignal, signInResourceSignal } from '../../signals';
import { BaseResource } from '../internal';
import { SignIn } from '../SignIn';
@@ -18,6 +19,10 @@ vi.mock('../../../utils/authenticateWithPopup', async () => {
// Import the mocked function after mocking
import { _futureAuthenticateWithPopup } from '../../../utils/authenticateWithPopup';
+beforeEach(() => {
+ vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
+});
+
// Mock the CaptchaChallenge module
vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({
CaptchaChallenge: vi.fn().mockImplementation(function () {
@@ -3674,3 +3679,45 @@ describe('SignIn', () => {
});
});
});
+
+describe('SignIn protect_check gate', () => {
+ const clerk = {} as any;
+ let previousClerk: any;
+
+ beforeEach(() => {
+ previousClerk = SignIn.clerk;
+ SignIn.clerk = clerk;
+ });
+
+ afterEach(() => {
+ SignIn.clerk = previousClerk;
+ });
+
+ const gatedResponse = {
+ client: null,
+ response: {
+ id: 'signin_123',
+ protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' },
+ },
+ };
+
+ it('hands the resource to the gate after a mutation', async () => {
+ BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse);
+ const signIn = new SignIn({ id: 'signin_123' } as any);
+
+ await signIn.create({ identifier: 'a@example.com' });
+
+ expect(signIn.protectCheck?.token).toBe('challenge-token');
+ expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(clerk, 'signIn', signIn, undefined);
+ });
+
+ it('leaves reloads to the caller', async () => {
+ BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse);
+ const signIn = new SignIn({ id: 'signin_123' } as any);
+
+ await signIn.reload();
+
+ expect(signIn.protectCheck?.token).toBe('challenge-token');
+ expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
index dd2a5617b47..8a267438ddb 100644
--- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
@@ -2,6 +2,7 @@ import { createDeferredPromise } from '@clerk/shared/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../../events';
+import { ProtectCheckGate } from '../../protectCheckGate';
import { signUpErrorSignal, signUpResourceSignal } from '../../signals';
import { BaseResource } from '../internal';
import { SignUp } from '../SignUp';
@@ -19,6 +20,10 @@ vi.mock('../../../utils/authenticateWithPopup', async () => {
import { _futureAuthenticateWithPopup } from '../../../utils/authenticateWithPopup';
import { CaptchaChallenge } from '../../../utils/captcha/CaptchaChallenge';
+beforeEach(() => {
+ vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
+});
+
// Mock the CaptchaChallenge module
vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({
CaptchaChallenge: vi.fn().mockImplementation(function () {
@@ -2334,3 +2339,45 @@ describe('SignUp', () => {
});
});
});
+
+describe('SignUp protect_check gate', () => {
+ const clerk = {} as any;
+ let previousClerk: any;
+
+ beforeEach(() => {
+ previousClerk = SignUp.clerk;
+ SignUp.clerk = clerk;
+ });
+
+ afterEach(() => {
+ SignUp.clerk = previousClerk;
+ });
+
+ const gatedResponse = {
+ client: null,
+ response: {
+ id: 'signup_123',
+ protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' },
+ },
+ };
+
+ it('hands the resource to the gate after a mutation', async () => {
+ BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse);
+ const signUp = new SignUp({ id: 'signup_123' } as any);
+
+ await signUp.update({ emailAddress: 'a@example.com' });
+
+ expect(signUp.protectCheck?.token).toBe('challenge-token');
+ expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(clerk, 'signUp', signUp, undefined);
+ });
+
+ it('leaves reloads to the caller', async () => {
+ BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse);
+ const signUp = new SignUp({ id: 'signup_123' } as any);
+
+ await signUp.reload();
+
+ expect(signUp.protectCheck?.token).toBe('challenge-token');
+ expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx
index cc3e13ff439..476cf384eed 100644
--- a/packages/react/src/components/HandleSSOCallback.tsx
+++ b/packages/react/src/components/HandleSSOCallback.tsx
@@ -78,6 +78,12 @@ export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode {
// Prevent re-running this effect if the page is re-rendered during session activation (such as on Next.js).
hasRun.current = true;
+ try {
+ await clerk.__internal_resolvePendingProtectCheck?.();
+ } catch {
+ return navigateToSignIn();
+ }
+
// If this was a sign-in, and it's complete, there's nothing else to do.
// Note: We perform a cast here to prevent TypeScript from narrowing the type of signIn.status. TypeScript
// doesn't understand that the status can be mutated during the execution of this function.
diff --git a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx
index 7eb4622f745..4ed8ce726a3 100644
--- a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx
+++ b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx
@@ -18,6 +18,7 @@ const mockSignUpFinalize = vi.fn().mockImplementation(async ({ navigate }) => {
return { error: null };
});
const mockSignUpCreate = vi.fn().mockResolvedValue({ error: null });
+const mockResolvePendingProtectCheck = vi.fn().mockResolvedValue(undefined);
const mockSetActive = vi.fn().mockImplementation(async ({ navigate }) => {
await navigate({ session: { id: 'sess_existing' }, decorateUrl: (url: string) => url });
});
@@ -30,6 +31,7 @@ vi.mock('../../../src/hooks', () => ({
useClerk: () => ({
loaded: mockClerkLoaded,
setActive: mockSetActive,
+ __internal_resolvePendingProtectCheck: mockResolvePendingProtectCheck,
}),
useSignIn: () => ({
signIn: {
@@ -82,6 +84,7 @@ describe('', () => {
mockClerkLoaded = true;
mockSignIn = {};
mockSignUp = {};
+ mockResolvePendingProtectCheck.mockResolvedValue(undefined);
});
it('renders captcha element by default', () => {
@@ -116,6 +119,47 @@ describe('', () => {
});
});
+ it('resolves a Protect gate carried by the client before routing', async () => {
+ mockSignIn = { status: 'needs_protect_check' };
+ mockResolvePendingProtectCheck.mockImplementation(async () => {
+ mockSignIn = { status: 'complete' };
+ });
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockSignInFinalize).toHaveBeenCalled();
+ });
+ expect(mockResolvePendingProtectCheck).toHaveBeenCalledTimes(1);
+ expect(mockResolvePendingProtectCheck.mock.invocationCallOrder[0]).toBeLessThan(
+ mockSignInFinalize.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('navigates to sign-in when Protect blocks the attempt', async () => {
+ mockSignIn = { status: 'needs_protect_check' };
+ mockResolvePendingProtectCheck.mockRejectedValue(new Error('blocked'));
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockNavigateToSignIn).toHaveBeenCalled();
+ });
+ expect(mockNavigateToApp).not.toHaveBeenCalled();
+ });
+
it('finalizes sign-in and navigates to app when signIn.status is complete', async () => {
mockSignIn = { status: 'complete' };
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index 1bceeeacdb3..a0c02640b80 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -1649,6 +1649,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
}
};
+ __internal_resolvePendingProtectCheck = async (): Promise => {
+ await this.clerkjs?.__internal_resolvePendingProtectCheck?.();
+ };
+
__internal_resumeAfterProtectCheck = async (
params?: ResumeAfterProtectCheckParams,
customNavigate?: (to: string) => Promise,
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index 5c09303315b..e1a52c2096b 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -342,6 +342,23 @@ export interface Clerk {
*/
__internal_protectChallengeLoadTimeoutMs?: number;
+ /**
+ * Registers a prebuilt component that renders Protect challenges itself for the given flows, so
+ * clerk-js leaves a pending `protect_check` on those resources instead of opening its own modal.
+ * Returns the unregister function.
+ *
+ * @internal
+ */
+ __internal_registerProtectCheckHandler?: (flows: ProtectCheckFlow[]) => () => void;
+
+ /**
+ * Resolves a pending `protect_check` on the client's current sign-in or sign-up through Clerk's
+ * Protect modal. Callback pages need it because the gate arrives with the client, not on a request.
+ *
+ * @internal
+ */
+ __internal_resolvePendingProtectCheck?: () => Promise;
+
frontendApi: string;
/** Your Clerk [Publishable Key](!publishable-key). */
@@ -1979,6 +1996,14 @@ export type __internal_UserVerificationProps = RoutingOptions & {
export type __internal_UserVerificationModalProps = WithoutRouting<__internal_UserVerificationProps>;
+export type ProtectCheckFlow = 'signIn' | 'signUp';
+
+export type __internal_ProtectCheckModalProps = {
+ resource: SignInResource | SignUpResource;
+ onResolved: () => void;
+ onFailed: (error: Error) => void;
+};
+
export type __internal_EnableOrganizationsPromptProps = {
onSuccess?: () => void;
onClose?: () => void;
diff --git a/packages/shared/src/ui/types.ts b/packages/shared/src/ui/types.ts
index 820958ce1b8..a2ed6d89f05 100644
--- a/packages/shared/src/ui/types.ts
+++ b/packages/shared/src/ui/types.ts
@@ -1,5 +1,5 @@
import type { ModuleManager } from '../moduleManager';
-import type { Clerk, ClerkOptions, EnvironmentResource } from '../types';
+import type { __internal_ProtectCheckModalProps, Clerk, ClerkOptions, EnvironmentResource } from '../types';
// TODO @nikos
type Appearance = any;
@@ -21,6 +21,7 @@ export type ComponentControls = {
closeDrawer: (drawer: string, options?: { notify?: boolean }) => void;
prefetch: (component: 'organizationSwitcher') => void;
mountImpersonationFab: () => void;
+ openProtectCheckModal?: (props: __internal_ProtectCheckModalProps) => void;
};
// Instance shape that the class will implement
diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx
index ef830af901b..0fb3e678246 100644
--- a/packages/ui/src/Components.tsx
+++ b/packages/ui/src/Components.tsx
@@ -4,6 +4,7 @@ import type {
__internal_CheckoutProps,
__internal_EnableOrganizationsPromptProps,
__internal_PlanDetailsProps,
+ __internal_ProtectCheckModalProps,
__internal_SubscriptionDetailsProps,
__internal_UserVerificationProps,
Clerk,
@@ -39,6 +40,7 @@ import {
KeylessPrompt,
OrganizationProfileModal,
preloadComponent,
+ ProtectCheckModal,
SignInModal,
SignUpModal,
UserProfileModal,
@@ -97,6 +99,7 @@ export type ComponentControls = {
| 'userVerification'
| 'waitlist'
| 'blankCaptcha'
+ | 'protectCheck'
| 'enableOrganizationsPrompt',
>(
modal: T,
@@ -112,7 +115,9 @@ export type ComponentControls = {
? InviteMembersModalProps
: T extends 'enableOrganizationsPrompt'
? __internal_EnableOrganizationsPromptProps
- : UserProfileProps,
+ : T extends 'protectCheck'
+ ? __internal_ProtectCheckModalProps
+ : UserProfileProps,
) => void;
closeModal: (
modal:
@@ -126,6 +131,7 @@ export type ComponentControls = {
| 'userVerification'
| 'waitlist'
| 'blankCaptcha'
+ | 'protectCheck'
| 'enableOrganizationsPrompt',
options?: {
notify?: boolean;
@@ -150,6 +156,7 @@ export type ComponentControls = {
prefetch: (component: 'organizationSwitcher') => void;
// Special case, as the impersonation fab mounts automatically
mountImpersonationFab: () => void;
+ openProtectCheckModal: (props: __internal_ProtectCheckModalProps) => void;
};
interface HtmlNodeOptions {
@@ -180,6 +187,7 @@ interface ComponentsState {
createOrganizationModal: null | CreateOrganizationModalProps;
enableOrganizationsPromptModal: null | __internal_EnableOrganizationsPromptProps;
blankCaptchaModal: null;
+ protectCheckModal: null | __internal_ProtectCheckModalProps;
organizationSwitcherPrefetch: boolean;
waitlistModal: null | WaitlistModalProps;
checkoutDrawer: {
@@ -313,6 +321,7 @@ const Components = (props: ComponentsProps) => {
organizationSwitcherPrefetch: false,
waitlistModal: null,
blankCaptchaModal: null,
+ protectCheckModal: null,
checkoutDrawer: {
open: false,
props: null,
@@ -339,6 +348,7 @@ const Components = (props: ComponentsProps) => {
createOrganizationModal,
waitlistModal,
blankCaptchaModal,
+ protectCheckModal,
checkoutDrawer,
planDetailsDrawer,
subscriptionDetailsDrawer,
@@ -477,6 +487,10 @@ const Components = (props: ComponentsProps) => {
setState(s => ({ ...s, impersonationFab: true }));
};
+ componentsControls.openProtectCheckModal = props => {
+ componentsControls.openModal('protectCheck', props);
+ };
+
componentsControls.openDrawer = (name, props) => {
setState(s => ({
...s,
@@ -686,6 +700,23 @@ const Components = (props: ComponentsProps) => {
);
+ const mountedProtectCheckModal = protectCheckModal && (
+ componentsControls.closeModal('protectCheck')}
+ startPath={buildVirtualRouterUrl({ base: '/protect-check', path: urlStateParam?.path })}
+ componentName={'ProtectCheckModal'}
+ canCloseModal={false}
+ modalContainerSx={t => ({ alignItems: 'center', backdropFilter: `blur(${t.sizes.$2})` })}
+ getContainer={() => null}
+ >
+
+
+ );
+
return (
{
{createOrganizationModal && mountedCreateOrganizationModal}
{waitlistModal && mountedWaitlistModal}
{blankCaptchaModal && mountedBlankCaptchaModal}
+ {mountedProtectCheckModal}
clerk.__internal_registerProtectCheckHandler?.(['signIn', 'signUp']), [clerk]);
+
async function oneTapCallback(response: GISCredentialResponse) {
isPromptedRef.current = false;
try {
diff --git a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx
new file mode 100644
index 00000000000..3d7b9e0ad3e
--- /dev/null
+++ b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx
@@ -0,0 +1,173 @@
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import { waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen } from '@/test/utils';
+
+import { OptionsProvider } from '../../../contexts';
+import { ProtectCheckModal } from '..';
+
+vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({
+ executeProtectCheck: vi.fn(),
+}));
+
+import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck';
+
+const { createFixtures: createBaseFixtures } = bindCreateFixtures('SignIn');
+
+const createFixtures = async (...args: Parameters) => {
+ const result = await createBaseFixtures(...args);
+ result.fixtures.router.currentPath = '/protect-check';
+ result.fixtures.router.matches.mockImplementation((path?: string) => path === 'protect-check');
+ return result;
+};
+
+const mockExecute = executeProtectCheck as unknown as ReturnType;
+
+beforeEach(() => {
+ mockExecute.mockReset();
+});
+
+describe('ProtectCheckModal', () => {
+ it('calls onResolved once the gate clears', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ const onResolved = vi.fn();
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockImplementation(() => {
+ (fixtures.signIn as any).protectCheck = null;
+ return Promise.resolve(fixtures.signIn);
+ });
+
+ render(
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => expect(onResolved).toHaveBeenCalledTimes(1));
+ expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' });
+ });
+
+ it('runs a chained challenge and only calls onResolved after the last gate clears', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ const onResolved = vi.fn();
+ mockExecute.mockResolvedValueOnce('proof-1').mockReturnValueOnce(new Promise(() => {}));
+ fixtures.signIn.submitProtectCheck.mockImplementationOnce(() => {
+ (fixtures.signIn as any).protectCheck = {
+ status: 'pending',
+ token: 'challenge-token-2',
+ sdkUrl: 'https://protect.example.com/sdk.js',
+ };
+ return Promise.resolve(fixtures.signIn);
+ });
+
+ render(
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(2));
+ expect(mockExecute.mock.calls[1][0]).toMatchObject({ token: 'challenge-token-2' });
+ expect(onResolved).not.toHaveBeenCalled();
+ });
+
+ it('keeps the modal open with a retry when the submit fails for a reason other than a block', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ const onResolved = vi.fn();
+ const onFailed = vi.fn();
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockRejectedValue(
+ new ClerkAPIResponseError('invalid', {
+ status: 422,
+ data: [{ code: 'form_param_invalid', message: 'invalid' } as any],
+ }),
+ );
+
+ render(
+ ,
+ { wrapper },
+ );
+
+ await screen.findByRole('button', { name: /try again/i });
+ expect(onFailed).not.toHaveBeenCalled();
+ expect(onResolved).not.toHaveBeenCalled();
+ });
+
+ it('hands a blocked verdict to onFailed instead of resolving', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ const blocked = new ClerkAPIResponseError('blocked', {
+ status: 403,
+ data: [{ code: 'action_blocked', message: 'blocked', meta: { traceId: 'trace_1' } } as any],
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockRejectedValue(blocked);
+ const onResolved = vi.fn();
+ const onFailed = vi.fn();
+
+ render(
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => expect(onFailed).toHaveBeenCalledWith(blocked));
+ expect(onResolved).not.toHaveBeenCalled();
+ });
+
+ it('uses the sign-up localization keys for a sign-up gate', async () => {
+ const { wrapper: Wrapper, fixtures } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck();
+ });
+ mockExecute.mockReturnValue(new Promise(() => {}));
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+ );
+
+ render(
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Sign-up check');
+ expect(screen.queryByText('Sign-in check')).toBeNull();
+ await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/packages/ui/src/components/ProtectCheckModal/index.tsx b/packages/ui/src/components/ProtectCheckModal/index.tsx
new file mode 100644
index 00000000000..3990f9b3623
--- /dev/null
+++ b/packages/ui/src/components/ProtectCheckModal/index.tsx
@@ -0,0 +1,63 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants';
+import type { __internal_ProtectCheckModalProps, SignInResource, SignUpResource } from '@clerk/shared/types';
+
+import { withCardStateProvider } from '@/ui/elements/contexts';
+
+import { Flow } from '../../customizables';
+import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
+import { Route, Switch } from '../../router';
+import { ProtectCheckCard } from '../ProtectCheck/ProtectCheckCard';
+
+const flowOf = (resource: SignInResource | SignUpResource) =>
+ resource.pathRoot.endsWith('sign_ups') ? 'signUp' : 'signIn';
+
+const ProtectCheckModalCard = withCardStateProvider(
+ ({ resource, onResolved, onFailed }: __internal_ProtectCheckModalProps) => {
+ const runner = useProtectCheckRunner({
+ getProtectCheck: () => resource.protectCheck,
+ getResource: () => resource,
+ reload: () => resource.reload(),
+ submitProtectCheck: params =>
+ resource.submitProtectCheck(params).catch((error: unknown) => {
+ if (isClerkAPIResponseError(error) && error.errors[0]?.code === ERROR_CODES.FRAUD_ACTION_BLOCKED) {
+ onFailed(error);
+ }
+ throw error;
+ }),
+ onResolved: (updated, isCancelled) => {
+ if (!isCancelled() && !updated.protectCheck) {
+ onResolved();
+ }
+ return Promise.resolve();
+ },
+ });
+
+ return (
+
+ );
+ },
+);
+
+function ProtectCheckModal(props: __internal_ProtectCheckModalProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+ProtectCheckModal.displayName = 'ProtectCheckModal';
+
+export { ProtectCheckModal };
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx
new file mode 100644
index 00000000000..fe12ac14ee7
--- /dev/null
+++ b/packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render } from '@/test/utils';
+
+import { SignIn } from '..';
+
+const { createFixtures } = bindCreateFixtures('SignIn');
+
+describe('SignIn', () => {
+ it('owns the protect check gate while mounted', async () => {
+ const { wrapper, fixtures } = await createFixtures();
+ const unregister = vi.fn();
+ const register = vi.fn(() => unregister);
+ fixtures.clerk.__internal_registerProtectCheckHandler = register;
+
+ const { unmount } = render(, { wrapper });
+
+ expect(register).toHaveBeenCalledTimes(1);
+ expect(register).toHaveBeenCalledWith(['signIn']);
+ expect(unregister).not.toHaveBeenCalled();
+ unmount();
+ expect(unregister).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/ui/src/components/SignIn/index.tsx b/packages/ui/src/components/SignIn/index.tsx
index 01c6a532497..83876f50638 100644
--- a/packages/ui/src/components/SignIn/index.tsx
+++ b/packages/ui/src/components/SignIn/index.tsx
@@ -52,6 +52,12 @@ function RedirectToSignIn() {
function SignInRoutes(): JSX.Element {
const signInContext = useSignInContext();
const signUpContext = useSignUpContext();
+ const clerk = useClerk();
+ const { isCombinedFlow } = signInContext;
+ React.useEffect(
+ () => clerk.__internal_registerProtectCheckHandler?.(isCombinedFlow ? ['signIn', 'signUp'] : ['signIn']),
+ [clerk, isCombinedFlow],
+ );
return (
diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx
new file mode 100644
index 00000000000..6d8127c4204
--- /dev/null
+++ b/packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render } from '@/test/utils';
+
+import { SignUp } from '..';
+
+const { createFixtures } = bindCreateFixtures('SignUp');
+
+describe('SignUp', () => {
+ it('owns the protect check gate while mounted', async () => {
+ const { wrapper, fixtures } = await createFixtures();
+ const unregister = vi.fn();
+ const register = vi.fn(() => unregister);
+ fixtures.clerk.__internal_registerProtectCheckHandler = register;
+
+ const { unmount } = render(, { wrapper });
+
+ expect(register).toHaveBeenCalledTimes(1);
+ expect(register).toHaveBeenCalledWith(['signUp']);
+ expect(unregister).not.toHaveBeenCalled();
+ unmount();
+ expect(unregister).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/ui/src/components/SignUp/index.tsx b/packages/ui/src/components/SignUp/index.tsx
index f5b94663dbe..e99aef64ffe 100644
--- a/packages/ui/src/components/SignUp/index.tsx
+++ b/packages/ui/src/components/SignUp/index.tsx
@@ -31,6 +31,8 @@ function SignUpRoutes(): JSX.Element {
usePreloadTasks();
const signUpContext = useSignUpContext();
+ const clerk = useClerk();
+ React.useEffect(() => clerk.__internal_registerProtectCheckHandler?.(['signUp']), [clerk]);
return (
diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx
index 6d13d25476c..a8345aca4a4 100644
--- a/packages/ui/src/elements/contexts/index.tsx
+++ b/packages/ui/src/elements/contexts/index.tsx
@@ -99,6 +99,7 @@ export type FlowMetadata = {
| 'organizationList'
| 'oneTap'
| 'blankCaptcha'
+ | 'protectCheck'
| 'waitlist'
| 'checkout'
| 'planDetails'
diff --git a/packages/ui/src/lazyModules/components.ts b/packages/ui/src/lazyModules/components.ts
index 001b6b6eeab..c1f96df66bc 100644
--- a/packages/ui/src/lazyModules/components.ts
+++ b/packages/ui/src/lazyModules/components.ts
@@ -16,6 +16,7 @@ const componentImportPaths = {
ImpersonationFab: () => import(/* webpackChunkName: "impersonationfab" */ './../components/ImpersonationFab'),
GoogleOneTap: () => import(/* webpackChunkName: "onetap" */ './../components/GoogleOneTap'),
BlankCaptchaModal: () => import(/* webpackChunkName: "blankcaptcha" */ './../components/BlankCaptchaModal'),
+ ProtectCheckModal: () => import(/* webpackChunkName: "protectcheck" */ './../components/ProtectCheckModal'),
UserVerification: () => import(/* webpackChunkName: "userverification" */ './../components/UserVerification'),
Waitlist: () => import(/* webpackChunkName: "waitlist" */ './../components/Waitlist'),
KeylessPrompt: () => import(/* webpackChunkName: "keylessPrompt" */ '../components/devPrompts/KeylessPrompt'),
@@ -121,6 +122,10 @@ export const BlankCaptchaModal = lazy(() =>
componentImportPaths.BlankCaptchaModal().then(module => ({ default: module.BlankCaptchaModal })),
);
+export const ProtectCheckModal = lazy(() =>
+ componentImportPaths.ProtectCheckModal().then(module => ({ default: module.ProtectCheckModal })),
+);
+
export const ImpersonationFab = lazy(() =>
componentImportPaths.ImpersonationFab().then(module => ({ default: module.ImpersonationFab })),
);
@@ -203,6 +208,7 @@ export const ClerkComponents = {
Waitlist,
WaitlistModal,
BlankCaptchaModal,
+ ProtectCheckModal,
PricingTable,
Checkout,
PlanDetails,