From ff0794e7c93c815d556bc5445355dea1b7e97859 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:02 -0700 Subject: [PATCH 01/16] feat(shared): type the Protect check modal and flow claims --- packages/shared/src/types/clerk.ts | 25 +++++++++++++++++++++++++ packages/shared/src/ui/types.ts | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) 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 From 807f18f74b5491b3b0f247d4fed9b2556b166f6c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:02 -0700 Subject: [PATCH 02/16] feat(clerk-js,react): resolve protect_check gates in custom flows through the Protect modal --- .changeset/protect-check-modal.md | 8 + packages/clerk-js/bundlewatch.config.json | 2 +- .../clerk-js/src/core/__tests__/clerk.test.ts | 156 ++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 55 +++++- .../src/core/protectCheckGate.test.ts | 144 ++++++++++++++++ .../clerk-js/src/core/protectCheckGate.ts | 64 +++++++ packages/clerk-js/src/core/resources/Base.ts | 8 +- .../clerk-js/src/core/resources/SignIn.ts | 6 + .../clerk-js/src/core/resources/SignUp.ts | 6 + .../core/resources/__tests__/SignIn.test.ts | 47 ++++++ .../core/resources/__tests__/SignUp.test.ts | 47 ++++++ .../src/components/HandleSSOCallback.tsx | 6 + .../__tests__/HandleSSOCallback.test.tsx | 44 +++++ packages/react/src/isomorphicClerk.ts | 4 + 14 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 .changeset/protect-check-modal.md create mode 100644 packages/clerk-js/src/core/protectCheckGate.test.ts create mode 100644 packages/clerk-js/src/core/protectCheckGate.ts 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/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, From 46905682a0684dda762e19699e83d934ed055906 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:02 -0700 Subject: [PATCH 03/16] feat(ui): add the Protect modal and claim the flows the prebuilt components route themselves --- packages/ui/src/Components.tsx | 34 +++- .../components/GoogleOneTap/one-tap-start.tsx | 2 + .../__tests__/ProtectCheckModal.test.tsx | 173 ++++++++++++++++++ .../components/ProtectCheckModal/index.tsx | 63 +++++++ .../SignIn/__tests__/SignInRoutes.test.tsx | 25 +++ packages/ui/src/components/SignIn/index.tsx | 6 + .../SignUp/__tests__/SignUpRoutes.test.tsx | 25 +++ packages/ui/src/components/SignUp/index.tsx | 2 + packages/ui/src/elements/contexts/index.tsx | 1 + packages/ui/src/lazyModules/components.ts | 6 + 10 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx create mode 100644 packages/ui/src/components/ProtectCheckModal/index.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx create mode 100644 packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx 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, From c7143b34baba53d54d521e6bd9bd799d0a6f6fef Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:03 -0700 Subject: [PATCH 04/16] test(e2e): cover the Protect modal in custom flows and the prebuilt in-card route --- integration/tests/protect-check.test.ts | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) 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(); + }); +}); From 7547334701fe8ad97cd2e6b71d9f28b09e19879b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:03 -0700 Subject: [PATCH 05/16] fix(clerk-js,react,shared,ui): scope callback gates, narrow the One Tap claim, reject failed modals --- .../clerk-js/src/core/__tests__/clerk.test.ts | 31 ++++++- packages/clerk-js/src/core/clerk.ts | 13 ++- .../src/components/HandleSSOCallback.tsx | 4 +- .../__tests__/HandleSSOCallback.test.tsx | 22 +++++ packages/react/src/isomorphicClerk.ts | 5 +- packages/shared/src/types/clerk.ts | 7 +- .../__tests__/OneTapStart.test.tsx | 90 +++++++++++++++++++ .../components/GoogleOneTap/one-tap-start.tsx | 5 +- .../__tests__/ProtectCheckModal.test.tsx | 43 +++++++-- .../components/ProtectCheckModal/index.tsx | 11 +-- .../ui/src/hooks/useProtectCheckRunner.ts | 14 ++- 11 files changed, 211 insertions(+), 34 deletions(-) create mode 100644 packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index ade9bcc5c02..18ddda05689 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -4138,10 +4138,16 @@ describe('Clerk singleton', () => { 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 loadWithClient = async (client: { signIn: Record; signUp: Record }) => { + 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(); @@ -4150,6 +4156,27 @@ describe('Clerk singleton', () => { 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, '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); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 422de57a0b8..2d87b208dc0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -998,13 +998,18 @@ export class Clerk implements ClerkInterface { return ProtectCheckGate.getInstance().register(flows); }; - public __internal_resolvePendingProtectCheck = async (): Promise => { - if (!this.client) { + public __internal_resolvePendingProtectCheck = async (flow?: ProtectCheckFlow): Promise => { + const client = this.client; + if (!client || client.signIn.status === 'complete' || client.signUp.status === 'complete') { return; } const gate = ProtectCheckGate.getInstance(); - await gate.resolve(this, 'signIn', this.client.signIn); - await gate.resolve(this, 'signUp', this.client.signUp); + if (flow !== 'signUp') { + await gate.resolve(this, 'signIn', client.signIn); + } + if (flow !== 'signIn') { + await gate.resolve(this, 'signUp', client.signUp); + } }; public __internal_openProtectCheckModal = ( diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx index 476cf384eed..261c94fed8e 100644 --- a/packages/react/src/components/HandleSSOCallback.tsx +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -78,8 +78,10 @@ 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; + const intent = new URLSearchParams(window.location.search).get('intent'); + const flow = intent === 'signIn' || intent === 'signUp' ? intent : undefined; try { - await clerk.__internal_resolvePendingProtectCheck?.(); + await clerk.__internal_resolvePendingProtectCheck?.(flow); } catch { return navigateToSignIn(); } diff --git a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx index 4ed8ce726a3..1a29782611c 100644 --- a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx +++ b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx @@ -142,6 +142,28 @@ describe('', () => { ); }); + it('scopes the Protect gate to the intent carried by the callback URL', async () => { + const href = window.location.href; + window.history.replaceState(null, '', '/sso-callback?intent=signUp'); + mockSignUp = { status: 'missing_requirements' }; + + try { + render( + , + ); + + await waitFor(() => { + expect(mockResolvePendingProtectCheck).toHaveBeenCalledWith('signUp'); + }); + } finally { + window.history.replaceState(null, '', href); + } + }); + it('navigates to sign-in when Protect blocks the attempt', async () => { mockSignIn = { status: 'needs_protect_check' }; mockResolvePendingProtectCheck.mockRejectedValue(new Error('blocked')); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index a0c02640b80..6b972ec0fdd 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -48,6 +48,7 @@ import type { OrganizationSwitcherProps, PricingTableProps, ProtectAssertion, + ProtectCheckFlow, RedirectOptions, Resources, ResumeAfterProtectCheckParams, @@ -1649,8 +1650,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __internal_resolvePendingProtectCheck = async (): Promise => { - await this.clerkjs?.__internal_resolvePendingProtectCheck?.(); + __internal_resolvePendingProtectCheck = async (flow?: ProtectCheckFlow): Promise => { + await this.clerkjs?.__internal_resolvePendingProtectCheck?.(flow); }; __internal_resumeAfterProtectCheck = async ( diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index e1a52c2096b..8a944aa5925 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -354,10 +354,13 @@ export interface Clerk { /** * 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. + * Does nothing once either attempt is `complete`. Pass `flow` to resolve only that attempt's gate, + * so a callback for one flow does not open the challenge of a stale attempt on the other. Without + * `flow`, the sign-in gate is resolved before the sign-up gate. * * @internal */ - __internal_resolvePendingProtectCheck?: () => Promise; + __internal_resolvePendingProtectCheck?: (flow?: ProtectCheckFlow) => Promise; frontendApi: string; @@ -2001,7 +2004,7 @@ export type ProtectCheckFlow = 'signIn' | 'signUp'; export type __internal_ProtectCheckModalProps = { resource: SignInResource | SignUpResource; onResolved: () => void; - onFailed: (error: Error) => void; + onFailed: (error: unknown) => void; }; export type __internal_EnableOrganizationsPromptProps = { diff --git a/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx new file mode 100644 index 00000000000..943373a1c33 --- /dev/null +++ b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx @@ -0,0 +1,90 @@ +import type { SignInResource } from '@clerk/shared/types'; +import { waitFor } from '@testing-library/react'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { clearFetchCache } from '../../../hooks/useFetch'; +import { OneTapStart } from '../one-tap-start'; + +const { createFixtures } = bindCreateFixtures('GoogleOneTap'); + +const initialize = vi.fn(); + +const mountOneTap = async () => { + const { wrapper, fixtures } = await createFixtures(); + fixtures.environment.displayConfig.googleOneTapClientId = 'client-id'; + const release = vi.fn(); + const register = vi.fn().mockReturnValue(release); + fixtures.clerk.__internal_registerProtectCheckHandler = register; + + render(, { wrapper }); + await waitFor(() => expect(initialize).toHaveBeenCalledTimes(1)); + + const callback = initialize.mock.calls[0][0].callback as (response: { credential: string }) => Promise; + return { fixtures, register, release, callback }; +}; + +describe('OneTapStart', () => { + let consoleErrorSpy: ReturnType; + + beforeAll(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + beforeEach(() => { + initialize.mockReset(); + clearFetchCache(); + window.google = { accounts: { id: { initialize, prompt: vi.fn(), cancel: vi.fn() } } }; + }); + + it('does not claim the Protect flows on mount', async () => { + const { register } = await mountOneTap(); + + expect(register).not.toHaveBeenCalled(); + }); + + it('claims both Protect flows only while it authenticates and handles the callback', async () => { + const { fixtures, register, release, callback } = await mountOneTap(); + let finishAuth!: (value: SignInResource) => void; + fixtures.clerk.authenticateWithGoogleOneTap.mockReturnValue( + new Promise(resolve => { + finishAuth = resolve; + }), + ); + fixtures.clerk.handleGoogleOneTapCallback.mockResolvedValue(undefined); + + const pending = callback({ credential: 'cred' }); + + expect(register).toHaveBeenCalledWith(['signIn', 'signUp']); + expect(register.mock.invocationCallOrder[0]).toBeLessThan( + fixtures.clerk.authenticateWithGoogleOneTap.mock.invocationCallOrder[0], + ); + expect(release).not.toHaveBeenCalled(); + + finishAuth(fixtures.signIn); + await pending; + + expect(fixtures.clerk.handleGoogleOneTapCallback).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); + expect(release.mock.invocationCallOrder[0]).toBeGreaterThan( + fixtures.clerk.handleGoogleOneTapCallback.mock.invocationCallOrder[0], + ); + }); + + it('releases the claim when authentication throws', async () => { + const { fixtures, register, release, callback } = await mountOneTap(); + fixtures.clerk.authenticateWithGoogleOneTap.mockRejectedValue(new Error('nope')); + + await callback({ credential: 'cred' }); + + expect(register).toHaveBeenCalledWith(['signIn', 'signUp']); + expect(fixtures.clerk.handleGoogleOneTapCallback).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx b/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx index 1fc85763a96..62f1ff4cd0e 100644 --- a/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx +++ b/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx @@ -19,10 +19,9 @@ function OneTapStartInternal(): JSX.Element | null { const ctx = useGoogleOneTapContext(); - useEffect(() => clerk.__internal_registerProtectCheckHandler?.(['signIn', 'signUp']), [clerk]); - async function oneTapCallback(response: GISCredentialResponse) { isPromptedRef.current = false; + const release = clerk.__internal_registerProtectCheckHandler?.(['signIn', 'signUp']); try { const res = await clerk.authenticateWithGoogleOneTap({ token: response.credential, @@ -30,6 +29,8 @@ function OneTapStartInternal(): JSX.Element | null { await clerk.handleGoogleOneTapCallback(res, ctx.generateCallbackUrls(window.location.href), navigate); } catch (e: any) { console.error(e); + } finally { + release?.(); } } diff --git a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx index 3d7b9e0ad3e..5373a2c68af 100644 --- a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx +++ b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx @@ -1,4 +1,5 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import { waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -83,20 +84,42 @@ describe('ProtectCheckModal', () => { expect(onResolved).not.toHaveBeenCalled(); }); - it('keeps the modal open with a retry when the submit fails for a reason other than a block', async () => { + it('hands a submit failure other than a block to onFailed instead of offering a retry', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.startSignInWithProtectCheck(); }); const onResolved = vi.fn(); const onFailed = vi.fn(); + const invalid = new ClerkAPIResponseError('invalid', { + status: 422, + data: [{ code: 'form_param_invalid', message: 'invalid' } as any], + }); mockExecute.mockResolvedValue('proof-abc'); - fixtures.signIn.submitProtectCheck.mockRejectedValue( - new ClerkAPIResponseError('invalid', { - status: 422, - data: [{ code: 'form_param_invalid', message: 'invalid' } as any], - }), + fixtures.signIn.submitProtectCheck.mockRejectedValue(invalid); + + render( + , + { wrapper }, ); + await waitFor(() => expect(onFailed).toHaveBeenCalledWith(invalid)); + expect(screen.queryByRole('button', { name: /try again/i })).toBeNull(); + expect(onResolved).not.toHaveBeenCalled(); + }); + + it('hands the timed-out error to onFailed when the challenge is expired and a reload keeps it expired', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck({ expiresAt: Date.now() - 1000 }); + }); + const onResolved = vi.fn(); + const onFailed = vi.fn(); + const reloadMock = vi.fn().mockResolvedValue(fixtures.signIn); + (fixtures.signIn as any).reload = reloadMock; + render( { { wrapper }, ); - await screen.findByRole('button', { name: /try again/i }); - expect(onFailed).not.toHaveBeenCalled(); + await waitFor(() => + expect(onFailed).toHaveBeenCalledWith(expect.objectContaining({ code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT })), + ); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(mockExecute).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: /try again/i })).toBeNull(); expect(onResolved).not.toHaveBeenCalled(); }); diff --git a/packages/ui/src/components/ProtectCheckModal/index.tsx b/packages/ui/src/components/ProtectCheckModal/index.tsx index 3990f9b3623..09bd9788142 100644 --- a/packages/ui/src/components/ProtectCheckModal/index.tsx +++ b/packages/ui/src/components/ProtectCheckModal/index.tsx @@ -1,5 +1,3 @@ -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'; @@ -18,19 +16,14 @@ const ProtectCheckModalCard = withCardStateProvider( 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; - }), + submitProtectCheck: params => resource.submitProtectCheck(params), onResolved: (updated, isCancelled) => { if (!isCancelled() && !updated.protectCheck) { onResolved(); } return Promise.resolve(); }, + onError: onFailed, }); return ( diff --git a/packages/ui/src/hooks/useProtectCheckRunner.ts b/packages/ui/src/hooks/useProtectCheckRunner.ts index 885fcd98f53..22678724080 100644 --- a/packages/ui/src/hooks/useProtectCheckRunner.ts +++ b/packages/ui/src/hooks/useProtectCheckRunner.ts @@ -17,6 +17,7 @@ export interface ProtectCheckRunnerParams extends ProtectCheckRunnerR * `isCancelled` lets the continuation bail if the component unmounted mid-await. */ onResolved: (resource: TResource, isCancelled: () => boolean) => Promise; + onError?: (error: unknown) => void; } export interface ProtectCheckRunnerState { @@ -54,9 +55,18 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam const instanceTimeoutMs = useEnvironment().protectConfig?.challenge_load_timeout_ms; const loadTimeoutMs = loaderTimeoutMs ?? instanceTimeoutMs; + // Keep the latest callbacks without re-running the effect when the caller re-renders. + const paramsRef = React.useRef(params); + paramsRef.current = params; + // `handleError` re-throws what it does not recognise, and this runner awaits caller code that // raises plain errors (a transient fetch failure, an OAuth continuation that did not complete). const reportError = (err: any) => { + const { onError } = paramsRef.current; + if (onError) { + onError(err); + return; + } try { handleError(err, [], card.setError); } catch { @@ -84,10 +94,6 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam }; }, []); - // Keep the latest callbacks without re-running the effect when the caller re-renders. - const paramsRef = React.useRef(params); - paramsRef.current = params; - const reloadCountRef = React.useRef(0); const token = params.getProtectCheck()?.token; From 720e77e17f0bacfb67b946598f198eb8ca000ff3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:03 -0700 Subject: [PATCH 06/16] test(clerk-js,ui): drop Protect unit tests the e2e tests now cover --- .../clerk-js/src/core/__tests__/clerk.test.ts | 38 -------------- .../src/core/protectCheckGate.test.ts | 42 --------------- .../core/resources/__tests__/SignIn.test.ts | 10 ---- .../core/resources/__tests__/SignUp.test.ts | 10 ---- .../__tests__/OneTapStart.test.tsx | 9 +--- .../__tests__/ProtectCheckModal.test.tsx | 52 +------------------ 6 files changed, 3 insertions(+), 158 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 18ddda05689..26b81d87873 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -4092,34 +4092,6 @@ describe('Clerk singleton', () => { 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(); @@ -4176,16 +4148,6 @@ describe('Clerk singleton', () => { 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', () => { diff --git a/packages/clerk-js/src/core/protectCheckGate.test.ts b/packages/clerk-js/src/core/protectCheckGate.test.ts index 29530c3bcd0..1c7ff65bcd8 100644 --- a/packages/clerk-js/src/core/protectCheckGate.test.ts +++ b/packages/clerk-js/src/core/protectCheckGate.test.ts @@ -9,7 +9,6 @@ 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 = {}) => ({ @@ -42,28 +41,6 @@ describe('ProtectCheckGate', () => { 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']); @@ -80,12 +57,6 @@ describe('ProtectCheckGate', () => { 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) }); @@ -103,19 +74,6 @@ describe('ProtectCheckGate', () => { 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); 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 0050bef0448..3af0c78e264 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -3701,16 +3701,6 @@ describe('SignIn protect_check gate', () => { }, }; - 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); 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 8a267438ddb..6a87475de26 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -2361,16 +2361,6 @@ describe('SignUp protect_check gate', () => { }, }; - 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); diff --git a/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx index 943373a1c33..778f3d41e99 100644 --- a/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx +++ b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx @@ -43,13 +43,7 @@ describe('OneTapStart', () => { window.google = { accounts: { id: { initialize, prompt: vi.fn(), cancel: vi.fn() } } }; }); - it('does not claim the Protect flows on mount', async () => { - const { register } = await mountOneTap(); - - expect(register).not.toHaveBeenCalled(); - }); - - it('claims both Protect flows only while it authenticates and handles the callback', async () => { + it('claims both Protect flows only while it authenticates, not on mount', async () => { const { fixtures, register, release, callback } = await mountOneTap(); let finishAuth!: (value: SignInResource) => void; fixtures.clerk.authenticateWithGoogleOneTap.mockReturnValue( @@ -58,6 +52,7 @@ describe('OneTapStart', () => { }), ); fixtures.clerk.handleGoogleOneTapCallback.mockResolvedValue(undefined); + expect(register).not.toHaveBeenCalled(); const pending = callback({ credential: 'cred' }); diff --git a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx index 5373a2c68af..66f9d2031a0 100644 --- a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx +++ b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx @@ -31,30 +31,6 @@ beforeEach(() => { }); 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(); @@ -84,7 +60,7 @@ describe('ProtectCheckModal', () => { expect(onResolved).not.toHaveBeenCalled(); }); - it('hands a submit failure other than a block to onFailed instead of offering a retry', async () => { + it('hands a submit failure to onFailed instead of offering a retry', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.startSignInWithProtectCheck(); }); @@ -138,32 +114,6 @@ describe('ProtectCheckModal', () => { 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(); From f269ab419e88ded1eae749c1a80e74b3467eeeac Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 23:14:13 -0700 Subject: [PATCH 07/16] refactor(clerk-js,shared,ui): open the Protect modal only for Future API calls --- .../clerk-js/src/core/__tests__/clerk.test.ts | 49 +---------- packages/clerk-js/src/core/clerk.ts | 19 +---- .../src/core/protectCheckGate.test.ts | 28 +++--- .../clerk-js/src/core/protectCheckGate.ts | 31 ++----- packages/clerk-js/src/core/resources/Base.ts | 8 +- .../clerk-js/src/core/resources/SignIn.ts | 17 ++-- .../clerk-js/src/core/resources/SignUp.ts | 17 ++-- .../core/resources/__tests__/SignIn.test.ts | 38 +++++---- .../core/resources/__tests__/SignUp.test.ts | 38 +++++---- packages/shared/src/types/clerk.ts | 9 -- .../__tests__/OneTapStart.test.tsx | 85 ------------------- .../components/GoogleOneTap/one-tap-start.tsx | 3 - .../SignIn/__tests__/SignInRoutes.test.tsx | 25 ------ packages/ui/src/components/SignIn/index.tsx | 6 -- .../SignUp/__tests__/SignUpRoutes.test.tsx | 25 ------ packages/ui/src/components/SignUp/index.tsx | 2 - 16 files changed, 84 insertions(+), 316 deletions(-) delete mode 100644 packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx delete mode 100644 packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx delete mode 100644 packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 26b81d87873..0a2a7f40aaa 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -3187,49 +3187,6 @@ 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()', () => { @@ -4123,8 +4080,8 @@ describe('Clerk singleton', () => { await sut.__internal_resolvePendingProtectCheck(); - expect(resolve).toHaveBeenCalledWith(sut, 'signIn', sut.client?.signIn); - expect(resolve).toHaveBeenCalledWith(sut, 'signUp', sut.client?.signUp); + expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signIn); + expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signUp); resolve.mockRestore(); }); @@ -4145,7 +4102,7 @@ describe('Clerk singleton', () => { await sut.__internal_resolvePendingProtectCheck('signUp'); expect(resolve).toHaveBeenCalledTimes(1); - expect(resolve).toHaveBeenCalledWith(sut, 'signUp', sut.client?.signUp); + expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signUp); resolve.mockRestore(); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 2d87b208dc0..f5eaa05aa92 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -994,10 +994,6 @@ 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 (flow?: ProtectCheckFlow): Promise => { const client = this.client; if (!client || client.signIn.status === 'complete' || client.signUp.status === 'complete') { @@ -1005,10 +1001,10 @@ export class Clerk implements ClerkInterface { } const gate = ProtectCheckGate.getInstance(); if (flow !== 'signUp') { - await gate.resolve(this, 'signIn', client.signIn); + await gate.resolve(this, client.signIn); } if (flow !== 'signIn') { - await gate.resolve(this, 'signUp', client.signUp); + await gate.resolve(this, client.signUp); } }; @@ -2618,16 +2614,7 @@ export class Clerk implements ClerkInterface { return this.__internal_handleResourceCallback(signInOrUp, params, customNavigate); }; - private _handleRedirectCallback = async (...args: Parameters): Promise => { - const release = ProtectCheckGate.getInstance().register(['signIn', 'signUp']); - try { - return await this._routeRedirectCallback(...args); - } finally { - release(); - } - }; - - private _routeRedirectCallback = async ( + private _handleRedirectCallback = async ( params: ResumeAfterProtectCheckParams, { signIn, diff --git a/packages/clerk-js/src/core/protectCheckGate.test.ts b/packages/clerk-js/src/core/protectCheckGate.test.ts index 1c7ff65bcd8..53c2ff5fb45 100644 --- a/packages/clerk-js/src/core/protectCheckGate.test.ts +++ b/packages/clerk-js/src/core/protectCheckGate.test.ts @@ -29,7 +29,7 @@ describe('ProtectCheckGate', () => { const resource = gated(); let settled = false; - const pending = gate.resolve(clerk, 'signIn', resource).then(() => { + const pending = gate.resolve(clerk, resource).then(() => { settled = true; }); await Promise.resolve(); @@ -41,29 +41,21 @@ describe('ProtectCheckGate', () => { expect(settled).toBe(true); }); - it('counts registrations per flow and releases each one once', async () => { + it('leaves the proof submission to whatever runs the challenge', 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); + await gate.resolve(clerk, gated(), 'protect_check'); - releaseSignUp(); - await gate.resolve(clerk, 'signUp', gated('sua_1')); - expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(2); + 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()); + const outer = gate.resolve(clerk, gated()); let innerSettled = false; - const inner = gate.resolve(clerk, 'signIn', gated()).then(() => { + const inner = gate.resolve(clerk, gated()).then(() => { innerSettled = true; }); await Promise.resolve(); @@ -80,8 +72,8 @@ describe('ProtectCheckGate', () => { 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); + const outer = gate.resolve(clerk, gated('sia_1')); + const other = gate.resolve(clerk, signUp); await Promise.resolve(); expect(open).toHaveBeenCalledTimes(1); @@ -93,10 +85,10 @@ describe('ProtectCheckGate', () => { 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'); + await expect(gate.resolve(clerk, gated())).rejects.toThrow('no ui'); const next = mockClerk(); - await gate.resolve(next, 'signIn', gated()); + await gate.resolve(next, 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 index 831e45f547e..bb7b0a515f1 100644 --- a/packages/clerk-js/src/core/protectCheckGate.ts +++ b/packages/clerk-js/src/core/protectCheckGate.ts @@ -1,4 +1,4 @@ -import type { ProtectCheckFlow, SignInResource, SignUpResource } from '@clerk/shared/types'; +import type { SignInResource, SignUpResource } from '@clerk/shared/types'; import type { Clerk } from './resources/internal'; @@ -6,17 +6,14 @@ 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. + * Skips the proof submission, which belongs to whatever runs the challenge, 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(); @@ -24,25 +21,7 @@ export class 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 { + public async resolve(clerk: Clerk, resource: SignInResource | SignUpResource, action?: string): Promise { if (__BUILD_DISABLE_RHC__ || action === 'protect_check') { return; } @@ -52,7 +31,7 @@ export class ProtectCheckGate { } await this.inflight.promise.catch(() => {}); } - if (!resource.protectCheck || this.handlers[flow] > 0) { + if (!resource.protectCheck) { return; } const promise = clerk.__internal_openProtectCheckModal({ resource }).finally(() => { diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index e1ff96b1061..4ad63d5d01c 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -232,13 +232,7 @@ 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 }); - const resource = this.fromJSON((json?.response || json) as J); - await this._afterMutate(params); - return resource; - } - - protected _afterMutate(_params: BaseMutateParams): Promise { - return Promise.resolve(); + return this.fromJSON((json?.response || json) as J); } 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 e8ffc3c749f..c89e18b2c6b 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -101,7 +101,6 @@ import { } from '../errors'; import { eventBus } from '../events'; import { ProtectCheckGate } from '../protectCheckGate'; -import type { BaseMutateParams } from './internal'; import { BaseResource, UserData, Verification } from './internal'; /** @@ -170,7 +169,11 @@ export class SignIn extends BaseResource implements SignInResource { * This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance * of `SignIn`. */ - __internal_basePost = this._basePost.bind(this); + __internal_basePost: typeof this._basePost = async params => { + await this._basePost(params); + await ProtectCheckGate.getInstance().resolve(SignIn.clerk, this, params?.action); + return this; + }; /** * @internal Only used for internal purposes, and is not intended to be used directly. @@ -178,7 +181,11 @@ export class SignIn extends BaseResource implements SignInResource { * This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance * of `SignIn`. */ - __internal_basePatch = this._basePatch.bind(this); + __internal_basePatch: typeof this._basePatch = async params => { + await this._basePatch(params); + await ProtectCheckGate.getInstance().resolve(SignIn.clerk, this, params?.action); + return this; + }; /** * @internal Only used for internal purposes, and is not intended to be used directly. @@ -683,10 +690,6 @@ 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 fecf5de5e97..2722f968d87 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -70,7 +70,6 @@ import { } from '../errors'; import { eventBus } from '../events'; import { ProtectCheckGate } from '../protectCheckGate'; -import type { BaseMutateParams } from './internal'; import { BaseResource, SignUpVerifications } from './internal'; declare global { @@ -144,7 +143,11 @@ export class SignUp extends BaseResource implements SignUpResource { * This property is used to provide access to underlying Client methods to `SignUpFuture`, which wraps an instance * of `SignUp`. */ - __internal_basePost = this._basePost.bind(this); + __internal_basePost: typeof this._basePost = async params => { + await this._basePost(params); + await ProtectCheckGate.getInstance().resolve(SignUp.clerk, this, params?.action); + return this; + }; /** * @internal Only used for internal purposes, and is not intended to be used directly. @@ -152,7 +155,11 @@ export class SignUp extends BaseResource implements SignUpResource { * This property is used to provide access to underlying Client methods to `SignUpFuture`, which wraps an instance * of `SignUp`. */ - __internal_basePatch = this._basePatch.bind(this); + __internal_basePatch: typeof this._basePatch = async params => { + await this._basePatch(params); + await ProtectCheckGate.getInstance().resolve(SignUp.clerk, this, params?.action); + return this; + }; constructor(data: SignUpJSON | SignUpJSONSnapshot | null = null) { super(); @@ -530,10 +537,6 @@ 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 3af0c78e264..b78765f088e 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -19,10 +19,6 @@ 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 () { @@ -3681,31 +3677,39 @@ describe('SignIn', () => { }); describe('SignIn protect_check gate', () => { - const clerk = {} as any; let previousClerk: any; beforeEach(() => { previousClerk = SignIn.clerk; - SignIn.clerk = clerk; + SignIn.clerk = {} as any; + vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined); + BaseResource._fetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signin_123', + status: 'needs_protect_check', + protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' }, + }, + }); }); afterEach(() => { + vi.restoreAllMocks(); 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 Future call', async () => { + const signIn = new SignIn(); - it('leaves reloads to the caller', async () => { - BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); - const signIn = new SignIn({ id: 'signin_123' } as any); + await signIn.__internal_future.create({ identifier: 'user@example.com' }); + + expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(SignIn.clerk, signIn, undefined); + }); + + it('returns a classic call with the gate still pending', async () => { + const signIn = new SignIn(); - await signIn.reload(); + await signIn.create({ identifier: 'user@example.com' }); 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 6a87475de26..f5a4bfbd89a 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -20,10 +20,6 @@ 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 () { @@ -2341,31 +2337,39 @@ describe('SignUp', () => { }); describe('SignUp protect_check gate', () => { - const clerk = {} as any; let previousClerk: any; beforeEach(() => { previousClerk = SignUp.clerk; - SignUp.clerk = clerk; + SignUp.clerk = { __internal_environment: { displayConfig: { captchaOauthBypass: [] } } } as any; + vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined); + BaseResource._fetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signup_123', + status: 'needs_protect_check', + protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' }, + }, + }); }); afterEach(() => { + vi.restoreAllMocks(); 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 Future call', async () => { + const signUp = new SignUp(); - it('leaves reloads to the caller', async () => { - BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); - const signUp = new SignUp({ id: 'signup_123' } as any); + await signUp.__internal_future.create({ emailAddress: 'user@example.com' }); + + expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(SignUp.clerk, signUp, undefined); + }); + + it('returns a classic call with the gate still pending', async () => { + const signUp = new SignUp(); - await signUp.reload(); + await signUp.create({ emailAddress: 'user@example.com' }); expect(signUp.protectCheck?.token).toBe('challenge-token'); expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled(); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 8a944aa5925..ec23d03de7a 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -342,15 +342,6 @@ 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. diff --git a/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx deleted file mode 100644 index 778f3d41e99..00000000000 --- a/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { SignInResource } from '@clerk/shared/types'; -import { waitFor } from '@testing-library/react'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { bindCreateFixtures } from '@/test/create-fixtures'; -import { render } from '@/test/utils'; - -import { clearFetchCache } from '../../../hooks/useFetch'; -import { OneTapStart } from '../one-tap-start'; - -const { createFixtures } = bindCreateFixtures('GoogleOneTap'); - -const initialize = vi.fn(); - -const mountOneTap = async () => { - const { wrapper, fixtures } = await createFixtures(); - fixtures.environment.displayConfig.googleOneTapClientId = 'client-id'; - const release = vi.fn(); - const register = vi.fn().mockReturnValue(release); - fixtures.clerk.__internal_registerProtectCheckHandler = register; - - render(, { wrapper }); - await waitFor(() => expect(initialize).toHaveBeenCalledTimes(1)); - - const callback = initialize.mock.calls[0][0].callback as (response: { credential: string }) => Promise; - return { fixtures, register, release, callback }; -}; - -describe('OneTapStart', () => { - let consoleErrorSpy: ReturnType; - - beforeAll(() => { - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterAll(() => { - consoleErrorSpy.mockRestore(); - }); - - beforeEach(() => { - initialize.mockReset(); - clearFetchCache(); - window.google = { accounts: { id: { initialize, prompt: vi.fn(), cancel: vi.fn() } } }; - }); - - it('claims both Protect flows only while it authenticates, not on mount', async () => { - const { fixtures, register, release, callback } = await mountOneTap(); - let finishAuth!: (value: SignInResource) => void; - fixtures.clerk.authenticateWithGoogleOneTap.mockReturnValue( - new Promise(resolve => { - finishAuth = resolve; - }), - ); - fixtures.clerk.handleGoogleOneTapCallback.mockResolvedValue(undefined); - expect(register).not.toHaveBeenCalled(); - - const pending = callback({ credential: 'cred' }); - - expect(register).toHaveBeenCalledWith(['signIn', 'signUp']); - expect(register.mock.invocationCallOrder[0]).toBeLessThan( - fixtures.clerk.authenticateWithGoogleOneTap.mock.invocationCallOrder[0], - ); - expect(release).not.toHaveBeenCalled(); - - finishAuth(fixtures.signIn); - await pending; - - expect(fixtures.clerk.handleGoogleOneTapCallback).toHaveBeenCalledTimes(1); - expect(release).toHaveBeenCalledTimes(1); - expect(release.mock.invocationCallOrder[0]).toBeGreaterThan( - fixtures.clerk.handleGoogleOneTapCallback.mock.invocationCallOrder[0], - ); - }); - - it('releases the claim when authentication throws', async () => { - const { fixtures, register, release, callback } = await mountOneTap(); - fixtures.clerk.authenticateWithGoogleOneTap.mockRejectedValue(new Error('nope')); - - await callback({ credential: 'cred' }); - - expect(register).toHaveBeenCalledWith(['signIn', 'signUp']); - expect(fixtures.clerk.handleGoogleOneTapCallback).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx b/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx index 62f1ff4cd0e..6f058d40192 100644 --- a/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx +++ b/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx @@ -21,7 +21,6 @@ function OneTapStartInternal(): JSX.Element | null { async function oneTapCallback(response: GISCredentialResponse) { isPromptedRef.current = false; - const release = clerk.__internal_registerProtectCheckHandler?.(['signIn', 'signUp']); try { const res = await clerk.authenticateWithGoogleOneTap({ token: response.credential, @@ -29,8 +28,6 @@ function OneTapStartInternal(): JSX.Element | null { await clerk.handleGoogleOneTapCallback(res, ctx.generateCallbackUrls(window.location.href), navigate); } catch (e: any) { console.error(e); - } finally { - release?.(); } } diff --git a/packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx deleted file mode 100644 index fe12ac14ee7..00000000000 --- a/packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 83876f50638..01c6a532497 100644 --- a/packages/ui/src/components/SignIn/index.tsx +++ b/packages/ui/src/components/SignIn/index.tsx @@ -52,12 +52,6 @@ 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 deleted file mode 100644 index 6d8127c4204..00000000000 --- a/packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 e99aef64ffe..f5b94663dbe 100644 --- a/packages/ui/src/components/SignUp/index.tsx +++ b/packages/ui/src/components/SignUp/index.tsx @@ -31,8 +31,6 @@ function SignUpRoutes(): JSX.Element { usePreloadTasks(); const signUpContext = useSignUpContext(); - const clerk = useClerk(); - React.useEffect(() => clerk.__internal_registerProtectCheckHandler?.(['signUp']), [clerk]); return ( From ced6af798c7201dea659b155ccd27d14bb88349d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 23:15:04 -0700 Subject: [PATCH 08/16] fix(clerk-js): let calls without a Protect gate skip the queue behind an open modal --- .../clerk-js/src/core/protectCheckGate.test.ts | 17 +++++++++++++++++ packages/clerk-js/src/core/protectCheckGate.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/protectCheckGate.test.ts b/packages/clerk-js/src/core/protectCheckGate.test.ts index 53c2ff5fb45..2918431eb60 100644 --- a/packages/clerk-js/src/core/protectCheckGate.test.ts +++ b/packages/clerk-js/src/core/protectCheckGate.test.ts @@ -83,6 +83,23 @@ describe('ProtectCheckGate', () => { expect(open).toHaveBeenLastCalledWith({ resource: signUp }); }); + it('lets a call on a resource without a gate return while another resource holds the modal', async () => { + const deferred = createDeferredPromise(); + const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) }); + + const outer = gate.resolve(clerk, gated('sia_1')); + let clearSettled = false; + const clear = gate.resolve(clerk, { id: 'sua_1', protectCheck: null } as any).then(() => { + clearSettled = true; + }); + await Promise.resolve(); + const settledWhileModalOpen = clearSettled; + + deferred.resolve(); + await Promise.all([outer, clear]); + expect(settledWhileModalOpen).toBe(true); + }); + 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, gated())).rejects.toThrow('no ui'); diff --git a/packages/clerk-js/src/core/protectCheckGate.ts b/packages/clerk-js/src/core/protectCheckGate.ts index bb7b0a515f1..13c35cec014 100644 --- a/packages/clerk-js/src/core/protectCheckGate.ts +++ b/packages/clerk-js/src/core/protectCheckGate.ts @@ -22,7 +22,7 @@ export class ProtectCheckGate { } public async resolve(clerk: Clerk, resource: SignInResource | SignUpResource, action?: string): Promise { - if (__BUILD_DISABLE_RHC__ || action === 'protect_check') { + if (__BUILD_DISABLE_RHC__ || action === 'protect_check' || !resource.protectCheck) { return; } while (this.inflight) { From 91cce95cd94f6d5f4c7851547f4edf01ea16be41 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 23:16:49 -0700 Subject: [PATCH 09/16] fix(ui): resolve the Protect modal at once when its gate is already clear --- .../__tests__/ProtectCheckModal.test.tsx | 22 +++++++++++++++++++ .../components/ProtectCheckModal/index.tsx | 18 ++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx index 66f9d2031a0..03f1e9cd8b4 100644 --- a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx +++ b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx @@ -1,6 +1,7 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import { waitFor } from '@testing-library/react'; +import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; @@ -31,6 +32,27 @@ beforeEach(() => { }); describe('ProtectCheckModal', () => { + it('calls onResolved once when it mounts for a resource whose gate is already clear', async () => { + const { wrapper, fixtures } = await createFixtures(); + const onResolved = vi.fn(); + expect(fixtures.signIn.protectCheck).toBeFalsy(); + + render( + + + , + { wrapper }, + ); + + await waitFor(() => expect(onResolved).toHaveBeenCalled()); + expect(onResolved).toHaveBeenCalledTimes(1); + expect(mockExecute).not.toHaveBeenCalled(); + }); + it('runs a chained challenge and only calls onResolved after the last gate clears', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.startSignInWithProtectCheck(); diff --git a/packages/ui/src/components/ProtectCheckModal/index.tsx b/packages/ui/src/components/ProtectCheckModal/index.tsx index 09bd9788142..61d8cb43958 100644 --- a/packages/ui/src/components/ProtectCheckModal/index.tsx +++ b/packages/ui/src/components/ProtectCheckModal/index.tsx @@ -1,4 +1,5 @@ import type { __internal_ProtectCheckModalProps, SignInResource, SignUpResource } from '@clerk/shared/types'; +import { useEffect, useRef } from 'react'; import { withCardStateProvider } from '@/ui/elements/contexts'; @@ -35,7 +36,22 @@ const ProtectCheckModalCard = withCardStateProvider( }, ); -function ProtectCheckModal(props: __internal_ProtectCheckModalProps): JSX.Element { +function ProtectCheckModal(props: __internal_ProtectCheckModalProps): JSX.Element | null { + const { resource, onResolved } = props; + const isClearOnMount = useRef(!resource.protectCheck).current; + const didReportClearRef = useRef(false); + + useEffect(() => { + if (isClearOnMount && !didReportClearRef.current) { + didReportClearRef.current = true; + onResolved(); + } + }, [isClearOnMount, onResolved]); + + if (isClearOnMount) { + return null; + } + return (
From 4fdc08a4ab275cd85f069c983f6ab97b153770fe Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 23:18:06 -0700 Subject: [PATCH 10/16] docs(shared): say when the Future API resolves Protect challenges itself --- .changeset/protect-check-modal.md | 4 +++- packages/shared/src/types/signInFuture.ts | 6 ++++-- packages/shared/src/types/signUpFuture.ts | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.changeset/protect-check-modal.md b/.changeset/protect-check-modal.md index 50a6a335d95..df108002442 100644 --- a/.changeset/protect-check-modal.md +++ b/.changeset/protect-check-modal.md @@ -5,4 +5,6 @@ '@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. +Resolve Clerk Protect challenges in custom flows built with the `useSignIn()` and `useSignUp()` hooks. When a sign-in or sign-up method returns a Protect challenge, Clerk opens a modal over the page, runs the challenge, and returns from the method once it clears. Your flow doesn't need to render anything or call `submitProtectCheck()`. `` also resolves a challenge that arrives with an SSO redirect before it continues the flow. If Clerk's UI isn't loaded, these methods return with `protectCheck` set, as before. + +The prebuilt `` and `` components are unchanged and still run challenges inside their own cards. Classic resource methods such as `clerk.client.signIn.create()` are also unchanged. They return with `protectCheck` set, and the caller runs the challenge. diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index b8186f111ba..1f1ee50301e 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -364,7 +364,7 @@ export interface SignInFutureResource { *
  • `'needs_first_factor'` - One of the following [first factor verification](!first-factor-verification) strategies is missing: `'email_link'`, `'email_code'`, `passkey`, `password`, `'phone_code'`, `'web3_base_signature'`, `'web3_metamask_signature'`, `'web3_coinbase_wallet_signature'`, `'web3_okx_wallet_signature'`, `'web3_solana_signature'`, [`OAuthStrategy`](https://clerk.com/docs/reference/types/sso#oauthstrategy), or `'enterprise_sso'`.
  • *
  • `'needs_second_factor'` - One of the following [second factor verification](!second-factor-verification) strategies is missing: `'phone_code'`, `'totp'`, `'backup_code'`, `'email_code'`, or `'email_link'`.
  • *
  • `'needs_new_password'` - The user needs to set a new password. See the [dedicated custom flow](/docs/guides/development/custom-flows/authentication/forgot-password) guide for more information.
  • - *
  • `'needs_protect_check'` - A Clerk Protect challenge must be resolved before the sign-in can continue. This status is only returned when Protect mid-flow challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. Run the challenge described by `protectCheck` and resolve it via `submitProtectCheck()`. The pre-built components handle this automatically.
  • + *
  • `'needs_protect_check'` - A Clerk Protect challenge must be resolved before the sign-in can continue. This status is only returned when Protect mid-flow challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. When Clerk's UI is loaded, the sign-in methods resolve the challenge in a modal before they return. Otherwise, run the challenge described by `protectCheck` and resolve it via `submitProtectCheck()`. The pre-built components handle this automatically.
  • * */ readonly status: SignInStatus; @@ -423,6 +423,8 @@ export interface SignInFutureResource { /** * The current protect check challenge, if one is pending. Only populated when Protect mid-flow * challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. + * When Clerk's UI is loaded, the sign-in methods resolve the challenge in a modal before they + * return. Otherwise, run the challenge yourself and submit its proof token with `submitProtectCheck()`. */ readonly protectCheck: ProtectCheckResource | null; @@ -599,7 +601,7 @@ export interface SignInFutureResource { passkey: (params?: SignInFuturePasskeyParams) => Promise<{ error: ClerkError | null }>; /** - * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. + * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. Call it after running the challenge yourself when Clerk's UI isn't loaded. With the UI loaded, the other sign-in methods resolve the challenge in a modal before they return. */ submitProtectCheck: (params: SignInFutureSubmitProtectCheckParams) => Promise<{ error: ClerkError | null }>; diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index 31a341eb795..24ab5f23604 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -481,6 +481,8 @@ export interface SignUpFutureResource { /** * The current protect check challenge, if one is pending. Only populated when Protect mid-flow * challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. + * When Clerk's UI is loaded, the sign-up methods resolve the challenge in a modal before they + * return. Otherwise, run the challenge yourself and submit its proof token with `submitProtectCheck()`. */ readonly protectCheck: ProtectCheckResource | null; @@ -535,7 +537,7 @@ export interface SignUpFutureResource { web3: (params: SignUpFutureWeb3Params) => Promise<{ error: ClerkError | null }>; /** - * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. + * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. Call it after running the challenge yourself when Clerk's UI isn't loaded. With the UI loaded, the other sign-up methods resolve the challenge in a modal before they return. */ submitProtectCheck: (params: SignUpFutureSubmitProtectCheckParams) => Promise<{ error: ClerkError | null }>; From 37372ff3995459af6152de323730b7771b6ac871 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Sep 2026 13:24:07 -0700 Subject: [PATCH 11/16] fix(clerk-js): go to the SSO provider before the Protect challenge, like the prebuilt components --- .changeset/protect-check-modal.md | 2 +- .../clerk-js/src/core/resources/SignIn.ts | 92 ++++++++++---- .../clerk-js/src/core/resources/SignUp.ts | 42 +++++-- .../core/resources/__tests__/SignIn.test.ts | 116 ++++++++++++++++++ .../core/resources/__tests__/SignUp.test.ts | 103 ++++++++++++++++ 5 files changed, 319 insertions(+), 36 deletions(-) diff --git a/.changeset/protect-check-modal.md b/.changeset/protect-check-modal.md index df108002442..b62f3d06aa5 100644 --- a/.changeset/protect-check-modal.md +++ b/.changeset/protect-check-modal.md @@ -5,6 +5,6 @@ '@clerk/react': patch --- -Resolve Clerk Protect challenges in custom flows built with the `useSignIn()` and `useSignUp()` hooks. When a sign-in or sign-up method returns a Protect challenge, Clerk opens a modal over the page, runs the challenge, and returns from the method once it clears. Your flow doesn't need to render anything or call `submitProtectCheck()`. `` also resolves a challenge that arrives with an SSO redirect before it continues the flow. If Clerk's UI isn't loaded, these methods return with `protectCheck` set, as before. +Resolve Clerk Protect challenges in custom flows built with the `useSignIn()` and `useSignUp()` hooks. When a sign-in or sign-up method returns a Protect challenge, Clerk opens a modal over the page, runs the challenge, and returns from the method once it clears. Your flow doesn't need to render anything or call `submitProtectCheck()`. `` also resolves a challenge that arrives with an SSO redirect before it continues the flow. For SSO, the challenge runs when the user returns from the identity provider, through ``, matching the prebuilt components. If Clerk's UI isn't loaded, these methods return with `protectCheck` set, as before. The prebuilt `` and `` components are unchanged and still run challenges inside their own cards. Classic resource methods such as `clerk.client.signIn.create()` are also unchanged. They return with `protectCheck` set, and the caller runs the challenge. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index c89e18b2c6b..8ddc836e97a 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -101,7 +101,7 @@ import { } from '../errors'; import { eventBus } from '../events'; import { ProtectCheckGate } from '../protectCheckGate'; -import { BaseResource, UserData, Verification } from './internal'; +import { type BaseMutateParams, BaseResource, UserData, Verification } from './internal'; /** * Terminal states for email-link verification polling: `verified` (success), `expired` @@ -169,9 +169,14 @@ export class SignIn extends BaseResource implements SignInResource { * This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance * of `SignIn`. */ - __internal_basePost: typeof this._basePost = async params => { + __internal_basePost = async ( + params?: BaseMutateParams, + { resolveProtectCheck = true }: { resolveProtectCheck?: boolean } = {}, + ): Promise => { await this._basePost(params); - await ProtectCheckGate.getInstance().resolve(SignIn.clerk, this, params?.action); + if (resolveProtectCheck) { + await ProtectCheckGate.getInstance().resolve(SignIn.clerk, this, params?.action); + } return this; }; @@ -181,9 +186,14 @@ export class SignIn extends BaseResource implements SignInResource { * This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance * of `SignIn`. */ - __internal_basePatch: typeof this._basePatch = async params => { + __internal_basePatch = async ( + params?: BaseMutateParams, + { resolveProtectCheck = true }: { resolveProtectCheck?: boolean } = {}, + ): Promise => { await this._basePatch(params); - await ProtectCheckGate.getInstance().resolve(SignIn.clerk, this, params?.action); + if (resolveProtectCheck) { + await ProtectCheckGate.getInstance().resolve(SignIn.clerk, this, params?.action); + } return this; }; @@ -1098,7 +1108,7 @@ class SignInFuture implements SignInFutureResource { return { captchaToken, captchaWidgetType, captchaError }; } - private async _create(params: SignInFutureCreateParams): Promise { + private async _create(params: SignInFutureCreateParams, options?: { resolveProtectCheck?: boolean }): Promise { const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params); const timezone = params.timezone ?? getBrowserTimezone(); @@ -1111,10 +1121,13 @@ class SignInFuture implements SignInFutureResource { ...(timezone !== null ? { timezone } : {}), }; - await this.#resource.__internal_basePost({ - path: this.#resource.pathRoot, - body, - }); + await this.#resource.__internal_basePost( + { + path: this.#resource.pathRoot, + body, + }, + options, + ); } async create(params: SignInFutureCreateParams): Promise<{ error: ClerkError | null }> { @@ -1327,34 +1340,61 @@ class SignInFuture implements SignInFutureResource { const wouldReplayStaleRedirect = strategy !== 'enterprise_sso' && hasPendingRedirect; const shouldCreateSignIn = !this.#resource.id || wouldReplayStaleRedirect; + const isChallengePending = () => !!this.#resource.protectCheck || this.#resource.status === 'needs_protect_check'; + const pendingHandOff = () => { + const { status, externalVerificationRedirectURL } = this.#resource.firstFactorVerification; + return status === 'unverified' ? externalVerificationRedirectURL : null; + }; + const resolveChallenge = () => ProtectCheckGate.getInstance().resolve(SignIn.clerk, this.#resource); + + let challengedCreateHandOff: URL | null = null; + if (shouldCreateSignIn) { - await this._create({ - strategy, - ...routes, - identifier, - }); + await this._create( + { + strategy, + ...routes, + identifier, + }, + { resolveProtectCheck: false }, + ); + + if (isChallengePending()) { + challengedCreateHandOff = pendingHandOff(); + if (!challengedCreateHandOff) { + await resolveChallenge(); + } + } } if (strategy === 'enterprise_sso') { - await this.#resource.__internal_basePost({ - body: { - ...routes, - oidcPrompt, - enterpriseConnectionId, - strategy: 'enterprise_sso', + await this.#resource.__internal_basePost( + { + body: { + ...routes, + oidcPrompt, + enterpriseConnectionId, + strategy: 'enterprise_sso', + }, + action: 'prepare_first_factor', + coalesce: true, }, - action: 'prepare_first_factor', - coalesce: true, - }); + { resolveProtectCheck: false }, + ); + + if (isChallengePending() && !challengedCreateHandOff) { + await resolveChallenge(); + } } - const { status, externalVerificationRedirectURL } = this.#resource.firstFactorVerification; + const externalVerificationRedirectURL = challengedCreateHandOff ?? pendingHandOff(); - if (status === 'unverified' && externalVerificationRedirectURL) { + if (externalVerificationRedirectURL) { if (popup) { await _futureAuthenticateWithPopup(SignIn.clerk, { popup, externalVerificationRedirectURL }); // Pick up the modified SignIn resource await this.#resource.reload(); + await resolveChallenge(); } else { SignIn.clerk.__internal_windowNavigate(externalVerificationRedirectURL); } diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 2722f968d87..1babb4b914c 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -70,7 +70,7 @@ import { } from '../errors'; import { eventBus } from '../events'; import { ProtectCheckGate } from '../protectCheckGate'; -import { BaseResource, SignUpVerifications } from './internal'; +import { type BaseMutateParams, BaseResource, SignUpVerifications } from './internal'; declare global { interface Window { @@ -143,9 +143,14 @@ export class SignUp extends BaseResource implements SignUpResource { * This property is used to provide access to underlying Client methods to `SignUpFuture`, which wraps an instance * of `SignUp`. */ - __internal_basePost: typeof this._basePost = async params => { + __internal_basePost = async ( + params?: BaseMutateParams, + { resolveProtectCheck = true }: { resolveProtectCheck?: boolean } = {}, + ): Promise => { await this._basePost(params); - await ProtectCheckGate.getInstance().resolve(SignUp.clerk, this, params?.action); + if (resolveProtectCheck) { + await ProtectCheckGate.getInstance().resolve(SignUp.clerk, this, params?.action); + } return this; }; @@ -155,9 +160,14 @@ export class SignUp extends BaseResource implements SignUpResource { * This property is used to provide access to underlying Client methods to `SignUpFuture`, which wraps an instance * of `SignUp`. */ - __internal_basePatch: typeof this._basePatch = async params => { + __internal_basePatch = async ( + params?: BaseMutateParams, + { resolveProtectCheck = true }: { resolveProtectCheck?: boolean } = {}, + ): Promise => { await this._basePatch(params); - await ProtectCheckGate.getInstance().resolve(SignUp.clerk, this, params?.action); + if (resolveProtectCheck) { + await ProtectCheckGate.getInstance().resolve(SignUp.clerk, this, params?.action); + } return this; }; @@ -1132,7 +1142,7 @@ class SignUpFuture implements SignUpFutureResource { locale, }; if (this.#resource.id) { - return this.#resource.__internal_basePatch({ body }); + return this.#resource.__internal_basePatch({ body }, { resolveProtectCheck: false }); } // Inject browser locale and timezone only when creating the sign-up, so an existing // sign-up's values are not overwritten on update. @@ -1141,7 +1151,10 @@ class SignUpFuture implements SignUpFutureResource { if (browserTimezone !== null) { body.timezone = browserTimezone; } - return this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body }); + return this.#resource.__internal_basePost( + { path: this.#resource.pathRoot, body }, + { resolveProtectCheck: false }, + ); }; await authenticateFn().catch(async e => { @@ -1153,13 +1166,24 @@ class SignUpFuture implements SignUpFutureResource { throw e; }); - const { status, externalVerificationRedirectURL } = this.#resource.verifications.externalAccount; + const pendingHandOff = () => { + const { status, externalVerificationRedirectURL } = this.#resource.verifications.externalAccount; + return status === 'unverified' ? externalVerificationRedirectURL : null; + }; + const resolveChallenge = () => ProtectCheckGate.getInstance().resolve(SignUp.clerk, this.#resource); + + if (this.#resource.protectCheck && !pendingHandOff()) { + await resolveChallenge(); + } + + const externalVerificationRedirectURL = pendingHandOff(); - if (status === 'unverified' && externalVerificationRedirectURL) { + if (externalVerificationRedirectURL) { if (popup) { await _futureAuthenticateWithPopup(SignUp.clerk, { popup, externalVerificationRedirectURL }); // Pick up the modified SignUp resource await this.#resource.reload(); + await resolveChallenge(); } else { SignUp.clerk.__internal_windowNavigate(externalVerificationRedirectURL); } 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 b78765f088e..6614e1a1468 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -3714,4 +3714,120 @@ describe('SignIn protect_check gate', () => { expect(signIn.protectCheck?.token).toBe('challenge-token'); expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled(); }); + + describe('Future sso', () => { + let windowNavigate: ReturnType; + + const handOff = (url: string) => ({ status: 'unverified', external_verification_redirect_url: url }); + const challenged = (firstFactorVerification: ReturnType | null = null) => ({ + client: null, + response: { + id: 'signin_123', + status: 'needs_protect_check', + first_factor_verification: firstFactorVerification, + protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' }, + }, + }); + const cleared = (firstFactorVerification: ReturnType) => ({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor', first_factor_verification: firstFactorVerification }, + }); + + beforeEach(() => { + vi.stubGlobal('window', { location: { origin: 'https://example.com', href: 'https://example.com/sign-in' } }); + windowNavigate = vi.fn(); + SignIn.clerk = { + buildUrlWithAuth: vi.fn(url => url), + buildUrl: vi.fn(path => 'https://example.com' + path), + frontendApi: 'clerk.example.com', + __internal_windowNavigate: windowNavigate, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('goes to the provider when a challenged create carries a hand-off, leaving the challenge for the way back', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue(challenged(handOff('https://accounts.google.example/auth'))); + + const signIn = new SignIn(); + const { error } = await signIn.__internal_future.sso({ + strategy: 'oauth_google', + redirectUrl: 'https://example.com/protected', + redirectCallbackUrl: 'https://example.com/sso-callback', + }); + + expect(error).toBeNull(); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://accounts.google.example/auth')); + expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled(); + }); + + it('runs the challenge before going to the provider when a challenged create has no hand-off', async () => { + BaseResource._fetch = vi + .fn() + .mockResolvedValueOnce(challenged()) + .mockResolvedValueOnce(cleared(handOff('https://accounts.google.example/auth'))); + const resolve = vi.mocked(ProtectCheckGate.prototype.resolve).mockImplementation(async (_clerk, resource) => { + await resource.submitProtectCheck({ proofToken: 'proof' }); + }); + + const signIn = new SignIn(); + const { error } = await signIn.__internal_future.sso({ + strategy: 'oauth_google', + redirectUrl: 'https://example.com/protected', + redirectCallbackUrl: 'https://example.com/sso-callback', + }); + + expect(error).toBeNull(); + expect(resolve).toHaveBeenCalledTimes(1); + expect(resolve.mock.calls[0][1]).toBe(signIn); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://accounts.google.example/auth')); + expect(resolve.mock.invocationCallOrder[0]).toBeLessThan(windowNavigate.mock.invocationCallOrder[0]); + }); + + it('follows the create hand-off when the enterprise SSO prepare after it hits the same pending challenge', async () => { + const mockFetch = vi.fn().mockResolvedValue(challenged(handOff('https://idp.example/from-create'))); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + const { error } = await signIn.__internal_future.sso({ + strategy: 'enterprise_sso', + redirectUrl: 'https://example.com/protected', + redirectCallbackUrl: 'https://example.com/sso-callback', + }); + + expect(error).toBeNull(); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.example/from-create')); + expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled(); + }); + + it('runs a challenge that is waiting when the popup returns', async () => { + const popup = { location: { href: '' } } as Window; + BaseResource._fetch = vi + .fn() + .mockResolvedValueOnce(cleared(handOff('https://accounts.google.example/auth'))) + .mockResolvedValueOnce(challenged()); + vi.mocked(_futureAuthenticateWithPopup).mockResolvedValue(undefined); + + const signIn = new SignIn(); + const { error } = await signIn.__internal_future.sso({ + strategy: 'oauth_google', + redirectUrl: 'https://example.com/protected', + redirectCallbackUrl: 'https://example.com/sso-callback', + popup, + }); + + expect(error).toBeNull(); + expect(_futureAuthenticateWithPopup).toHaveBeenCalledTimes(1); + expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledTimes(1); + expect(vi.mocked(ProtectCheckGate.prototype.resolve).mock.calls[0][1]).toBe(signIn); + expect(vi.mocked(_futureAuthenticateWithPopup).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(ProtectCheckGate.prototype.resolve).mock.invocationCallOrder[0], + ); + }); + }); }); 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 f5a4bfbd89a..b5fdc758c6b 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -2374,4 +2374,107 @@ describe('SignUp protect_check gate', () => { expect(signUp.protectCheck?.token).toBe('challenge-token'); expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled(); }); + + describe('Future sso', () => { + let windowNavigate: ReturnType; + + const handOff = (url: string) => ({ status: 'unverified', external_verification_redirect_url: url }); + const challenged = (externalAccount: ReturnType | null = null) => ({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + verifications: { external_account: externalAccount }, + protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' }, + }, + }); + const cleared = (externalAccount: ReturnType) => ({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + verifications: { external_account: externalAccount }, + }, + }); + + beforeEach(() => { + vi.stubGlobal('window', { location: { origin: 'https://example.com', href: 'https://example.com/sign-up' } }); + windowNavigate = vi.fn(); + SignUp.clerk = { + buildUrlWithAuth: vi.fn(url => url), + buildUrl: vi.fn(path => 'https://example.com' + path), + frontendApi: 'clerk.example.com', + __internal_windowNavigate: windowNavigate, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('goes to the provider when a challenged create carries a hand-off, leaving the challenge for the way back', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue(challenged(handOff('https://accounts.google.example/auth'))); + + const signUp = new SignUp(); + const { error } = await signUp.__internal_future.sso({ + strategy: 'oauth_google', + redirectUrl: 'https://example.com/protected', + redirectCallbackUrl: 'https://example.com/sso-callback', + }); + + expect(error).toBeNull(); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://accounts.google.example/auth')); + expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled(); + }); + + it('runs the challenge before going to the provider when a challenged create has no hand-off', async () => { + BaseResource._fetch = vi + .fn() + .mockResolvedValueOnce(challenged()) + .mockResolvedValueOnce(cleared(handOff('https://accounts.google.example/auth'))); + const resolve = vi.mocked(ProtectCheckGate.prototype.resolve).mockImplementation(async (_clerk, resource) => { + await resource.submitProtectCheck({ proofToken: 'proof' }); + }); + + const signUp = new SignUp(); + const { error } = await signUp.__internal_future.sso({ + strategy: 'oauth_google', + redirectUrl: 'https://example.com/protected', + redirectCallbackUrl: 'https://example.com/sso-callback', + }); + + expect(error).toBeNull(); + expect(resolve).toHaveBeenCalledTimes(1); + expect(resolve.mock.calls[0][1]).toBe(signUp); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://accounts.google.example/auth')); + expect(resolve.mock.invocationCallOrder[0]).toBeLessThan(windowNavigate.mock.invocationCallOrder[0]); + }); + + it('runs a challenge that is waiting when the popup returns', async () => { + const popup = { location: { href: '' } } as Window; + BaseResource._fetch = vi + .fn() + .mockResolvedValueOnce(cleared(handOff('https://accounts.google.example/auth'))) + .mockResolvedValueOnce(challenged()); + vi.mocked(_futureAuthenticateWithPopup).mockResolvedValue(undefined); + + const signUp = new SignUp(); + const { error } = await signUp.__internal_future.sso({ + strategy: 'oauth_google', + redirectUrl: 'https://example.com/protected', + redirectCallbackUrl: 'https://example.com/sso-callback', + popup, + }); + + expect(error).toBeNull(); + expect(_futureAuthenticateWithPopup).toHaveBeenCalledTimes(1); + expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledTimes(1); + expect(vi.mocked(ProtectCheckGate.prototype.resolve).mock.calls[0][1]).toBe(signUp); + expect(vi.mocked(_futureAuthenticateWithPopup).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(ProtectCheckGate.prototype.resolve).mock.invocationCallOrder[0], + ); + }); + }); }); From 896f221501dfc3afbe63e09571ba8f26e3548d6c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Sep 2026 13:30:24 -0700 Subject: [PATCH 12/16] test(e2e): check a custom-flow SSO reaches the provider before the Protect challenge --- integration/tests/protect-check.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/integration/tests/protect-check.test.ts b/integration/tests/protect-check.test.ts index 9874be08247..8bbf0d5e10a 100644 --- a/integration/tests/protect-check.test.ts +++ b/integration/tests/protect-check.test.ts @@ -146,4 +146,29 @@ test.describe('protect check in custom flows @custom', () => { 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()); + } + }); + + 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(protectCheckRequests).toEqual([]); + }); }); From e064028cf4180fffd23c04525618fd69b0e3c761 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Sep 2026 13:38:49 -0700 Subject: [PATCH 13/16] fix(clerk-js): prepare enterprise SSO again after its Protect challenge clears --- .../clerk-js/src/core/resources/SignIn.ts | 28 +++++++++-------- .../core/resources/__tests__/SignIn.test.ts | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 8ddc836e97a..94a4916c196 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1368,22 +1368,26 @@ class SignInFuture implements SignInFutureResource { } if (strategy === 'enterprise_sso') { - await this.#resource.__internal_basePost( - { - body: { - ...routes, - oidcPrompt, - enterpriseConnectionId, - strategy: 'enterprise_sso', + const prepare = (options?: { resolveProtectCheck?: boolean }) => + this.#resource.__internal_basePost( + { + body: { + ...routes, + oidcPrompt, + enterpriseConnectionId, + strategy: 'enterprise_sso', + }, + action: 'prepare_first_factor', + coalesce: true, }, - action: 'prepare_first_factor', - coalesce: true, - }, - { resolveProtectCheck: false }, - ); + options, + ); + + await prepare({ resolveProtectCheck: false }); if (isChallengePending() && !challengedCreateHandOff) { await resolveChallenge(); + await prepare(); } } 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 6614e1a1468..5d2c983ceba 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -3805,6 +3805,36 @@ describe('SignIn protect_check gate', () => { expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled(); }); + it('prepares enterprise SSO again after a challenged prepare instead of following an older redirect', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(challenged()) + .mockResolvedValueOnce(cleared(handOff('https://idp.example/fresh'))); + BaseResource._fetch = mockFetch; + const resolve = vi.mocked(ProtectCheckGate.prototype.resolve).mockImplementation(async (_clerk, resource) => { + (resource as SignIn).protectCheck = null; + }); + + const signIn = new SignIn({ + id: 'signin_123', + object: 'sign_in', + status: 'needs_first_factor', + first_factor_verification: handOff('https://idp.example/stale'), + } as any); + const { error } = await signIn.__internal_future.sso({ + strategy: 'enterprise_sso', + redirectUrl: 'https://example.com/protected', + redirectCallbackUrl: 'https://example.com/sso-callback', + }); + + expect(error).toBeNull(); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls.every(([init]) => init.path.endsWith('/prepare_first_factor'))).toBe(true); + expect(resolve.mock.invocationCallOrder[0]).toBeLessThan(mockFetch.mock.invocationCallOrder[1]); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.example/fresh')); + expect(windowNavigate).toHaveBeenCalledTimes(1); + }); + it('runs a challenge that is waiting when the popup returns', async () => { const popup = { location: { href: '' } } as Window; BaseResource._fetch = vi From 3927a229d71af517000dbf377a38773fc82df2f9 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Sep 2026 13:38:50 -0700 Subject: [PATCH 14/16] docs(shared): note that sso() runs the Protect challenge on the way back --- packages/shared/src/types/signInFuture.ts | 6 ++++-- packages/shared/src/types/signUpFuture.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 1f1ee50301e..fe1ff3d48eb 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -364,7 +364,7 @@ export interface SignInFutureResource { *
  • `'needs_first_factor'` - One of the following [first factor verification](!first-factor-verification) strategies is missing: `'email_link'`, `'email_code'`, `passkey`, `password`, `'phone_code'`, `'web3_base_signature'`, `'web3_metamask_signature'`, `'web3_coinbase_wallet_signature'`, `'web3_okx_wallet_signature'`, `'web3_solana_signature'`, [`OAuthStrategy`](https://clerk.com/docs/reference/types/sso#oauthstrategy), or `'enterprise_sso'`.
  • *
  • `'needs_second_factor'` - One of the following [second factor verification](!second-factor-verification) strategies is missing: `'phone_code'`, `'totp'`, `'backup_code'`, `'email_code'`, or `'email_link'`.
  • *
  • `'needs_new_password'` - The user needs to set a new password. See the [dedicated custom flow](/docs/guides/development/custom-flows/authentication/forgot-password) guide for more information.
  • - *
  • `'needs_protect_check'` - A Clerk Protect challenge must be resolved before the sign-in can continue. This status is only returned when Protect mid-flow challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. When Clerk's UI is loaded, the sign-in methods resolve the challenge in a modal before they return. Otherwise, run the challenge described by `protectCheck` and resolve it via `submitProtectCheck()`. The pre-built components handle this automatically.
  • + *
  • `'needs_protect_check'` - A Clerk Protect challenge must be resolved before the sign-in can continue. This status is only returned when Protect mid-flow challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. When Clerk's UI is loaded, the sign-in methods resolve the challenge in a modal before they return. Otherwise, run the challenge described by `protectCheck` and resolve it via `submitProtectCheck()`. When `sso()` redirects to the identity provider, the challenge runs on the way back through `` instead. The pre-built components handle this automatically.
  • * */ readonly status: SignInStatus; @@ -425,6 +425,8 @@ export interface SignInFutureResource { * challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. * When Clerk's UI is loaded, the sign-in methods resolve the challenge in a modal before they * return. Otherwise, run the challenge yourself and submit its proof token with `submitProtectCheck()`. + * When `sso()` redirects to the identity provider, the challenge runs on the way back through + * `` instead. */ readonly protectCheck: ProtectCheckResource | null; @@ -601,7 +603,7 @@ export interface SignInFutureResource { passkey: (params?: SignInFuturePasskeyParams) => Promise<{ error: ClerkError | null }>; /** - * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. Call it after running the challenge yourself when Clerk's UI isn't loaded. With the UI loaded, the other sign-in methods resolve the challenge in a modal before they return. + * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. Call it after running the challenge yourself when Clerk's UI isn't loaded. With the UI loaded, the other sign-in methods resolve the challenge in a modal before they return. When `sso()` redirects to the identity provider, the challenge runs on the way back through `` instead. */ submitProtectCheck: (params: SignInFutureSubmitProtectCheckParams) => Promise<{ error: ClerkError | null }>; diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index 24ab5f23604..773a94043c2 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -483,6 +483,8 @@ export interface SignUpFutureResource { * challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. * When Clerk's UI is loaded, the sign-up methods resolve the challenge in a modal before they * return. Otherwise, run the challenge yourself and submit its proof token with `submitProtectCheck()`. + * When `sso()` redirects to the identity provider, the challenge runs on the way back through + * `` instead. */ readonly protectCheck: ProtectCheckResource | null; @@ -537,7 +539,7 @@ export interface SignUpFutureResource { web3: (params: SignUpFutureWeb3Params) => Promise<{ error: ClerkError | null }>; /** - * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. Call it after running the challenge yourself when Clerk's UI isn't loaded. With the UI loaded, the other sign-up methods resolve the challenge in a modal before they return. + * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. Call it after running the challenge yourself when Clerk's UI isn't loaded. With the UI loaded, the other sign-up methods resolve the challenge in a modal before they return. When `sso()` redirects to the identity provider, the challenge runs on the way back through `` instead. */ submitProtectCheck: (params: SignUpFutureSubmitProtectCheckParams) => Promise<{ error: ClerkError | null }>; From 47eaf52db382f3dcba2d2516d602c92963809af7 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Sep 2026 13:38:51 -0700 Subject: [PATCH 15/16] test(e2e): assert the SSO create was challenged before checking the order --- integration/tests/protect-check.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/integration/tests/protect-check.test.ts b/integration/tests/protect-check.test.ts index 8bbf0d5e10a..f13771eb6ee 100644 --- a/integration/tests/protect-check.test.ts +++ b/integration/tests/protect-check.test.ts @@ -155,6 +155,18 @@ test.describe('protect check in custom flows @custom', () => { 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(); @@ -169,6 +181,7 @@ test.describe('protect check in custom flows @custom', () => { }); await providerRedirect; + expect(createStatus).toBe('needs_protect_check'); expect(protectCheckRequests).toEqual([]); }); }); From 7d3c241da311847d871679437a07b97a8f5666b2 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Sep 2026 14:07:58 -0700 Subject: [PATCH 16/16] docs(repo): shorten the Protect challenge changeset --- .changeset/protect-check-modal.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/protect-check-modal.md b/.changeset/protect-check-modal.md index b62f3d06aa5..a19cee76d7f 100644 --- a/.changeset/protect-check-modal.md +++ b/.changeset/protect-check-modal.md @@ -5,6 +5,4 @@ '@clerk/react': patch --- -Resolve Clerk Protect challenges in custom flows built with the `useSignIn()` and `useSignUp()` hooks. When a sign-in or sign-up method returns a Protect challenge, Clerk opens a modal over the page, runs the challenge, and returns from the method once it clears. Your flow doesn't need to render anything or call `submitProtectCheck()`. `` also resolves a challenge that arrives with an SSO redirect before it continues the flow. For SSO, the challenge runs when the user returns from the identity provider, through ``, matching the prebuilt components. If Clerk's UI isn't loaded, these methods return with `protectCheck` set, as before. - -The prebuilt `` and `` components are unchanged and still run challenges inside their own cards. Classic resource methods such as `clerk.client.signIn.create()` are also unchanged. They return with `protectCheck` set, and the caller runs the challenge. +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 ``. Classic resource methods such as `clerk.client.signIn.create()`, and apps without Clerk's UI loaded, still return with `protectCheck` set, as before.