Skip to content
Merged
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
ff0794e
feat(shared): type the Protect check modal and flow claims
wobsoriano Sep 29, 2026
807f18f
feat(clerk-js,react): resolve protect_check gates in custom flows thr…
wobsoriano Sep 29, 2026
4690568
feat(ui): add the Protect modal and claim the flows the prebuilt comp…
wobsoriano Sep 29, 2026
c7143b3
test(e2e): cover the Protect modal in custom flows and the prebuilt i…
wobsoriano Sep 29, 2026
7547334
fix(clerk-js,react,shared,ui): scope callback gates, narrow the One T…
wobsoriano Sep 29, 2026
720e77e
test(clerk-js,ui): drop Protect unit tests the e2e tests now cover
wobsoriano Sep 29, 2026
f269ab4
refactor(clerk-js,shared,ui): open the Protect modal only for Future …
wobsoriano Sep 30, 2026
ced6af7
fix(clerk-js): let calls without a Protect gate skip the queue behind…
wobsoriano Sep 30, 2026
91cce95
fix(ui): resolve the Protect modal at once when its gate is already c…
wobsoriano Sep 30, 2026
4fdc08a
docs(shared): say when the Future API resolves Protect challenges itself
wobsoriano Sep 30, 2026
37372ff
fix(clerk-js): go to the SSO provider before the Protect challenge, l…
wobsoriano Sep 30, 2026
896f221
test(e2e): check a custom-flow SSO reaches the provider before the Pr…
wobsoriano Sep 30, 2026
e064028
fix(clerk-js): prepare enterprise SSO again after its Protect challen…
wobsoriano Sep 30, 2026
3927a22
docs(shared): note that sso() runs the Protect challenge on the way back
wobsoriano Sep 30, 2026
47eaf52
test(e2e): assert the SSO create was challenged before checking the o…
wobsoriano Sep 30, 2026
7d3c241
docs(repo): shorten the Protect challenge changeset
wobsoriano Sep 30, 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
---

Custom flows built with `useSignIn()` and `useSignUp()` now handle Clerk Protect challenges for you. When a sign-in or sign-up method gets a challenge, Clerk shows it in a modal and the method returns once the user passes it. For SSO, the challenge appears when the user comes back to `<HandleSSOCallback />`. Classic resource methods such as `clerk.client.signIn.create()`, and apps without Clerk's UI loaded, still return with `protectCheck` set, as before.
119 changes: 119 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,124 @@ 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();
});

test('goes to the SSO provider before the Protect challenge', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const protectCheckRequests: string[] = [];
page.on('request', request => {
if (request.url().includes('/protect_check')) {
protectCheckRequests.push(request.url());
}
});
let createStatus: string | undefined;
await page.route(
url => url.pathname.endsWith('/v1/client/sign_ins'),
async route => {
if (route.request().method() !== 'POST') {
return route.fallback();
}
const response = await route.fetch();
createStatus = (await response.json()).response?.status;
await route.fulfill({ response });
},
);

await u.page.goToRelative('/sign-in');
await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
await page.waitForFunction(() => !!window.Clerk?.loaded && !!window.Clerk?.client);
const providerRedirect = page.waitForURL(/accounts\.google\.com/, { waitUntil: 'commit' });
await page.evaluate(() => {
void window.Clerk.client?.signIn.__internal_future.sso({
strategy: 'oauth_google',
redirectUrl: '/protected',
redirectCallbackUrl: '/sso-callback',
});
});

await providerRedirect;
expect(createStatus).toBe('needs_protect_check');
expect(protectCheckRequests).toEqual([]);
});
});
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
102 changes: 102 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 @@ -4005,6 +4006,107 @@ 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('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');
});

const loadWithClient = async (client: { signIn: Record<string, unknown>; signUp: Record<string, unknown> }) => {
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [], ...client }));
const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
return sut;
};

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 = await loadWithClient({ signIn: gatedSignIn(), signUp: gatedSignIn() });

await sut.__internal_resolvePendingProtectCheck();

expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signIn);
expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signUp);
resolve.mockRestore();
});

it('leaves every gate alone once the sign-in is complete', async () => {
const resolve = vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
const sut = await loadWithClient({ signIn: { status: 'complete' }, signUp: gatedSignIn() });

await sut.__internal_resolvePendingProtectCheck();

expect(resolve).not.toHaveBeenCalled();
resolve.mockRestore();
});

it('resolves only the sign-up gate when the callback is a sign-up', async () => {
const resolve = vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
const sut = await loadWithClient({ signIn: gatedSignIn(), signUp: gatedSignIn() });

await sut.__internal_resolvePendingProtectCheck('signUp');

expect(resolve).toHaveBeenCalledTimes(1);
expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signUp);
resolve.mockRestore();
});
});

describe('ui.ClerkUI option', () => {
beforeEach(() => {
mockEnvironmentFetch.mockReturnValue(
Expand Down
45 changes: 45 additions & 0 deletions 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,48 @@ export class Clerk implements ClerkInterface {
.then(controls => controls.closeModal('enableOrganizationsPrompt'));
};

public __internal_resolvePendingProtectCheck = async (flow?: ProtectCheckFlow): Promise<void> => {
const client = this.client;
if (!client || client.signIn.status === 'complete' || client.signUp.status === 'complete') {
return;
}
const gate = ProtectCheckGate.getInstance();
if (flow !== 'signUp') {
await gate.resolve(this, client.signIn);
}
if (flow !== 'signIn') {
await gate.resolve(this, client.signUp);
}
};

public __internal_openProtectCheckModal = (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with no UI loaded, or a UI version that predates the modal, this resolves right away and leaves the gate on the resource, same as today

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
Loading
Loading