Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e03d3a8
feat(shared): type the Protect check modal props and handler registra…
wobsoriano Sep 25, 2026
6ee668e
feat(clerk-js): resolve protect_check gates through the Protect modal
wobsoriano Sep 25, 2026
0ebd2a0
feat(ui): add the ProtectCheckModal for custom flows
wobsoriano Sep 25, 2026
2ae3f34
refactor(clerk-js): run the Protect gate from a protected hook on Sig…
wobsoriano Sep 26, 2026
cf149a2
fix(clerk-js,ui): leave the gate in place when the UI cannot render t…
wobsoriano Sep 26, 2026
f94441c
fix(clerk-js,ui): reject the gated call when Protect blocks the attempt
wobsoriano Sep 26, 2026
ffb769d
fix(clerk-js,react): resolve Protect gates that arrive on the SSO cal…
wobsoriano Sep 26, 2026
db739a3
fix(clerk-js,ui): scope prebuilt Protect ownership to the flows each …
wobsoriano Sep 26, 2026
4043dd6
fix(clerk-js): serialize Protect resolutions instead of letting concu…
wobsoriano Sep 26, 2026
2234b9e
refactor(ui): render the Protect modal with the shared ProtectCheckCard
wobsoriano Sep 26, 2026
cef21e8
test(clerk-js,ui): drop Protect tests that repeat coverage and pin th…
wobsoriano Sep 26, 2026
55dd4ba
fix(clerk-js,ui): keep the Protect modal away from flows that route t…
wobsoriano Sep 26, 2026
f39cb0e
ci(repo): raise the clerk.native.js bundlewatch budget to 82KB
wobsoriano Sep 28, 2026
6472316
test(e2e): cover the Protect modal in custom flows and keep it out of…
wobsoriano Sep 29, 2026
6e768eb
fix(ui): keep the modalContent class on the Protect modal
wobsoriano Sep 29, 2026
db408a1
test(e2e): check the Protect modal with the suite's modal selector an…
wobsoriano Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/protect-check-modal.md
Original file line number Diff line number Diff line change
@@ -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 `<SignIn />` and `<SignUp />` components keep handling challenges inside their own cards.
81 changes: 81 additions & 0 deletions integration/tests/protect-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down Expand Up @@ -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();
Expand All @@ -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();
});
});
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
156 changes: 156 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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(
Expand Down
55 changes: 54 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import type {
__internal_EnableOrganizationsPromptProps,
__internal_OAuthConsentProps,
__internal_PlanDetailsProps,
__internal_ProtectCheckModalProps,
__internal_SubscriptionDetailsProps,
__internal_UserVerificationModalProps,
APIKeysNamespace,
Expand Down Expand Up @@ -108,6 +109,7 @@ import type {
OrganizationSwitcherProps,
PricingTableProps,
ProtectAssertion,
ProtectCheckFlow,
PublicKeyCredentialCreationOptionsWithoutExtensions,
PublicKeyCredentialRequestOptionsWithoutExtensions,
PublicKeyCredentialWithAuthenticatorAssertionResponse,
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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<void> => {
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<void> => {
if (!this.#clerkUI) {
return Promise.resolve();
}
return this.#clerkUI
.then(ui => ui.ensureMounted())
.then(controls => {
if (!controls.openProtectCheckModal) {
return;
}
return new Promise<void>((resolve, reject) => {
controls.openProtectCheckModal?.({
...props,
onResolved: () => {
controls.closeModal('protectCheck');
resolve();
},
onFailed: error => {
controls.closeModal('protectCheck');
reject(error);
},
});
});
});
};

public __internal_openBlankCaptchaModal = (): Promise<unknown> => {
this.assertComponentsReady(this.#clerkUI);
return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.openModal('blankCaptcha', {}));
Expand Down Expand Up @@ -2569,7 +2613,16 @@ export class Clerk implements ClerkInterface {
return this.__internal_handleResourceCallback(signInOrUp, params, customNavigate);
};

private _handleRedirectCallback = async (
private _handleRedirectCallback = async (...args: Parameters<Clerk['_routeRedirectCallback']>): Promise<unknown> => {
const release = ProtectCheckGate.getInstance().register(['signIn', 'signUp']);
try {
return await this._routeRedirectCallback(...args);
} finally {
release();
}
};

private _routeRedirectCallback = async (
params: ResumeAfterProtectCheckParams,
{
signIn,
Expand Down
Loading
Loading