diff --git a/.changeset/protect-check-support.md b/.changeset/protect-check-support.md new file mode 100644 index 00000000000..4c50e840b92 --- /dev/null +++ b/.changeset/protect-check-support.md @@ -0,0 +1,21 @@ +--- +'@clerk/clerk-js': minor +'@clerk/localizations': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add support for Clerk Protect mid-flow SDK challenges (`protect_check`) on both sign-up and sign-in. + +When the Protect antifraud service issues a challenge, responses now carry a `protectCheck` field +with `{ status, token, sdkUrl, expiresAt?, uiHints? }`. Clients resolve the gate by loading the +SDK at `sdkUrl`, executing the challenge, and submitting the resulting proof token via +`signUp.submitProtectCheck({ proofToken })` or `signIn.submitProtectCheck({ proofToken })`. The +response may carry a chained challenge, which the SDK resolves iteratively. + +Sign-in adds a new `'needs_protect_check'` value to the `SignInStatus` union, surfaced when the +server-side SDK-version gate is enabled. Clients should treat the `protectCheck` field as the +authoritative gate signal and fall back to the status value for defense in depth. + +The pre-built `` and `` components handle the gate automatically by routing +to a new `protect-check` route that runs the challenge SDK and resumes the flow on completion. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 4663e15eafe..50b61a38505 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2241,6 +2241,7 @@ export class Clerk implements ClerkInterface { firstFactorVerificationErrorCode: firstFactorVerification.error?.code, firstFactorVerificationSessionId: firstFactorVerification.error?.meta?.sessionId, sessionId: signIn.createdSessionId, + protectCheck: signIn.protectCheck, }; const makeNavigate = (to: string) => () => navigate(to); @@ -2264,6 +2265,10 @@ export class Clerk implements ClerkInterface { buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }), ); + const navigateToSignInProtectCheck = makeNavigate( + buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }), + ); + const redirectUrls = new RedirectUrls(this.#options, params); const navigateToContinueSignUp = makeNavigate( @@ -2296,6 +2301,7 @@ export class Clerk implements ClerkInterface { verifyPhonePath: params.verifyPhoneNumberUrl || buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }), + protectCheckPath: buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), navigate, }); }; @@ -2332,11 +2338,20 @@ export class Clerk implements ClerkInterface { }); } + // Per Protect spec §4.4: OAuth/SAML callbacks can result in a protect_check gate that + // surfaces on the next /v1/client read. Honor either the field or the status override. + if (si.protectCheck || si.status === 'needs_protect_check') { + return navigateToSignInProtectCheck(); + } + const userExistsButNeedsToSignIn = su.externalAccountStatus === 'transferable' && su.externalAccountErrorCode === 'external_account_exists'; if (userExistsButNeedsToSignIn) { const res = await signIn.create({ transfer: true }); + if (res.protectCheck || res.status === 'needs_protect_check') { + return navigateToSignInProtectCheck(); + } switch (res.status) { case 'complete': return this.setActive({ diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index f6540d06eb9..d1ecd03a0f3 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -31,6 +31,7 @@ import type { PhoneCodeFactor, PrepareFirstFactorParams, PrepareSecondFactorParams, + ProtectCheckResource, ResetPasswordEmailCodeFactorConfig, ResetPasswordParams, ResetPasswordPhoneCodeFactorConfig, @@ -112,6 +113,7 @@ export class SignIn extends BaseResource implements SignInResource { createdSessionId: string | null = null; userData: UserData = new UserData(null); clientTrustState?: ClientTrustState; + protectCheck: ProtectCheckResource | null = null; /** * The current status of the sign-in process. @@ -153,6 +155,14 @@ export class SignIn extends BaseResource implements SignInResource { */ __internal_basePost = this._basePost.bind(this); + /** + * @internal Only used for internal purposes, and is not intended to be used directly. + * + * 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 Only used for internal purposes, and is not intended to be used directly. * @@ -257,6 +267,14 @@ export class SignIn extends BaseResource implements SignInResource { }); }; + submitProtectCheck = (params: { proofToken: string }): Promise => { + debugLogger.debug('SignIn.submitProtectCheck', { id: this.id }); + return this._basePatch({ + action: 'protect_check', + body: { proof_token: params.proofToken }, + }); + }; + attemptFirstFactor = (params: AttemptFirstFactorParams): Promise => { debugLogger.debug('SignIn.attemptFirstFactor', { id: this.id, strategy: params.strategy }); let config; @@ -594,6 +612,15 @@ export class SignIn extends BaseResource implements SignInResource { this.createdSessionId = data.created_session_id; this.userData = new UserData(data.user_data); this.clientTrustState = data.client_trust_state ?? undefined; + this.protectCheck = data.protect_check + ? { + status: data.protect_check.status, + token: data.protect_check.token, + sdkUrl: data.protect_check.sdk_url, + expiresAt: data.protect_check.expires_at, + uiHints: data.protect_check.ui_hints, + } + : null; } eventBus.emit('resource:update', { resource: this }); @@ -654,6 +681,15 @@ export class SignIn extends BaseResource implements SignInResource { identifier: this.identifier, created_session_id: this.createdSessionId, user_data: this.userData.__internal_toSnapshot(), + protect_check: this.protectCheck + ? { + status: this.protectCheck.status, + token: this.protectCheck.token, + sdk_url: this.protectCheck.sdkUrl, + ...(this.protectCheck.expiresAt !== undefined && { expires_at: this.protectCheck.expiresAt }), + ...(this.protectCheck.uiHints !== undefined && { ui_hints: this.protectCheck.uiHints }), + } + : null, }; } } @@ -783,6 +819,19 @@ class SignInFuture implements SignInFutureResource { return this.#resource.secondFactorVerification; } + get protectCheck() { + return this.#resource.protectCheck; + } + + async submitProtectCheck(params: { proofToken: string }): Promise<{ error: ClerkError | null }> { + return runAsyncResourceTask(this.#resource, async () => { + await this.#resource.__internal_basePatch({ + action: 'protect_check', + body: { proof_token: params.proofToken }, + }); + }); + } + get canBeDiscarded() { return this.#canBeDiscarded; } diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index d0c884509ed..f8c7204ad6a 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -17,6 +17,7 @@ import type { PreparePhoneNumberVerificationParams, PrepareVerificationParams, PrepareWeb3WalletVerificationParams, + ProtectCheckResource, SignUpAuthenticateWithSolanaParams, SignUpAuthenticateWithWeb3Params, SignUpCreateParams, @@ -92,6 +93,7 @@ export class SignUp extends BaseResource implements SignUpResource { externalAccount: any; hasPassword = false; unsafeMetadata: SignUpUnsafeMetadata = {}; + protectCheck: ProtectCheckResource | null = null; createdSessionId: string | null = null; createdUserId: string | null = null; abandonAt: number | null = null; @@ -195,6 +197,14 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; + submitProtectCheck = (params: { proofToken: string }): Promise => { + debugLogger.debug('SignUp.submitProtectCheck', { id: this.id }); + return this._basePatch({ + action: 'protect_check', + body: { proof_token: params.proofToken }, + }); + }; + prepareEmailAddressVerification = (params?: PrepareEmailAddressVerificationParams): Promise => { return this.prepareVerification(params || { strategy: 'email_code' }); }; @@ -495,6 +505,15 @@ export class SignUp extends BaseResource implements SignUpResource { this.missingFields = data.missing_fields; this.unverifiedFields = data.unverified_fields; this.verifications = new SignUpVerifications(data.verifications); + this.protectCheck = data.protect_check + ? { + status: data.protect_check.status, + token: data.protect_check.token, + sdkUrl: data.protect_check.sdk_url, + expiresAt: data.protect_check.expires_at, + uiHints: data.protect_check.ui_hints, + } + : null; this.username = data.username; this.firstName = data.first_name; this.lastName = data.last_name; @@ -528,6 +547,15 @@ export class SignUp extends BaseResource implements SignUpResource { missing_fields: this.missingFields, unverified_fields: this.unverifiedFields, verifications: this.verifications.__internal_toSnapshot(), + protect_check: this.protectCheck + ? { + status: this.protectCheck.status, + token: this.protectCheck.token, + sdk_url: this.protectCheck.sdkUrl, + ...(this.protectCheck.expiresAt !== undefined && { expires_at: this.protectCheck.expiresAt }), + ...(this.protectCheck.uiHints !== undefined && { ui_hints: this.protectCheck.uiHints }), + } + : null, username: this.username, first_name: this.firstName, last_name: this.lastName, @@ -778,6 +806,10 @@ class SignUpFuture implements SignUpFutureResource { return this.#resource.unverifiedFields; } + get protectCheck() { + return this.#resource.protectCheck; + } + get isTransferable() { // TODO: we can likely remove the error code check as the status should be sufficient return ( @@ -1133,6 +1165,15 @@ class SignUpFuture implements SignUpFutureResource { }); } + async submitProtectCheck(params: { proofToken: string }): Promise<{ error: ClerkError | null }> { + return runAsyncResourceTask(this.#resource, async () => { + await this.#resource.__internal_basePatch({ + action: 'protect_check', + body: { proof_token: params.proofToken }, + }); + }); + } + async ticket(params?: SignUpFutureTicketParams): Promise<{ error: ClerkError | null }> { const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket'); return this.create({ ...params, ticket: ticket ?? 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 76d82b08de8..31ca4fbb72f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -2432,4 +2432,171 @@ describe('SignIn', () => { }); }); }); + + describe('protectCheck', () => { + it('deserializes protect_check from JSON', () => { + const signIn = new SignIn({ + id: 'signin_123', + object: 'sign_in', + status: 'needs_protect_check', + supported_identifiers: [], + identifier: 'user@example.com', + user_data: {} as any, + supported_first_factors: [], + supported_second_factors: [], + first_factor_verification: null, + second_factor_verification: null, + created_session_id: null, + protect_check: { + status: 'pending', + token: 'challenge-token-abc', + sdk_url: 'https://sdk.example.com/challenge.js', + expires_at: 1741564800000, + ui_hints: { theme: 'dark' }, + }, + }); + + expect(signIn.status).toBe('needs_protect_check'); + expect(signIn.protectCheck?.status).toBe('pending'); + expect(signIn.protectCheck?.token).toBe('challenge-token-abc'); + expect(signIn.protectCheck?.sdkUrl).toBe('https://sdk.example.com/challenge.js'); + expect(signIn.protectCheck?.expiresAt).toBe(1741564800000); + expect(signIn.protectCheck?.uiHints).toEqual({ theme: 'dark' }); + }); + + it('sets protectCheck to null when not present in JSON', () => { + const signIn = new SignIn({ + id: 'signin_123', + object: 'sign_in', + status: 'needs_first_factor', + supported_identifiers: [], + identifier: 'user@example.com', + user_data: {} as any, + supported_first_factors: [], + supported_second_factors: [], + first_factor_verification: null, + second_factor_verification: null, + created_session_id: null, + } as any); + + expect(signIn.protectCheck).toBeNull(); + }); + + it('handles protect_check with optional fields omitted', () => { + const signIn = new SignIn({ + id: 'signin_123', + object: 'sign_in', + status: 'needs_protect_check', + supported_identifiers: [], + identifier: 'user@example.com', + user_data: {} as any, + supported_first_factors: [], + supported_second_factors: [], + first_factor_verification: null, + second_factor_verification: null, + created_session_id: null, + protect_check: { + status: 'pending', + token: 'minimal-token', + sdk_url: 'https://example.com/sdk.js', + }, + }); + + expect(signIn.protectCheck?.expiresAt).toBeUndefined(); + expect(signIn.protectCheck?.uiHints).toBeUndefined(); + + const snapshot = signIn.__internal_toSnapshot(); + expect(snapshot.protect_check).toEqual({ + status: 'pending', + token: 'minimal-token', + sdk_url: 'https://example.com/sdk.js', + }); + }); + + it('round-trips protectCheck through snapshot', () => { + const signIn = new SignIn({ + id: 'signin_123', + object: 'sign_in', + status: 'needs_protect_check', + supported_identifiers: [], + identifier: 'user@example.com', + user_data: {} as any, + supported_first_factors: [], + supported_second_factors: [], + first_factor_verification: null, + second_factor_verification: null, + created_session_id: null, + protect_check: { + status: 'pending', + token: 'test-token', + sdk_url: 'https://example.com/sdk.js', + expires_at: 1700000000000, + ui_hints: {}, + }, + }); + + const snapshot = signIn.__internal_toSnapshot(); + expect(snapshot.protect_check).toEqual({ + status: 'pending', + token: 'test-token', + sdk_url: 'https://example.com/sdk.js', + expires_at: 1700000000000, + ui_hints: {}, + }); + + const signIn2 = new SignIn(snapshot); + expect(signIn2.protectCheck?.token).toBe('test-token'); + }); + + it('calls _basePatch with correct params for submitProtectCheck', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signin_123', + object: 'sign_in', + status: 'needs_first_factor', + supported_identifiers: [], + identifier: 'user@example.com', + user_data: {}, + supported_first_factors: [], + supported_second_factors: [], + first_factor_verification: null, + second_factor_verification: null, + created_session_id: null, + protect_check: null, + }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ + id: 'signin_123', + object: 'sign_in', + status: 'needs_protect_check', + supported_identifiers: [], + identifier: 'user@example.com', + user_data: {} as any, + supported_first_factors: [], + supported_second_factors: [], + first_factor_verification: null, + second_factor_verification: null, + created_session_id: null, + protect_check: { + status: 'pending', + token: 'challenge-token', + sdk_url: 'https://example.com/sdk.js', + }, + }); + + const result = await signIn.submitProtectCheck({ proofToken: 'proof-abc' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PATCH', + body: { proof_token: 'proof-abc' }, + }), + ); + expect(result.status).toBe('needs_first_factor'); + expect(result.protectCheck).toBeNull(); + }); + }); }); 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 96257b65b73..dd5262c8850 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -1356,4 +1356,238 @@ describe('SignUp', () => { }); }); }); + + describe('protectCheck', () => { + it('deserializes protect_check from JSON', () => { + const signUp = new SignUp({ + id: 'signup_123', + object: 'sign_up', + status: 'missing_requirements', + required_fields: [], + optional_fields: [], + missing_fields: ['protect_check'], + unverified_fields: [], + username: null, + first_name: null, + last_name: null, + email_address: 'test@example.com', + phone_number: null, + web3_wallet: null, + external_account_strategy: null, + external_account: null, + has_password: false, + unsafe_metadata: {}, + created_session_id: null, + created_user_id: null, + abandon_at: null, + legal_accepted_at: null, + locale: null, + verifications: null, + protect_check: { + status: 'pending', + token: 'challenge-token-abc', + sdk_url: 'https://sdk.example.com/challenge.js', + expires_at: 1741564800000, + ui_hints: { theme: 'dark' }, + }, + }); + + expect(signUp.protectCheck).not.toBeNull(); + expect(signUp.protectCheck?.status).toBe('pending'); + expect(signUp.protectCheck?.token).toBe('challenge-token-abc'); + expect(signUp.protectCheck?.sdkUrl).toBe('https://sdk.example.com/challenge.js'); + expect(signUp.protectCheck?.expiresAt).toBe(1741564800000); + expect(signUp.protectCheck?.uiHints).toEqual({ theme: 'dark' }); + expect(signUp.missingFields).toContain('protect_check'); + }); + + it('handles protect_check with optional expires_at and ui_hints omitted', () => { + const signUp = new SignUp({ + id: 'signup_123', + object: 'sign_up', + status: 'missing_requirements', + required_fields: [], + optional_fields: [], + missing_fields: ['protect_check'], + unverified_fields: [], + username: null, + first_name: null, + last_name: null, + email_address: null, + phone_number: null, + web3_wallet: null, + external_account_strategy: null, + external_account: null, + has_password: false, + unsafe_metadata: {}, + created_session_id: null, + created_user_id: null, + abandon_at: null, + legal_accepted_at: null, + locale: null, + verifications: null, + protect_check: { + status: 'pending', + token: 'minimal-token', + sdk_url: 'https://example.com/sdk.js', + }, + }); + + expect(signUp.protectCheck?.status).toBe('pending'); + expect(signUp.protectCheck?.token).toBe('minimal-token'); + expect(signUp.protectCheck?.expiresAt).toBeUndefined(); + expect(signUp.protectCheck?.uiHints).toBeUndefined(); + + // Snapshot omits the optional fields when absent + const snapshot = signUp.__internal_toSnapshot(); + expect(snapshot.protect_check).toEqual({ + status: 'pending', + token: 'minimal-token', + sdk_url: 'https://example.com/sdk.js', + }); + }); + + it('sets protectCheck to null when not present in JSON', () => { + const signUp = new SignUp({ + id: 'signup_123', + object: 'sign_up', + status: 'missing_requirements', + required_fields: [], + optional_fields: [], + missing_fields: [], + unverified_fields: [], + username: null, + first_name: null, + last_name: null, + email_address: null, + phone_number: null, + web3_wallet: null, + external_account_strategy: null, + external_account: null, + has_password: false, + unsafe_metadata: {}, + created_session_id: null, + created_user_id: null, + abandon_at: null, + legal_accepted_at: null, + locale: null, + verifications: null, + protect_check: null, + }); + + expect(signUp.protectCheck).toBeNull(); + }); + + it('round-trips protectCheck through snapshot', () => { + const signUp = new SignUp({ + id: 'signup_123', + object: 'sign_up', + status: 'missing_requirements', + required_fields: [], + optional_fields: [], + missing_fields: ['protect_check'], + unverified_fields: [], + username: null, + first_name: null, + last_name: null, + email_address: null, + phone_number: null, + web3_wallet: null, + external_account_strategy: null, + external_account: null, + has_password: false, + unsafe_metadata: {}, + created_session_id: null, + created_user_id: null, + abandon_at: null, + legal_accepted_at: null, + locale: null, + verifications: null, + protect_check: { + status: 'pending', + token: 'test-token', + sdk_url: 'https://example.com/sdk.js', + expires_at: 1700000000000, + ui_hints: {}, + }, + }); + + const snapshot = signUp.__internal_toSnapshot(); + expect(snapshot.protect_check).toEqual({ + status: 'pending', + token: 'test-token', + sdk_url: 'https://example.com/sdk.js', + expires_at: 1700000000000, + ui_hints: {}, + }); + + // Re-create from snapshot + const signUp2 = new SignUp(snapshot); + expect(signUp2.protectCheck?.token).toBe('test-token'); + }); + + it('calls _basePatch with correct params for submitProtectCheck', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signup_123', + object: 'sign_up', + status: 'complete', + required_fields: [], + optional_fields: [], + missing_fields: [], + unverified_fields: [], + verifications: null, + protect_check: null, + created_session_id: 'sess_123', + created_user_id: 'user_123', + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ + id: 'signup_123', + object: 'sign_up', + status: 'missing_requirements', + required_fields: [], + optional_fields: [], + missing_fields: ['protect_check'], + unverified_fields: [], + username: null, + first_name: null, + last_name: null, + email_address: null, + phone_number: null, + web3_wallet: null, + external_account_strategy: null, + external_account: null, + has_password: false, + unsafe_metadata: {}, + created_session_id: null, + created_user_id: null, + abandon_at: null, + legal_accepted_at: null, + locale: null, + verifications: null, + protect_check: { + status: 'pending', + token: 'challenge-token', + sdk_url: 'https://example.com/sdk.js', + expires_at: 1700000000000, + ui_hints: {}, + }, + }); + + const result = await signUp.submitProtectCheck({ proofToken: 'proof-abc' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PATCH', + body: { proof_token: 'proof-abc' }, + }), + ); + expect(result.status).toBe('complete'); + expect(result.protectCheck).toBeNull(); + }); + }); }); diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 3b5b3e1bc61..01cfdebe3ff 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -803,6 +803,11 @@ export const enUS: LocalizationResource = { subtitle: 'Select a wallet below to sign in', title: 'Sign in with Solana', }, + protectCheck: { + loading: 'Loading…', + subtitle: 'Please wait while we verify your request.', + title: 'Verifying your request', + }, }, signInEnterPasswordTitle: 'Enter your password', signUp: { @@ -899,6 +904,11 @@ export const enUS: LocalizationResource = { subtitle: 'Select a wallet below to sign up', title: 'Sign up with Solana', }, + protectCheck: { + loading: 'Loading…', + subtitle: 'Please wait while we verify your request.', + title: 'Verifying your request', + }, }, socialButtonsBlockButton: 'Continue with {{provider|titleize}}', socialButtonsBlockButtonManyInView: '{{provider|titleize}}', @@ -1019,6 +1029,7 @@ export const enUS: LocalizationResource = { api_key_name_already_exists: 'API Key name already exists.', api_key_usage_exceeded: 'You have reached your usage limit. You can remove the limit by upgrading to a paid plan.', avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.", avatar_file_type_invalid: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', captcha_invalid: undefined, captcha_unavailable: @@ -1084,6 +1095,13 @@ export const enUS: LocalizationResource = { sentencePrefix: 'Your password must contain', }, phone_number_exists: undefined, + protect_check_aborted: undefined, + protect_check_already_resolved: undefined, + protect_check_execution_failed: "Verification didn't complete. Please try again.", + protect_check_invalid_script: "Couldn't load verification. Please contact support if this persists.", + protect_check_invalid_sdk_url: "Verification couldn't start. Please contact support.", + protect_check_script_load_failed: + "Couldn't load verification. This may be caused by a network issue or a Content Security Policy that blocks the verification script. Please try again or contact support.", session_exists: undefined, web3_missing_identifier: 'A Web3 Wallet extension cannot be found. Please install one to continue.', web3_signature_request_rejected: 'You have rejected the signature request. Please try again to continue.', diff --git a/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts b/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts index 587b2547215..d13513a7c04 100644 --- a/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts +++ b/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts @@ -69,6 +69,86 @@ describe('completeSignUpFlow', () => { expect(mockNavigate).toHaveBeenCalledWith('verify-phone', { searchParams: new URLSearchParams() }); }); + it('navigates to protect check page if protect_check is a missing field', async () => { + const mockSignUp = { + status: 'missing_requirements', + missingFields: ['protect_check'] as SignUpField[], + unverifiedFields: ['email_address'], + } as SignUpResource; + + await completeSignUpFlow({ + signUp: mockSignUp, + protectCheckPath: 'protect-check', + verifyEmailPath: 'verify-email', + handleComplete: mockHandleComplete, + navigate: mockNavigate, + }); + + expect(mockHandleComplete).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith('protect-check', { searchParams: new URLSearchParams() }); + }); + + it('navigates to protect check page when protectCheck field is present even without missing_fields entry', async () => { + const mockSignUp = { + status: 'missing_requirements', + missingFields: [] as SignUpField[], + unverifiedFields: ['email_address'], + protectCheck: { + status: 'pending', + token: 't', + sdkUrl: 'https://example.com/sdk.js', + }, + } as unknown as SignUpResource; + + await completeSignUpFlow({ + signUp: mockSignUp, + protectCheckPath: 'protect-check', + verifyEmailPath: 'verify-email', + handleComplete: mockHandleComplete, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledWith('protect-check', { searchParams: new URLSearchParams() }); + }); + + it('skips protect check if no protectCheckPath is provided', async () => { + const mockSignUp = { + status: 'missing_requirements', + missingFields: ['protect_check'] as SignUpField[], + unverifiedFields: ['email_address'], + } as SignUpResource; + + await completeSignUpFlow({ + signUp: mockSignUp, + verifyEmailPath: 'verify-email', + handleComplete: mockHandleComplete, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledWith('verify-email', { searchParams: new URLSearchParams() }); + }); + + it('prioritizes enterprise_sso over protect_check', async () => { + const mockSignUp = { + status: 'missing_requirements', + missingFields: ['enterprise_sso', 'protect_check'] as SignUpField[], + authenticateWithRedirect: mockAuthenticateWithRedirect, + } as unknown as SignUpResource; + + await completeSignUpFlow({ + signUp: mockSignUp, + protectCheckPath: 'protect-check', + handleComplete: mockHandleComplete, + navigate: mockNavigate, + redirectUrl: 'https://example.com/acs', + redirectUrlComplete: 'https://example.com/done', + }); + + expect(mockAuthenticateWithRedirect).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it('does nothing in any other case', async () => { const mockSignUp = { status: 'missing_requirements', diff --git a/packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts b/packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts new file mode 100644 index 00000000000..f8291b6b3b2 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ProtectCheckResource } from '@/types'; + +import { executeProtectCheck } from '../protectCheck'; + +const fakeContainer = (): HTMLDivElement => ({}) as HTMLDivElement; + +const protectCheck = (overrides: Partial = {}): ProtectCheckResource => ({ + status: 'pending', + token: 'challenge-token', + sdkUrl: 'https://protect.example.com/sdk.js', + ...overrides, +}); + +describe('executeProtectCheck', () => { + beforeEach(() => { + vi.resetModules(); + }); + + describe('URL validation (security)', () => { + it('rejects non-HTTPS schemes', async () => { + await expect( + executeProtectCheck(protectCheck({ sdkUrl: 'http://example.com/sdk.js' }), fakeContainer()), + ).rejects.toMatchObject({ code: 'protect_check_invalid_sdk_url' }); + }); + + it('rejects data: URLs (would allow inline JS injection)', async () => { + await expect( + executeProtectCheck(protectCheck({ sdkUrl: 'data:text/javascript,export default ()=>{}' }), fakeContainer()), + ).rejects.toMatchObject({ code: 'protect_check_invalid_sdk_url' }); + }); + + it('rejects javascript: URLs', async () => { + await expect( + executeProtectCheck(protectCheck({ sdkUrl: 'javascript:void(0)' }), fakeContainer()), + ).rejects.toMatchObject({ code: 'protect_check_invalid_sdk_url' }); + }); + + it('rejects URLs containing credentials', async () => { + await expect( + executeProtectCheck(protectCheck({ sdkUrl: 'https://user:pass@example.com/sdk.js' }), fakeContainer()), + ).rejects.toMatchObject({ code: 'protect_check_invalid_sdk_url' }); + }); + + it('rejects unparseable URLs', async () => { + await expect(executeProtectCheck(protectCheck({ sdkUrl: 'not a url' }), fakeContainer())).rejects.toMatchObject({ + code: 'protect_check_invalid_sdk_url', + }); + }); + }); + + describe('script invocation', () => { + it('returns the proof token from the script default export', async () => { + vi.doMock('https://protect.example.com/sdk-success.js', () => ({ + default: () => Promise.resolve('proof-token-123'), + })); + + const result = await executeProtectCheck( + protectCheck({ sdkUrl: 'https://protect.example.com/sdk-success.js' }), + fakeContainer(), + ); + expect(result).toBe('proof-token-123'); + }); + + it('passes only the spec-defined fields (token, uiHints, signal) — NOT the full resource', async () => { + const fn = vi.fn().mockResolvedValue('proof'); + vi.doMock('https://protect.example.com/sdk-args.js', () => ({ default: fn })); + + const container = fakeContainer(); + const controller = new AbortController(); + await executeProtectCheck( + protectCheck({ + sdkUrl: 'https://protect.example.com/sdk-args.js', + token: 'opaque-challenge-token', + uiHints: { reason: 'device_new' }, + }), + container, + { signal: controller.signal }, + ); + + expect(fn).toHaveBeenCalledWith(container, { + token: 'opaque-challenge-token', + uiHints: { reason: 'device_new' }, + signal: controller.signal, + }); + }); + }); + + describe('cancellation', () => { + it('rejects with protect_check_aborted if signal is already aborted before load', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + executeProtectCheck(protectCheck({ sdkUrl: 'https://protect.example.com/never-loaded.js' }), fakeContainer(), { + signal: controller.signal, + }), + ).rejects.toMatchObject({ code: 'protect_check_aborted' }); + }); + + it('rejects with protect_check_aborted when signal is aborted during script execution', async () => { + const controller = new AbortController(); + vi.doMock('https://protect.example.com/sdk-aborts.js', () => ({ + default: (_container: HTMLDivElement, opts: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + opts.signal?.addEventListener('abort', () => { + const err = new Error('aborted by signal'); + err.name = 'AbortError'; + reject(err); + }); + }), + })); + + const promise = executeProtectCheck( + protectCheck({ sdkUrl: 'https://protect.example.com/sdk-aborts.js' }), + fakeContainer(), + { signal: controller.signal }, + ); + controller.abort(); + await expect(promise).rejects.toMatchObject({ code: 'protect_check_aborted' }); + }); + + it('rejects with protect_check_aborted when script resolves AFTER abort fires (uncooperative SDK)', async () => { + const controller = new AbortController(); + vi.doMock('https://protect.example.com/sdk-uncooperative.js', () => ({ + default: () => + new Promise(resolve => { + // Resolves after a microtask, ignoring the signal entirely + setTimeout(() => resolve('late-proof'), 10); + }), + })); + + const promise = executeProtectCheck( + protectCheck({ sdkUrl: 'https://protect.example.com/sdk-uncooperative.js' }), + fakeContainer(), + { signal: controller.signal }, + ); + // Abort while the script is still running + setTimeout(() => controller.abort(), 5); + await expect(promise).rejects.toMatchObject({ code: 'protect_check_aborted' }); + }); + }); + + describe('error wrapping', () => { + it('wraps load failures with a CSP-aware message and code (no URL leakage)', async () => { + // No vi.doMock for this URL → import() fails to resolve + await expect( + executeProtectCheck(protectCheck({ sdkUrl: 'https://nonexistent.example/missing.js' }), fakeContainer()), + ).rejects.toMatchObject({ + code: 'protect_check_script_load_failed', + message: expect.stringContaining('Content Security Policy'), + }); + }); + + it('does not leak the sdkUrl in the user-facing load-failure message', async () => { + try { + await executeProtectCheck( + protectCheck({ sdkUrl: 'https://attacker-controlled.example/evil.js' }), + fakeContainer(), + ); + throw new Error('should have rejected'); + } catch (err: any) { + expect(err.message).not.toContain('attacker-controlled.example'); + expect(err.message).not.toContain('evil.js'); + } + }); + + it('rejects with protect_check_invalid_script when default export is not a function', async () => { + vi.doMock('https://protect.example.com/sdk-no-default.js', () => ({ + default: { not: 'a function' }, + })); + + await expect( + executeProtectCheck(protectCheck({ sdkUrl: 'https://protect.example.com/sdk-no-default.js' }), fakeContainer()), + ).rejects.toMatchObject({ code: 'protect_check_invalid_script' }); + }); + + it('rejects with protect_check_execution_failed when the script throws', async () => { + vi.doMock('https://protect.example.com/sdk-throws.js', () => ({ + default: () => Promise.reject(new Error('script went boom')), + })); + + await expect( + executeProtectCheck(protectCheck({ sdkUrl: 'https://protect.example.com/sdk-throws.js' }), fakeContainer()), + ).rejects.toMatchObject({ + code: 'protect_check_execution_failed', + message: expect.stringContaining('script went boom'), + }); + }); + }); +}); diff --git a/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts b/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts index 09b39203e0a..4afdaf13d36 100644 --- a/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts +++ b/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts @@ -5,6 +5,7 @@ type CompleteSignUpFlowProps = { signUp: SignUpResource; verifyEmailPath?: string; verifyPhonePath?: string; + protectCheckPath?: string; continuePath?: string; navigate: (to: string, options?: { searchParams?: URLSearchParams }) => Promise; handleComplete?: () => Promise; @@ -17,6 +18,7 @@ export const completeSignUpFlow = ({ signUp, verifyEmailPath, verifyPhonePath, + protectCheckPath, continuePath, navigate, handleComplete, @@ -39,6 +41,13 @@ export const completeSignUpFlow = ({ const params = forwardClerkQueryParams(); + // Per Protect spec §5.1: the protect_check field is the authoritative gating signal. + // Sign-up also surfaces it via missing_fields entry; treat either as equivalent. + const isProtectGated = !!signUp.protectCheck || signUp.missingFields.some(mf => mf === 'protect_check'); + if (isProtectGated && protectCheckPath) { + return navigate(protectCheckPath, { searchParams: params }); + } + if (signUp.unverifiedFields?.includes('email_address') && verifyEmailPath) { return navigate(verifyEmailPath, { searchParams: params }); } diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index f81693798e1..aa511d9cabb 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -45,6 +45,7 @@ export const ERROR_CODES = { CAPTCHA_INVALID: 'captcha_invalid', FRAUD_DEVICE_BLOCKED: 'device_blocked', FRAUD_ACTION_BLOCKED: 'action_blocked', + PROTECT_CHECK_ALREADY_RESOLVED: 'protect_check_already_resolved', SIGNUP_RATE_LIMIT_EXCEEDED: 'signup_rate_limit_exceeded', USER_BANNED: 'user_banned', USER_DEACTIVATED: 'user_deactivated', diff --git a/packages/shared/src/internal/clerk-js/protectCheck.ts b/packages/shared/src/internal/clerk-js/protectCheck.ts new file mode 100644 index 00000000000..060b55993e6 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/protectCheck.ts @@ -0,0 +1,153 @@ +import { ClerkRuntimeError } from '../../error'; +import type { ProtectCheckResource } from '../../types'; + +export interface ExecuteProtectCheckOptions { + /** + * Signals that the caller no longer needs the proof token (component unmounted, user + * navigated away, etc.). When the signal aborts: + * - If the script has not yet been imported, `executeProtectCheck` rejects with + * `protect_check_aborted` without loading the script. + * - The signal is forwarded to the script as `{ signal }` in the second argument so + * cooperating SDKs can cancel any in-flight UI / network work. + * - Even if the script ignores the signal and resolves with a token, the helper + * re-checks `signal.aborted` after the await and rejects with `protect_check_aborted` + * so the caller never observes a "successful" abort. + * + * Scripts that don't honor the signal will continue to run; this is best-effort by design. + */ + signal?: AbortSignal; +} + +interface ScriptInitOptions { + token: string; + uiHints?: Record; + signal?: AbortSignal; +} + +type ScriptDefault = (container: HTMLDivElement, init: ScriptInitOptions) => Promise; + +/** + * Validates the `sdk_url` returned by the server before passing it to dynamic `import()`. + * + * Rejects: + * - Anything that fails URL parsing (relative paths, garbage strings) + * - Non-`https:` schemes — including `http:`, `data:`, `blob:`, `javascript:`. The server + * contract (FAPI spec §2.1) says the URL is always HTTPS, but the dynamic-import + * primitive accepts `data:`/`blob:` modules which would let a tampered response inject + * arbitrary code into the host page. + * - URLs containing credentials (`user:pass@host`) — phishing surface, no legitimate use. + * + * Throws `ClerkRuntimeError` with code `protect_check_invalid_sdk_url`. We deliberately do + * NOT silently strip an invalid `protect_check` from the resource: the gate must remain + * present so the user can't bypass it by manipulating the response. Fail-closed. + */ +function assertValidSdkUrl(sdkUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(sdkUrl); + } catch { + throw new ClerkRuntimeError('Protect check sdk_url is not a valid URL', { + code: 'protect_check_invalid_sdk_url', + }); + } + if (parsed.protocol !== 'https:') { + throw new ClerkRuntimeError('Protect check sdk_url must use HTTPS', { + code: 'protect_check_invalid_sdk_url', + }); + } + if (parsed.username || parsed.password) { + throw new ClerkRuntimeError('Protect check sdk_url must not contain credentials', { + code: 'protect_check_invalid_sdk_url', + }); + } + return parsed; +} + +/** + * Loads the Protect challenge SDK from `protectCheck.sdkUrl`, hands it the container element + * and the spec-defined init payload (`token`, `uiHints`, `signal`), and returns the proof + * token the SDK produces. + * + * The SDK script must: + * - Be a valid ES module served over HTTPS + * - Have a default export of the shape `(container, { token, uiHints, signal }) => Promise` + * - Honor the `signal` to abort any pending work (best-effort) + * + * Per FAPI spec §5.2, only the spec-defined fields (`token`, optional `ui_hints`) are + * surfaced to the script — the full sign-up/sign-in resource is intentionally NOT passed + * to minimize the trust surface granted to third-party Protect scripts. + * + * Failure modes are surfaced as `ClerkRuntimeError` with one of: + * - `protect_check_invalid_sdk_url` — URL fails the safety checks above + * - `protect_check_aborted` — caller aborted before or during execution + * - `protect_check_script_load_failed` — network error, CSP block, or invalid module + * - `protect_check_invalid_script` — module loaded but no callable default export + * - `protect_check_execution_failed` — the script's default export threw + */ +export async function executeProtectCheck( + protectCheck: Pick, + container: HTMLDivElement, + options: ExecuteProtectCheckOptions = {}, +): Promise { + const { signal } = options; + const { sdkUrl, token, uiHints } = protectCheck; + + const validated = assertValidSdkUrl(sdkUrl); + + if (signal?.aborted) { + throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); + } + + let mod: Record; + try { + mod = await import(/* webpackIgnore: true */ validated.toString()); + } catch (err) { + // The browser surfaces CSP-blocked imports as the same error shape as a network error + // (typically a TypeError "Failed to fetch dynamically imported module"), so we can't + // reliably distinguish them. Surface a generic message to the UI — the URL is NOT + // included to avoid a phishing surface where a tampered response could place an + // attacker-chosen URL in the auth UI. Diagnostic detail goes to the original error. + const original = err instanceof Error ? err.message : String(err); + throw new ClerkRuntimeError( + 'Protect check script failed to load. This is commonly caused by a Content Security ' + + 'Policy that blocks the script origin (add it to your script-src directive), a ' + + `network error, or an invalid module. (Original error: ${original})`, + { code: 'protect_check_script_load_failed' }, + ); + } + + if (signal?.aborted) { + throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); + } + + if (typeof mod.default !== 'function') { + throw new ClerkRuntimeError('Protect check script does not export a default function', { + code: 'protect_check_invalid_script', + }); + } + + let proofToken: string; + try { + proofToken = await (mod.default as ScriptDefault)(container, { token, uiHints, signal }); + } catch (err) { + // Distinguish abort-induced rejections from genuine script errors: only relabel as + // `protect_check_aborted` when the error looks like an abort (`AbortError`), otherwise + // surface the script's actual failure so production diagnostics aren't masked. + const looksLikeAbort = err instanceof Error && err.name === 'AbortError'; + if (signal?.aborted && looksLikeAbort) { + throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); + } + const original = err instanceof Error ? err.message : String(err); + throw new ClerkRuntimeError(`Protect check script execution failed: ${original}`, { + code: 'protect_check_execution_failed', + }); + } + + // The script may have ignored the signal and resolved with a token after the abort fired. + // Re-check here so callers get a consistent contract: if you aborted, you never see a token. + if (signal?.aborted) { + throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); + } + + return proofToken; +} diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 7c91ed39498..26381d9e172 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -143,6 +143,18 @@ export interface SignUpJSON extends ClerkResourceJSON { legal_accepted_at: number | null; locale: string | null; verifications: SignUpVerificationsJSON | null; + protect_check: ProtectCheckJSON | null; +} + +export interface ProtectCheckJSON { + /** + * Always `'pending'` when surfaced to clients. Completed checks are never emitted on the wire. + */ + status: 'pending'; + token: string; + sdk_url: string; + expires_at?: number; + ui_hints?: Record; } /** diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 222509565bb..c18f3ca3b40 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -399,6 +399,11 @@ export type __internal_LocalizationResource = { subtitle: LocalizationValue; noAvailableWallets: LocalizationValue; }; + protectCheck: { + title: LocalizationValue; + subtitle: LocalizationValue; + loading: LocalizationValue; + }; }; signIn: { start: { @@ -581,6 +586,11 @@ export type __internal_LocalizationResource = { title: LocalizationValue; subtitle: LocalizationValue; }; + protectCheck: { + title: LocalizationValue; + subtitle: LocalizationValue; + loading: LocalizationValue; + }; }; reverification: { password: { @@ -1448,11 +1458,18 @@ type WithParamName = T & Partial>}`, LocalizationValue>>; type UnstableErrors = WithParamName<{ + action_blocked: LocalizationValue; avatar_file_type_invalid: LocalizationValue; avatar_file_size_exceeded: LocalizationValue; external_account_not_found: LocalizationValue; identification_deletion_failed: LocalizationValue; phone_number_exists: LocalizationValue; + protect_check_aborted: LocalizationValue; + protect_check_already_resolved: LocalizationValue; + protect_check_execution_failed: LocalizationValue; + protect_check_invalid_script: LocalizationValue; + protect_check_invalid_sdk_url: LocalizationValue; + protect_check_script_load_failed: LocalizationValue; form_identifier_not_found: LocalizationValue; captcha_unavailable: LocalizationValue; captcha_invalid: LocalizationValue; diff --git a/packages/shared/src/types/signIn.ts b/packages/shared/src/types/signIn.ts index 031cf9e76eb..b33e0f4563f 100644 --- a/packages/shared/src/types/signIn.ts +++ b/packages/shared/src/types/signIn.ts @@ -1,6 +1,7 @@ import type { ClerkResourceJSON, ClientTrustState, + ProtectCheckJSON, SignInFirstFactorJSON, SignInSecondFactorJSON, UserDataJSON, @@ -26,6 +27,7 @@ import type { UserData, } from './signInCommon'; import type { SignInFutureResource } from './signInFuture'; +import type { ProtectCheckResource } from './signUpCommon'; import type { SignInJSONSnapshot } from './snapshots'; import type { CreateEmailLinkFlowReturn, VerificationResource } from './verification'; import type { AuthenticateWithWeb3Params } from './web3Wallet'; @@ -50,6 +52,12 @@ export interface SignInResource extends ClerkResource { identifier: string | null; createdSessionId: string | null; userData: UserData; + /** + * The current protect check challenge, if one is pending. Mid-flow fraud-prevention gate + * issued by Clerk Protect. When non-null, the client must load the SDK at `sdkUrl`, run the + * challenge with `token`, and submit the resulting proof token via `submitProtectCheck`. + */ + protectCheck: ProtectCheckResource | null; create: (params: SignInCreateParams) => Promise; @@ -63,6 +71,13 @@ export interface SignInResource extends ClerkResource { attemptSecondFactor: (params: AttemptSecondFactorParams) => Promise; + /** + * 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. After the + * gate clears, the client should retry the operation that was gated. + */ + submitProtectCheck: (params: { proofToken: string }) => Promise; + authenticateWithRedirect: (params: AuthenticateWithRedirectParams) => Promise; authenticateWithPopup: (params: AuthenticateWithPopupParams) => Promise; @@ -111,4 +126,5 @@ export interface SignInJSON extends ClerkResourceJSON { first_factor_verification: VerificationJSON | null; second_factor_verification: VerificationJSON | null; created_session_id: string | null; + protect_check?: ProtectCheckJSON | null; } diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 40e255b8cf1..8e1fb480c29 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -63,6 +63,7 @@ export type SignInStatus = | 'needs_second_factor' | 'needs_client_trust' | 'needs_new_password' + | 'needs_protect_check' | 'complete'; export type SignInIdentifier = diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index b320d3afcf7..c524228137b 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -2,6 +2,7 @@ import type { ClerkError } from '../errors/clerkError'; import type { SetActiveNavigate } from './clerk'; import type { PhoneCodeChannel } from './phoneCodeChannel'; import type { SignInFirstFactor, SignInSecondFactor, SignInStatus, UserData } from './signInCommon'; +import type { ProtectCheckResource } from './signUpCommon'; import type { OAuthStrategy, PasskeyStrategy, TicketStrategy, Web3Strategy } from './strategies'; import type { VerificationResource } from './verification'; import type { Web3Provider } from './web3'; @@ -368,6 +369,11 @@ export interface SignInFutureResource { */ readonly userData: UserData; + /** + * The current protect check challenge, if one is pending. + */ + readonly protectCheck: ProtectCheckResource | null; + /** * Indicates that the sign-in can be discarded (has been finalized or explicitly reset). * @@ -559,6 +565,12 @@ 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. + */ + submitProtectCheck: (params: { proofToken: string }) => Promise<{ error: ClerkError | null }>; + /** * Used to convert a sign-in with `status === 'complete'` into an active session. Will cause anything observing the * session state (such as the `useUser()` hook) to update automatically. diff --git a/packages/shared/src/types/signUp.ts b/packages/shared/src/types/signUp.ts index 38da8659e9b..453278a60e1 100644 --- a/packages/shared/src/types/signUp.ts +++ b/packages/shared/src/types/signUp.ts @@ -6,6 +6,7 @@ import type { ClerkResource } from './resource'; import type { AttemptVerificationParams, PrepareVerificationParams, + ProtectCheckResource, SignUpAuthenticateWithSolanaParams, SignUpAuthenticateWithWeb3Params, SignUpCreateParams, @@ -48,6 +49,7 @@ export interface SignUpResource extends ClerkResource { missingFields: SignUpField[]; unverifiedFields: SignUpIdentificationField[]; verifications: SignUpVerificationsResource; + protectCheck: ProtectCheckResource | null; username: string | null; firstName: string | null; @@ -104,6 +106,8 @@ export interface SignUpResource extends ClerkResource { }, ) => Promise; + submitProtectCheck: (params: { proofToken: string }) => Promise; + authenticateWithMetamask: (params?: SignUpAuthenticateWithWeb3Params) => Promise; authenticateWithCoinbaseWallet: (params?: SignUpAuthenticateWithWeb3Params) => Promise; authenticateWithOKXWallet: (params?: SignUpAuthenticateWithWeb3Params) => Promise; diff --git a/packages/shared/src/types/signUpCommon.ts b/packages/shared/src/types/signUpCommon.ts index 41c15035b46..40859a3f13d 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -24,7 +24,20 @@ import type { VerificationResource } from './verification'; export type SignUpStatus = 'missing_requirements' | 'complete' | 'abandoned'; -export type SignUpField = SignUpAttributeField | SignUpIdentificationField; +export type ProtectCheckField = 'protect_check'; + +export type SignUpField = SignUpAttributeField | SignUpIdentificationField | ProtectCheckField; + +export interface ProtectCheckResource { + /** + * Always `'pending'` when surfaced to clients. + */ + status: 'pending'; + token: string; + sdkUrl: string; + expiresAt?: number; + uiHints?: Record; +} export type PrepareVerificationParams = | { diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index 1daf1239ece..2435758d7a3 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -1,7 +1,13 @@ import type { ClerkError } from '../errors/clerkError'; import type { SetActiveNavigate } from './clerk'; import type { PhoneCodeChannel } from './phoneCodeChannel'; -import type { SignUpField, SignUpIdentificationField, SignUpStatus, SignUpVerificationResource } from './signUpCommon'; +import type { + ProtectCheckResource, + SignUpField, + SignUpIdentificationField, + SignUpStatus, + SignUpVerificationResource, +} from './signUpCommon'; import type { AppleIdTokenStrategy, EnterpriseSSOStrategy, @@ -500,6 +506,11 @@ export interface SignUpFutureResource { */ readonly locale: string | null; + /** + * The current protect check challenge, if one is pending. + */ + readonly protectCheck: ProtectCheckResource | null; + /** * Indicates that the sign-up can be discarded (has been finalized or explicitly reset). * @@ -555,6 +566,12 @@ 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. + */ + submitProtectCheck: (params: { proofToken: string }) => Promise<{ error: ClerkError | null }>; + /** * Used to convert a sign-up with `status === 'complete'` into an active session. Will cause anything observing the * session state (such as the `useUser()` hook) to update automatically. diff --git a/packages/ui/src/common/EmailLinkVerify.tsx b/packages/ui/src/common/EmailLinkVerify.tsx index 1b00cfd3232..40e9e72fb5a 100644 --- a/packages/ui/src/common/EmailLinkVerify.tsx +++ b/packages/ui/src/common/EmailLinkVerify.tsx @@ -37,6 +37,7 @@ export const EmailLinkVerify = (props: EmailLinkVerifyProps) => { signUp, verifyEmailPath, verifyPhonePath, + protectCheckPath: '../protect-check', continuePath, navigate, }); diff --git a/packages/ui/src/components/SignIn/ResetPassword.tsx b/packages/ui/src/components/SignIn/ResetPassword.tsx index 7d8f7198705..77ed1436398 100644 --- a/packages/ui/src/components/SignIn/ResetPassword.tsx +++ b/packages/ui/src/components/SignIn/ResetPassword.tsx @@ -14,6 +14,7 @@ import { Col, descriptors, localizationKeys, useLocalizations } from '../../cust import { useConfirmPassword } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useRouter } from '../../router'; +import { isSignInProtectGated } from './handleProtectCheck'; const ResetPasswordInternal = () => { const signIn = useCoreSignIn(); @@ -78,10 +79,15 @@ const ResetPasswordInternal = () => { passwordField.clearFeedback(); confirmField.clearFeedback(); try { - const { status, createdSessionId } = await signIn.resetPassword({ + const res = await signIn.resetPassword({ password: passwordField.value, signOutOfOtherSessions: sessionsField.checked, }); + const { status, createdSessionId } = res; + + if (isSignInProtectGated(res)) { + return navigate('../protect-check'); + } switch (status) { case 'complete': @@ -93,6 +99,8 @@ const ResetPasswordInternal = () => { return console.error(clerkInvalidFAPIResponse(status, supportEmail)); case 'needs_second_factor': return navigate('../factor-two'); + case 'needs_protect_check': + return navigate('../protect-check'); default: return console.error(clerkInvalidFAPIResponse(status, supportEmail)); } diff --git a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx index c7a112ca08b..b62a459a5e7 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx @@ -12,6 +12,7 @@ import { useCoreSignIn, useSignInContext } from '../../contexts'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { type LocalizationKey, localizationKeys } from '../../localization'; import { useRouter } from '../../router'; +import { isSignInProtectGated } from './handleProtectCheck'; export type SignInFactorOneAlternativeChannelCodeCard = Pick< VerificationCodeCardProps, @@ -63,6 +64,10 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne .then(async res => { await resolve(); + if (isSignInProtectGated(res)) { + return navigate('../protect-check'); + } + switch (res.status) { case 'complete': return setActive({ @@ -75,6 +80,8 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne return navigate('../factor-two'); case 'needs_new_password': return navigate('../reset-password'); + case 'needs_protect_check': + return navigate('../protect-check'); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index da2863fd3d9..9191fa920e8 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -14,6 +14,7 @@ import { useFetch } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { type LocalizationKey } from '../../localization'; import { useRouter } from '../../router'; +import { isSignInProtectGated } from './handleProtectCheck'; export type SignInFactorOneCodeCard = Pick< VerificationCodeCardProps, @@ -94,6 +95,10 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => .then(async res => { await resolve(); + if (isSignInProtectGated(res)) { + return navigate('../protect-check'); + } + switch (res.status) { case 'complete': return setActive({ @@ -106,6 +111,8 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => return navigate('../factor-two'); case 'needs_new_password': return navigate('../reset-password'); + case 'needs_protect_check': + return navigate('../protect-check'); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx index f4a453b4fe5..e91fcb0c708 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx @@ -15,6 +15,7 @@ import { useCoreSignIn, useSignInContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys } from '../../customizables'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useRouter } from '../../router/RouteContext'; +import { isSignInProtectGated } from './handleProtectCheck'; import { HavingTrouble } from './HavingTrouble'; import { useResetPasswordFactor } from './useResetPasswordFactor'; @@ -74,6 +75,9 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) void signIn .attemptFirstFactor({ strategy: 'password', password: passwordControl.value }) .then(res => { + if (isSignInProtectGated(res)) { + return navigate('../protect-check'); + } switch (res.status) { case 'complete': return setActive({ @@ -86,6 +90,8 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) return navigate('../factor-two'); case 'needs_client_trust': return navigate('../client-trust'); + case 'needs_protect_check': + return navigate('../protect-check'); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx index 119eb6f3308..0973835851f 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx @@ -15,6 +15,7 @@ import { useCoreSignIn, useSignInContext } from '../../contexts'; import { Col, descriptors, localizationKeys } from '../../customizables'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useRouter } from '../../router'; +import { isSignInProtectGated } from './handleProtectCheck'; import { isResetPasswordStrategy } from './utils'; type SignInFactorTwoBackupCodeCardProps = { @@ -45,6 +46,9 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa return signIn .attemptSecondFactor({ strategy: 'backup_code', code: codeControl.value }) .then(res => { + if (isSignInProtectGated(res)) { + return navigate('../protect-check'); + } switch (res.status) { case 'complete': if (isResettingPassword(res) && res.createdSessionId) { @@ -58,6 +62,8 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); + case 'needs_protect_check': + return navigate('../protect-check'); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx index 0cf1ad32f57..1690f037424 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx @@ -14,6 +14,7 @@ import { localizationKeys, Text } from '../../customizables'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import type { LocalizationKey } from '../../localization'; import { useRouter } from '../../router'; +import { isSignInProtectGated } from './handleProtectCheck'; import { isResetPasswordStrategy } from './utils'; export type SignInFactorTwoCodeCard = Pick & { @@ -84,6 +85,9 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => .attemptSecondFactor({ strategy: props.factor.strategy, code }) .then(async res => { await resolve(); + if (isSignInProtectGated(res)) { + return navigate('../protect-check'); + } switch (res.status) { case 'complete': if (isResettingPassword(res) && res.createdSessionId) { @@ -97,6 +101,8 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); + case 'needs_protect_check': + return navigate('../protect-check'); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx new file mode 100644 index 00000000000..dcb75d270ba --- /dev/null +++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx @@ -0,0 +1,223 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; +import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck'; +import { useClerk } from '@clerk/shared/react'; +import type { SignInResource } from '@clerk/shared/types'; +import React from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; +import { handleError } from '@/ui/utils/errorHandler'; + +import { withRedirectToAfterSignIn } from '../../common'; +import { useCoreSignIn, useSignInContext } from '../../contexts'; +import { Box, Col, descriptors, Flow, localizationKeys, useLocalizations } from '../../customizables'; +import { useRouter } from '../../router'; + +/** + * Routes the user to the next step after a protect check has been resolved (or short-circuits + * to the same route to handle a chained challenge). + * + * Per spec §4.3: after the gate clears, the client should retry the operation that was gated. + * For most steps (factor-one/factor-two cards), the underlying card uses `useFetch` to call + * `prepareFirstFactor`/`prepareSecondFactor` on mount, so navigating back is sufficient to + * re-trigger the gated work. + */ +function navigateNext(signIn: SignInResource, navigate: (to: string) => Promise) { + // Chained challenge — stay here and re-run the new challenge on next render. Both + // signals are checked: `protectCheck` is the authoritative field per spec §5.1, and + // `'needs_protect_check'` is the SDK-version-gated status from spec §2.2.2. + if (signIn.protectCheck || signIn.status === 'needs_protect_check') { + return navigate('.'); + } + + switch (signIn.status) { + case 'needs_first_factor': + return navigate('../factor-one'); + case 'needs_second_factor': + return navigate('../factor-two'); + case 'needs_client_trust': + return navigate('../client-trust'); + case 'needs_new_password': + return navigate('../reset-password'); + case 'complete': + // Finalization is handled by the caller via setActive; just bounce to index. + return navigate('..'); + default: + return navigate('..'); + } +} + +function SignInProtectCheckInternal(): JSX.Element { + const card = useCardState(); + const { t } = useLocalizations(); + const signIn = useCoreSignIn(); + const { navigate } = useRouter(); + const { setActive } = useClerk(); + const ctx = useSignInContext(); + const { afterSignInUrl, navigateOnSetActive } = ctx; + + const containerRef = React.useRef(null); + const isRunningRef = React.useRef(false); + const [isRunning, setIsRunning] = React.useState(false); + + React.useEffect(() => { + const protectCheck = signIn.protectCheck; + if (!protectCheck || isRunningRef.current) { + return; + } + + // Cancellation: if the component unmounts (route change, navigation away) or the + // dependency changes (chained challenge with a new protectCheck reference), abort + // any in-flight script execution and skip downstream state updates. + const abortController = new AbortController(); + let cancelled = false; + + // Per spec §5.1.4: do not attempt to solve an expired challenge. + // Reload the resource so the server can mint a fresh challenge before re-routing, + // otherwise the local stale `signIn.protectCheck` would re-trigger this same effect + // and loop indefinitely with no user feedback. + if (protectCheck.expiresAt !== undefined && protectCheck.expiresAt < Date.now()) { + isRunningRef.current = true; + setIsRunning(true); + void (async () => { + try { + await signIn.reload(); + } catch (err: any) { + if (!cancelled) { + handleError(err, [], card.setError); + } + } finally { + if (!cancelled) { + isRunningRef.current = false; + setIsRunning(false); + } + } + })(); + return () => { + cancelled = true; + abortController.abort(); + isRunningRef.current = false; + }; + } + + const container = containerRef.current; + if (!container) { + return; + } + + isRunningRef.current = true; + setIsRunning(true); + + const finalizeIfComplete = async (updatedSignIn: SignInResource) => { + if (cancelled) { + return false; + } + if (updatedSignIn.status === 'complete' && updatedSignIn.createdSessionId) { + await setActive({ + session: updatedSignIn.createdSessionId, + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); + }, + }); + return true; + } + return false; + }; + + const runChallenge = async () => { + try { + const proofToken = await executeProtectCheck(protectCheck, container, { + signal: abortController.signal, + }); + if (cancelled) { + return; + } + + let updatedSignIn: SignInResource; + try { + updatedSignIn = await signIn.submitProtectCheck({ proofToken }); + } catch (err) { + if (cancelled) { + return; + } + // Per spec §3.3, §5.3.4: protect_check_already_resolved is retry-safe. + // Reload to clear the stale local protectCheck; otherwise navigateNext would + // see protectCheck still set and self-loop on this route. + if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED) { + await signIn.reload(); + if (cancelled) { + return; + } + await navigateNext(signIn, navigate); + return; + } + throw err; + } + if (cancelled) { + return; + } + + if (await finalizeIfComplete(updatedSignIn)) { + return; + } + + if (cancelled) { + return; + } + await navigateNext(updatedSignIn, navigate); + } catch (err: any) { + if (cancelled) { + return; + } + handleError(err, [], card.setError); + } finally { + if (!cancelled) { + isRunningRef.current = false; + setIsRunning(false); + } + } + }; + + void runChallenge(); + + return () => { + cancelled = true; + abortController.abort(); + // Reset the guard so the next mount / dep-change can re-run; this is what makes + // chained challenges work correctly across re-renders. + isRunningRef.current = false; + }; + }, [signIn.protectCheck]); + + return ( + + + + + + + + {card.error} + + + {isRunning && !card.error ? ( + {t(localizationKeys('signIn.protectCheck.loading'))} + ) : null} + + + + + + ); +} + +export const SignInProtectCheck = withRedirectToAfterSignIn(withCardStateProvider(SignInProtectCheckInternal)); diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 73cb247af7e..953e8df98b3 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -40,6 +40,7 @@ import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods'; import { useRouter } from '../../router'; import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer'; +import { isSignInProtectGated } from './handleProtectCheck'; import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from './shared'; import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard'; import { SignInSocialButtons } from './SignInSocialButtons'; @@ -225,6 +226,9 @@ function SignInStartInternal(): JSX.Element { ticket: organizationTicket, }) .then(res => { + if (isSignInProtectGated(res)) { + return navigate('protect-check'); + } switch (res.status) { case 'needs_first_factor': { if (!hasOnlyEnterpriseSSOFirstFactors(res) || hasMultipleEnterpriseConnections(res.supportedFirstFactors)) { @@ -237,6 +241,8 @@ function SignInStartInternal(): JSX.Element { return navigate('factor-two'); case 'needs_client_trust': return navigate('client-trust'); + case 'needs_protect_check': + return navigate('protect-check'); case 'complete': removeClerkQueryParam('__clerk_ticket'); return clerk.setActive({ @@ -377,6 +383,10 @@ function SignInStartInternal(): JSX.Element { try { const res = await safePasswordSignInForEnterpriseSSOInstance(signIn.create(buildSignInParams(fields)), fields); + if (isSignInProtectGated(res)) { + return navigate('protect-check'); + } + switch (res.status) { case 'needs_identifier': // Check if we need to initiate an enterprise sso flow @@ -395,6 +405,8 @@ function SignInStartInternal(): JSX.Element { return navigate('factor-two'); case 'needs_client_trust': return navigate('client-trust'); + case 'needs_protect_check': + return navigate('protect-check'); case 'complete': return clerk.setActive({ session: res.createdSessionId, diff --git a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx new file mode 100644 index 00000000000..23d6c3d5cb7 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx @@ -0,0 +1,200 @@ +import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; +import type { SignInResource } from '@clerk/shared/types'; +import { waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { SignInProtectCheck } from '../SignInProtectCheck'; + +vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({ + executeProtectCheck: vi.fn(), +})); + +import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +const mockExecute = executeProtectCheck as unknown as ReturnType; + +beforeEach(() => { + mockExecute.mockReset(); +}); + +describe('SignInProtectCheck', () => { + it('renders verification UI', async () => { + const { wrapper } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + mockExecute.mockReturnValue(new Promise(() => {})); // never resolves + + const { findByText } = render(, { wrapper }); + + expect(await findByText(/verifying your request/i)).toBeInTheDocument(); + }); + + it('runs the SDK challenge and submits the proof token, then routes by status', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck({ sdkUrl: 'https://protect.example.com/v1.js' }); + }); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signIn.submitProtectCheck.mockResolvedValue({ + status: 'needs_first_factor', + protectCheck: null, + createdSessionId: null, + } as unknown as SignInResource); + + render(, { wrapper }); + + await waitFor(() => { + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + sdkUrl: 'https://protect.example.com/v1.js', + token: 'challenge-token', + }), + expect.any(HTMLDivElement), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' }); + expect(fixtures.router.navigate).toHaveBeenCalledWith('../factor-one'); + }); + }); + + it('routes to factor-two when status becomes needs_second_factor', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signIn.submitProtectCheck.mockResolvedValue({ + status: 'needs_second_factor', + protectCheck: null, + createdSessionId: null, + } as unknown as SignInResource); + + render(, { wrapper }); + + await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('../factor-two')); + }); + + it('finalizes the session when status becomes complete', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signIn.submitProtectCheck.mockResolvedValue({ + status: 'complete', + protectCheck: null, + createdSessionId: 'sess_123', + } as unknown as SignInResource); + + render(, { wrapper }); + + await waitFor(() => expect(fixtures.clerk.setActive).toHaveBeenCalled()); + }); + + it('reloads the resource (does not run SDK) when expiresAt is in the past', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck({ expiresAt: Date.now() - 60_000 }); + }); + const reloadMock = vi.fn().mockResolvedValue(fixtures.signIn); + (fixtures.signIn as any).reload = reloadMock; + + render(, { wrapper }); + + await waitFor(() => { + expect(mockExecute).not.toHaveBeenCalled(); + expect(reloadMock).toHaveBeenCalled(); + }); + }); + + it('treats protect_check_already_resolved as a soft success, reloads, and continues the flow', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signIn.submitProtectCheck.mockRejectedValue( + new ClerkAPIResponseError('Already resolved', { + data: [{ code: 'protect_check_already_resolved', message: 'Already resolved', long_message: '' }], + status: 400, + clerkTraceId: 'trace_123', + }), + ); + const reloadMock = vi.fn().mockResolvedValue(fixtures.signIn); + (fixtures.signIn as any).reload = reloadMock; + + render(, { wrapper }); + + await waitFor(() => { + expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalled(); + // Spec §5.3.4: reload to refresh stale local state before re-routing + expect(reloadMock).toHaveBeenCalled(); + }); + }); + + it('self-navigates on a chained challenge', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + mockExecute.mockResolvedValue('proof-1'); + fixtures.signIn.submitProtectCheck.mockResolvedValue({ + status: 'needs_protect_check', + protectCheck: { + status: 'pending', + token: 'challenge-token-2', + sdkUrl: 'https://protect.example.com/sdk.js', + }, + createdSessionId: null, + } as unknown as SignInResource); + + render(, { wrapper }); + + await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('.')); + }); + + it('aborts the SDK signal and does not submit when unmounted mid-challenge', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + let capturedSignal: AbortSignal | undefined; + let resolveProof: (token: string) => void; + mockExecute.mockImplementation((_protectCheck, _container, opts) => { + capturedSignal = opts?.signal; + return new Promise(resolve => { + resolveProof = resolve; + }); + }); + + const { unmount } = render(, { wrapper }); + + await waitFor(() => expect(mockExecute).toHaveBeenCalled()); + expect(capturedSignal?.aborted).toBe(false); + + unmount(); + + expect(capturedSignal?.aborted).toBe(true); + + // Even if the script later resolves (uncooperative SDK), submit must not fire + resolveProof!('late-proof'); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(fixtures.signIn.submitProtectCheck).not.toHaveBeenCalled(); + }); + + it('does not submit a proof token when the SDK challenge fails to execute', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + // executeProtectCheck always wraps load failures in ClerkRuntimeError; mirror that here + // so handleError's known-error check passes and the rejection is fully consumed. + mockExecute.mockRejectedValue( + new ClerkRuntimeError('Protect check script failed to load', { + code: 'protect_check_script_load_failed', + }), + ); + + render(, { wrapper }); + + await waitFor(() => expect(mockExecute).toHaveBeenCalled()); + expect(fixtures.signIn.submitProtectCheck).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts b/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts index 5c2fa3a2726..caa2a834a9d 100644 --- a/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts +++ b/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts @@ -100,6 +100,7 @@ export function handleCombinedFlowTransfer({ signUp: res, verifyEmailPath: 'create/verify-email-address', verifyPhonePath: 'create/verify-phone-number', + protectCheckPath: 'create/protect-check', handleComplete: () => clerk.setActive({ session: res.createdSessionId, diff --git a/packages/ui/src/components/SignIn/handleProtectCheck.ts b/packages/ui/src/components/SignIn/handleProtectCheck.ts new file mode 100644 index 00000000000..09ef351efbd --- /dev/null +++ b/packages/ui/src/components/SignIn/handleProtectCheck.ts @@ -0,0 +1,12 @@ +import type { SignInResource } from '@clerk/shared/types'; + +/** + * Detects whether a sign-in response is gated by Clerk Protect (per spec §2.2.2). + * + * The `protectCheck` field is the authoritative gating signal; new SDKs / newer servers + * also surface `status === 'needs_protect_check'`. Either signal triggers navigation + * to the protect-check route. + */ +export function isSignInProtectGated(signIn: SignInResource): boolean { + return !!signIn.protectCheck || signIn.status === 'needs_protect_check'; +} diff --git a/packages/ui/src/components/SignIn/index.tsx b/packages/ui/src/components/SignIn/index.tsx index f977eb229f9..08fb71c64d0 100644 --- a/packages/ui/src/components/SignIn/index.tsx +++ b/packages/ui/src/components/SignIn/index.tsx @@ -34,6 +34,7 @@ import { SignInAccountSwitcher } from './SignInAccountSwitcher'; import { SignInClientTrust } from './SignInClientTrust'; import { SignInFactorOne } from './SignInFactorOne'; import { SignInFactorTwo } from './SignInFactorTwo'; +import { SignInProtectCheck } from './SignInProtectCheck'; import { SignInSSOCallback } from './SignInSSOCallback'; import { SignInStart } from './SignInStart'; @@ -52,6 +53,12 @@ function SignInRoutes(): JSX.Element { return ( + !!clerk.client.signIn.protectCheck} + > + + diff --git a/packages/ui/src/components/SignIn/shared.ts b/packages/ui/src/components/SignIn/shared.ts index ec25432ea00..f7021c4b6c0 100644 --- a/packages/ui/src/components/SignIn/shared.ts +++ b/packages/ui/src/components/SignIn/shared.ts @@ -10,6 +10,8 @@ import { handleError } from '@/ui/utils/errorHandler'; import { useCoreSignIn, useSignInContext } from '../../contexts'; import { useSupportEmail } from '../../hooks/useSupportEmail'; +import { useRouter } from '../../router'; +import { isSignInProtectGated } from './handleProtectCheck'; function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise) { const card = useCardState(); @@ -18,6 +20,7 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise const supportEmail = useSupportEmail(); const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); const { authenticateWithPasskey } = useCoreSignIn(); + const { navigate } = useRouter(); useEffect(() => { return () => { @@ -28,6 +31,12 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise return useCallback(async (...args: Parameters) => { try { const res = await authenticateWithPasskey(...args); + // Per spec §2.3 / §4: protect_check can fire on attempt_first_factor (which is what + // authenticateWithPasskey calls under the hood). Detect both the field and the + // SDK-version-gated status before dispatching on the underlying status. + if (isSignInProtectGated(res)) { + return navigate('../protect-check'); + } switch (res.status) { case 'complete': return setActive({ @@ -38,6 +47,8 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise }); case 'needs_second_factor': return onSecondFactor(); + case 'needs_protect_check': + return navigate('../protect-check'); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/ui/src/components/SignUp/SignUpContinue.tsx b/packages/ui/src/components/SignUp/SignUpContinue.tsx index 55c339d67f0..2777d63324b 100644 --- a/packages/ui/src/components/SignUp/SignUpContinue.tsx +++ b/packages/ui/src/components/SignUp/SignUpContinue.tsx @@ -179,6 +179,7 @@ function SignUpContinueInternal() { signUp: res, verifyEmailPath: './verify-email-address', verifyPhonePath: './verify-phone-number', + protectCheckPath: '../protect-check', handleComplete: () => clerk.setActive({ session: res.createdSessionId, diff --git a/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx b/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx index 66207e4d6b7..292e31430f5 100644 --- a/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx +++ b/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx @@ -56,6 +56,7 @@ export const SignUpEmailLinkCard = () => { continuePath: '../continue', verifyEmailPath: '../verify-email-address', verifyPhonePath: '../verify-phone-number', + protectCheckPath: '../protect-check', handleComplete: () => setActive({ session: su.createdSessionId, diff --git a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx new file mode 100644 index 00000000000..7a418c48171 --- /dev/null +++ b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx @@ -0,0 +1,187 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; +import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck'; +import { useClerk } from '@clerk/shared/react'; +import React from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; +import { handleError } from '@/ui/utils/errorHandler'; + +import { withRedirectToAfterSignUp } from '../../common'; +import { useCoreSignUp, useSignUpContext } from '../../contexts'; +import { Box, Col, descriptors, Flow, localizationKeys, useLocalizations } from '../../customizables'; +import { useRouter } from '../../router'; +import { completeSignUpFlow } from './util'; + +function SignUpProtectCheckInternal(): JSX.Element { + const card = useCardState(); + const { t } = useLocalizations(); + const signUp = useCoreSignUp(); + const { navigate } = useRouter(); + const { setActive } = useClerk(); + const ctx = useSignUpContext(); + const { afterSignUpUrl, navigateOnSetActive } = ctx; + + const containerRef = React.useRef(null); + const isRunningRef = React.useRef(false); + const [isRunning, setIsRunning] = React.useState(false); + + React.useEffect(() => { + const protectCheck = signUp.protectCheck; + if (!protectCheck || isRunningRef.current) { + return; + } + + // Cancellation: if the component unmounts (route change, navigation away) or the + // dependency changes (chained challenge with a new protectCheck reference), abort + // any in-flight script execution and skip downstream state updates. + const abortController = new AbortController(); + let cancelled = false; + + // Per spec §5.1.4: do not attempt to solve an expired challenge. + // Reload the resource so the server can mint a fresh challenge before re-routing, + // otherwise the local stale `signUp.protectCheck` would re-trigger this same effect + // and loop indefinitely with no user feedback. + if (protectCheck.expiresAt !== undefined && protectCheck.expiresAt < Date.now()) { + isRunningRef.current = true; + setIsRunning(true); + void (async () => { + try { + await signUp.reload(); + } catch (err: any) { + if (!cancelled) { + handleError(err, [], card.setError); + } + } finally { + if (!cancelled) { + isRunningRef.current = false; + setIsRunning(false); + } + } + })(); + return () => { + cancelled = true; + abortController.abort(); + isRunningRef.current = false; + }; + } + + const container = containerRef.current; + if (!container) { + return; + } + + isRunningRef.current = true; + setIsRunning(true); + + const continueAfter = async (resource = signUp) => { + if (cancelled) { + return; + } + await completeSignUpFlow({ + signUp: resource, + verifyEmailPath: '../verify-email-address', + verifyPhonePath: '../verify-phone-number', + protectCheckPath: '.', // Self-navigate handles chained challenges (spec §3.2b, §4.2) + continuePath: '../continue', + handleComplete: () => + setActive({ + session: resource.createdSessionId, + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); + }, + }), + navigate, + }); + }; + + const runChallenge = async () => { + try { + const proofToken = await executeProtectCheck(protectCheck, container, { + signal: abortController.signal, + }); + if (cancelled) { + return; + } + + let updatedSignUp; + try { + updatedSignUp = await signUp.submitProtectCheck({ proofToken }); + } catch (err) { + if (cancelled) { + return; + } + // Per spec §3.3, §5.3.4: protect_check_already_resolved is retry-safe. + // The server's resource state has moved past this gate; reload to pick up the + // fresh state, then continue routing based on the actual current status rather + // than stale local data. + if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED) { + await signUp.reload(); + if (cancelled) { + return; + } + await continueAfter(); + return; + } + throw err; + } + if (cancelled) { + return; + } + await continueAfter(updatedSignUp); + } catch (err: any) { + if (cancelled) { + return; + } + handleError(err, [], card.setError); + } finally { + if (!cancelled) { + isRunningRef.current = false; + setIsRunning(false); + } + } + }; + + void runChallenge(); + + return () => { + cancelled = true; + abortController.abort(); + // Reset the guard so the next mount / dep-change can re-run; this is what makes + // chained challenges work correctly across re-renders. + isRunningRef.current = false; + }; + }, [signUp.protectCheck]); + + return ( + + + + + + + + {card.error} + + + {isRunning && !card.error ? ( + {t(localizationKeys('signUp.protectCheck.loading'))} + ) : null} + + + + + + ); +} + +export const SignUpProtectCheck = withRedirectToAfterSignUp(withCardStateProvider(SignUpProtectCheckInternal)); diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index 8400c8984ce..0f859c7073f 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -164,6 +164,7 @@ function SignUpStartInternal(): JSX.Element { redirectUrlComplete, verifyEmailPath: 'verify-email-address', verifyPhonePath: 'verify-phone-number', + protectCheckPath: 'protect-check', continuePath: 'continue', handleComplete: () => { removeClerkQueryParam('__clerk_ticket'); @@ -345,6 +346,7 @@ function SignUpStartInternal(): JSX.Element { signUp: res, verifyEmailPath: 'verify-email-address', verifyPhonePath: 'verify-phone-number', + protectCheckPath: 'protect-check', handleComplete: () => setActive({ session: res.createdSessionId, diff --git a/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx b/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx index 349b81962aa..1d03a1ff081 100644 --- a/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx +++ b/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx @@ -46,6 +46,7 @@ export const SignUpVerificationCodeForm = (props: SignInFactorOneCodeFormProps) signUp: res, verifyEmailPath: '../verify-email-address', verifyPhonePath: '../verify-phone-number', + protectCheckPath: '../protect-check', continuePath: '../continue', handleComplete: () => setActive({ diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx new file mode 100644 index 00000000000..7d86fdcdbc5 --- /dev/null +++ b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx @@ -0,0 +1,171 @@ +import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; +import type { SignUpResource } from '@clerk/shared/types'; +import { waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { SignUpProtectCheck } from '../SignUpProtectCheck'; + +vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({ + executeProtectCheck: vi.fn(), +})); + +import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck'; + +const { createFixtures } = bindCreateFixtures('SignUp'); + +const mockExecute = executeProtectCheck as unknown as ReturnType; + +beforeEach(() => { + mockExecute.mockReset(); +}); + +describe('SignUpProtectCheck', () => { + it('renders verification UI', async () => { + const { wrapper } = await createFixtures(f => { + f.startSignUpWithProtectCheck(); + }); + mockExecute.mockReturnValue(new Promise(() => {})); // never resolves; keeps spinner + + const { findByText } = render(, { wrapper }); + + expect(await findByText(/verifying your request/i)).toBeInTheDocument(); + }); + + it('runs the SDK challenge with the URL and resource and submits the proof token', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignUpWithProtectCheck({ sdkUrl: 'https://protect.example.com/v1.js' }); + }); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signUp.submitProtectCheck.mockResolvedValue({ + status: 'complete', + protectCheck: null, + createdSessionId: 'sess_123', + } as unknown as SignUpResource); + + render(, { wrapper }); + + await waitFor(() => { + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + sdkUrl: 'https://protect.example.com/v1.js', + token: 'challenge-token', + }), + expect.any(HTMLDivElement), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(fixtures.signUp.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' }); + }); + }); + + it('reloads the resource (does not run SDK) when expiresAt is in the past', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignUpWithProtectCheck({ expiresAt: Date.now() - 60_000 }); + }); + const reloadMock = vi.fn().mockResolvedValue(fixtures.signUp); + (fixtures.signUp as any).reload = reloadMock; + + render(, { wrapper }); + + await waitFor(() => { + expect(mockExecute).not.toHaveBeenCalled(); + expect(reloadMock).toHaveBeenCalled(); + }); + }); + + it('treats protect_check_already_resolved as a soft success, reloads, and continues the flow', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignUpWithProtectCheck(); + }); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signUp.submitProtectCheck.mockRejectedValue( + new ClerkAPIResponseError('Already resolved', { + data: [{ code: 'protect_check_already_resolved', message: 'Already resolved', long_message: '' }], + status: 400, + clerkTraceId: 'trace_123', + }), + ); + const reloadMock = vi.fn().mockResolvedValue(fixtures.signUp); + (fixtures.signUp as any).reload = reloadMock; + + render(, { wrapper }); + + await waitFor(() => { + expect(fixtures.signUp.submitProtectCheck).toHaveBeenCalled(); + // Spec §5.3.4: reload to refresh stale local state before re-routing + expect(reloadMock).toHaveBeenCalled(); + }); + }); + + it('re-runs on a chained challenge (self-navigates)', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignUpWithProtectCheck(); + }); + mockExecute.mockResolvedValue('proof-1'); + // Submit returns a sign-up that still has protectCheck set (chained) + fixtures.signUp.submitProtectCheck.mockResolvedValue({ + status: 'missing_requirements', + missingFields: ['protect_check'], + protectCheck: { + status: 'pending', + token: 'challenge-token-2', + sdkUrl: 'https://protect.example.com/sdk.js', + }, + } as unknown as SignUpResource); + + render(, { wrapper }); + + await waitFor(() => { + // Self-navigates to '.' to re-render with the new challenge + expect(fixtures.router.navigate).toHaveBeenCalledWith('.', { searchParams: expect.any(URLSearchParams) }); + }); + }); + + it('aborts the SDK signal and does not submit when unmounted mid-challenge', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignUpWithProtectCheck(); + }); + let capturedSignal: AbortSignal | undefined; + let resolveProof: (token: string) => void; + mockExecute.mockImplementation((_protectCheck, _container, opts) => { + capturedSignal = opts?.signal; + return new Promise(resolve => { + resolveProof = resolve; + }); + }); + + const { unmount } = render(, { wrapper }); + + await waitFor(() => expect(mockExecute).toHaveBeenCalled()); + expect(capturedSignal?.aborted).toBe(false); + + unmount(); + + expect(capturedSignal?.aborted).toBe(true); + + // Even if the script later resolves (uncooperative SDK), submit must not fire + resolveProof!('late-proof'); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(fixtures.signUp.submitProtectCheck).not.toHaveBeenCalled(); + }); + + it('does not submit a proof token when the SDK challenge fails to execute', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignUpWithProtectCheck(); + }); + // executeProtectCheck always wraps load failures in ClerkRuntimeError; mirror that here + // so handleError's known-error check passes and the rejection is fully consumed. + mockExecute.mockRejectedValue( + new ClerkRuntimeError('Protect check script failed to load', { + code: 'protect_check_script_load_failed', + }), + ); + + render(, { wrapper }); + + await waitFor(() => expect(mockExecute).toHaveBeenCalled()); + expect(fixtures.signUp.submitProtectCheck).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/SignUp/index.tsx b/packages/ui/src/components/SignUp/index.tsx index b4028235421..269aaed9bb0 100644 --- a/packages/ui/src/components/SignUp/index.tsx +++ b/packages/ui/src/components/SignUp/index.tsx @@ -13,6 +13,7 @@ import { SignUpStartSolanaWalletsCard } from '@/ui/components/SignUp/SignUpStart import { SignUpContinue } from './SignUpContinue'; import { SignUpEnterpriseConnections } from './SignUpEnterpriseConnections'; +import { SignUpProtectCheck } from './SignUpProtectCheck'; import { SignUpSSOCallback } from './SignUpSSOCallback'; import { SignUpStart } from './SignUpStart'; import { SignUpVerifyEmail } from './SignUpVerifyEmail'; @@ -34,6 +35,12 @@ function SignUpRoutes(): JSX.Element { return ( + !!clerk.client.signUp.protectCheck} + > + + !!clerk.client.signUp.emailAddress} diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx index cecccfe3d88..42007f6eea5 100644 --- a/packages/ui/src/elements/contexts/index.tsx +++ b/packages/ui/src/elements/contexts/index.tsx @@ -132,7 +132,8 @@ export type FlowMetadata = { | 'chooseWallet' | 'enterpriseConnections' | 'organizationCreationDisabled' - | 'methodSelectionMFA'; + | 'methodSelectionMFA' + | 'protectCheck'; }; const [FlowMetadataCtx, useFlowMetadata] = createContextAndHook('FlowMetadata'); diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index 460117de14a..1a657a0eec0 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -223,7 +223,33 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => { } as SignInJSON; }; - return { startSignInWithEmailAddress, startSignInWithPhoneNumber, startSignInFactorTwo }; + const startSignInWithProtectCheck = (params?: { + expiresAt?: number; + uiHints?: Record; + sdkUrl?: string; + }) => { + const { expiresAt, uiHints, sdkUrl = 'https://protect.example.com/sdk.js' } = params || {}; + baseClient.sign_in = { + id: 'sia_2HseAXFGN12eqlwARPMxyyUa9o9', + status: 'needs_protect_check', + identifier: 'test@clerk.com', + supported_first_factors: [], + supported_second_factors: [], + first_factor_verification: null, + second_factor_verification: null, + created_session_id: null, + protect_check: { + status: 'pending', + token: 'challenge-token', + sdk_url: sdkUrl, + ...(expiresAt !== undefined && { expires_at: expiresAt }), + ...(uiHints !== undefined && { ui_hints: uiHints }), + }, + user_data: { ...(createUserFixture() as any) }, + } as SignInJSON; + }; + + return { startSignInWithEmailAddress, startSignInWithPhoneNumber, startSignInFactorTwo, startSignInWithProtectCheck }; }; const createSignUpFixtureHelpers = (baseClient: ClientJSON) => { @@ -289,11 +315,32 @@ const createSignUpFixtureHelpers = (baseClient: ClientJSON) => { } as SignUpJSON; }; + const startSignUpWithProtectCheck = (params?: { + expiresAt?: number; + uiHints?: Record; + sdkUrl?: string; + }) => { + const { expiresAt, uiHints, sdkUrl = 'https://protect.example.com/sdk.js' } = params || {}; + baseClient.sign_up = { + id: 'sua_2HseAXFGN12eqlwARPMxyyUa9o9', + status: 'missing_requirements', + missing_fields: ['protect_check'], + protect_check: { + status: 'pending', + token: 'challenge-token', + sdk_url: sdkUrl, + ...(expiresAt !== undefined && { expires_at: expiresAt }), + ...(uiHints !== undefined && { ui_hints: uiHints }), + }, + } as SignUpJSON; + }; + return { startSignUpWithEmailAddress, startSignUpWithPhoneNumber, startSignUpWithMissingLegalAccepted, startSignUpWithMissingLegalAcceptedAndUnverifiedFields, + startSignUpWithProtectCheck, }; };