Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c4118d8
feat(shared): add ProtectCheckRunner for the protect_check lifecycle
wobsoriano Sep 25, 2026
e1cd239
refactor(ui): drive useProtectCheckRunner with the shared ProtectChec…
wobsoriano Sep 25, 2026
78c0a7d
feat(shared): type the Protect check modal props and handler registra…
wobsoriano Sep 25, 2026
a8c7017
feat(clerk-js): resolve protect_check gates through the Protect modal
wobsoriano Sep 25, 2026
53716b4
feat(ui): add the ProtectCheckModal for custom flows
wobsoriano Sep 25, 2026
e87e630
refactor(clerk-js): run the Protect gate from a protected hook on Sig…
wobsoriano Sep 26, 2026
2d6ddce
fix(clerk-js,ui): leave the gate in place when the UI cannot render t…
wobsoriano Sep 26, 2026
c75c16f
fix(clerk-js,ui): reject the gated call when Protect blocks the attempt
wobsoriano Sep 26, 2026
3642a50
fix(clerk-js,react): resolve Protect gates that arrive on the SSO cal…
wobsoriano Sep 26, 2026
c9b0c5b
fix(clerk-js,ui): scope prebuilt Protect ownership to the flows each …
wobsoriano Sep 26, 2026
1faa691
fix(clerk-js): serialize Protect resolutions instead of letting concu…
wobsoriano Sep 26, 2026
a5efead
refactor(ui): share one ProtectCheckCard across the prebuilt cards an…
wobsoriano Sep 26, 2026
4339893
refactor(ui): use relative imports in ProtectCheckCard like the rest …
wobsoriano Sep 26, 2026
aac0989
test(clerk-js,ui): drop Protect tests that repeat coverage and pin th…
wobsoriano Sep 26, 2026
05bbbc0
fix(clerk-js,ui): keep the Protect modal away from flows that route t…
wobsoriano Sep 26, 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.
5 changes: 5 additions & 0 deletions .changeset/protect-check-runner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Add `ProtectCheckRunner` under `@clerk/shared/internal/clerk-js/protectCheckRunner`, a framework-free driver for the Protect `protect_check` challenge lifecycle. The prebuilt protect-check cards now use it.
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 @@ -17,6 +17,7 @@ import { mockJwt } from '@/test/core-fixtures';
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 @@ -3184,6 +3185,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 @@ -4003,6 +4047,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