diff --git a/.changeset/protect-check-support.md b/.changeset/protect-check-support.md new file mode 100644 index 00000000000..9b700745f4e --- /dev/null +++ b/.changeset/protect-check-support.md @@ -0,0 +1,22 @@ +--- +'@clerk/clerk-js': minor +'@clerk/localizations': minor +'@clerk/react': 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/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 979cf6e24fa..aa86ccff245 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2123,6 +2123,97 @@ describe('Clerk singleton', () => { expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/reset-password'); }); }); + + it('does not route a sign-up callback into a stale sign-in protect_check gate', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + onWindowLocationHost: () => false, + }), + ); + + // An abandoned sign-in keeps serializing its pending protect_check on the client. + const staleSignIn = new SignIn({ + status: 'needs_protect_check', + identifier: 'user@example.com', + first_factor_verification: null, + second_factor_verification: null, + user_data: null, + created_session_id: null, + created_user_id: null, + protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' }, + } as any as SignInJSON); + const completeSignUp = new SignUp({ status: 'complete', created_session_id: 'sess_signup' } as any as SignUpJSON); + // The intent-driven reload at the top of the handler is a no-op here; keep the state stable. + (staleSignIn as any).reload = vi.fn().mockResolvedValue(staleSignIn); + (completeSignUp as any).reload = vi.fn().mockResolvedValue(completeSignUp); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + signIn: staleSignIn, + signUp: completeSignUp, + }), + ); + + const mockSetActive = vi.fn(); + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + sut.setActive = mockSetActive; + + await sut.handleRedirectCallback({ reloadResource: 'signUp' }); + + await waitFor(() => { + // Completes the sign-up rather than routing into the stale sign-in's challenge. + expect(mockSetActive).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalledWith('/sign-in#/protect-check'); + }); + }); + + it('routes a sign-in callback to the protect-check gate', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + onWindowLocationHost: () => false, + }), + ); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + signIn: new SignIn({ + status: 'needs_protect_check', + identifier: 'user@example.com', + first_factor_verification: null, + second_factor_verification: null, + user_data: null, + created_session_id: null, + created_user_id: null, + protect_check: { status: 'pending', token: 'fresh-token', sdk_url: 'https://example.com/sdk.js' }, + } as any as SignInJSON), + signUp: new SignUp(null), + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + + await sut.handleRedirectCallback(); + + await waitFor(() => { + expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/protect-check'); + }); + }); }); describe('.handleEmailLinkVerification()', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b6c7b8d6734..c26b43d9df9 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2352,6 +2352,7 @@ export class Clerk implements ClerkInterface { externalAccountErrorCode: externalAccount.error?.code, externalAccountSessionId: externalAccount.error?.meta?.sessionId, sessionId: signUp.createdSessionId, + protectCheck: signUp.protectCheck, }; const si = { @@ -2360,6 +2361,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); @@ -2383,6 +2385,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( @@ -2396,7 +2402,18 @@ export class Clerk implements ClerkInterface { ), ); + const navigateToSignUpProtectCheck = makeNavigate( + buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), + ); + const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => { + // A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate + // check must run BEFORE the generic missing-fields short-circuit below — otherwise the + // OAuth/SAML callback would land on /continue instead of the challenge. + if (signUp.protectCheck || missingFields.includes('protect_check')) { + return navigateToSignUpProtectCheck(); + } + if (missingFields.length) { return navigateToContinueSignUp(); } @@ -2415,6 +2432,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, }); }; @@ -2451,11 +2469,35 @@ export class Clerk implements ClerkInterface { }); } + // OAuth/SAML callbacks can resolve into a protect_check gate that surfaces on the next + // /v1/client read, so check for it here before continuing with the transfer logic below. + // Honor either the explicit `protectCheck` field or the `needs_protect_check` status override. + // + // Scope to the callback's intent: an abandoned sign-in keeps serializing its pending + // `protect_check` on the client for up to a day (and a later sign-up doesn't clear it in + // multi-session mode), so an unscoped check would route a *sign-up* callback into the stale + // sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback. + // Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh + // response for the gate. + if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) { + return navigateToSignInProtectCheck(); + } + + // The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a + // gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate + // shouldn't hijack a sign-in callback. + if (params.reloadResource !== 'signIn' && su.protectCheck) { + return navigateToSignUpProtectCheck(); + } + 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({ @@ -2702,6 +2744,8 @@ export class Clerk implements ClerkInterface { strategy, legalAccepted, secondFactorUrl, + protectCheckUrl, + signUpProtectCheckUrl, walletName, }: ClerkAuthenticateWithWeb3Params): Promise => { if (!this.client || !this.environment) { @@ -2744,6 +2788,15 @@ export class Clerk implements ClerkInterface { secondFactorUrl || buildURL({ base: displayConfig.signInUrl, hashPath: '/factor-two' }, { stringify: true }), ); + const navigateToSignInProtectCheck = makeNavigate( + protectCheckUrl || buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }), + ); + + const navigateToSignUpProtectCheck = makeNavigate( + signUpProtectCheckUrl || + buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), + ); + const navigateToContinueSignUp = makeNavigate( signUpContinueUrl || buildURL( @@ -2756,6 +2809,7 @@ export class Clerk implements ClerkInterface { ); let signInOrSignUp: SignInResource | SignUpResource; + let viaSignUp = false; try { signInOrSignUp = await this.client.signIn.authenticateWithWeb3({ identifier, @@ -2765,6 +2819,7 @@ export class Clerk implements ClerkInterface { }); } catch (err) { if (isError(err, ERROR_CODES.FORM_IDENTIFIER_NOT_FOUND)) { + viaSignUp = true; signInOrSignUp = await this.client.signUp.authenticateWithWeb3({ identifier, generateSignature, @@ -2777,7 +2832,10 @@ export class Clerk implements ClerkInterface { if ( signUpContinueUrl && signInOrSignUp.status === 'missing_requirements' && - signInOrSignUp.verifications.web3Wallet.status === 'verified' + signInOrSignUp.verifications.web3Wallet.status === 'verified' && + // A protect_check gate also surfaces as missing_requirements; don't skip past it into + // the continue step. The gate is handled by the sign-up protect-check route instead. + !signInOrSignUp.protectCheck ) { await navigateToContinueSignUp(); } @@ -2798,6 +2856,15 @@ export class Clerk implements ClerkInterface { }); }; + // A Clerk Protect challenge can gate the inline web3 attempt (no redirect happens, so the + // centralized _handleRedirectCallback check never runs). Route to the challenge before the + // status switch below, otherwise the user is stranded on the wallet step. The sign-up fallback + // gates as `missing_requirements` + `protectCheck`, so it has no status branch below either. + if (signInOrSignUp.protectCheck || signInOrSignUp.status === 'needs_protect_check') { + await (viaSignUp ? navigateToSignUpProtectCheck : navigateToSignInProtectCheck)(); + return; + } + switch (signInOrSignUp.status) { case 'needs_second_factor': await navigateToFactorTwo(); diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 3b19d06d56a..21647b1fb2d 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,22 @@ export class SignIn extends BaseResource implements SignInResource { }); }; + /** + * Submits a proof token to resolve a Clerk Protect challenge (`protect_check`) during sign-in. + * + * @param params - The proof token parameters. + * @param params.proofToken - The proof token produced by the Protect challenge SDK. + * @returns A promise resolving to the updated `SignIn` resource (gate cleared, a chained + * challenge, or the completed flow). + */ + 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 +620,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 +689,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 +827,26 @@ class SignInFuture implements SignInFutureResource { return this.#resource.secondFactorVerification; } + get protectCheck() { + return this.#resource.protectCheck; + } + + /** + * Submits a proof token to resolve a Clerk Protect challenge (`protect_check`) during sign-in. + * + * @param params - The proof token parameters. + * @param params.proofToken - The proof token produced by the Protect challenge SDK. + * @returns A promise resolving to `{ error }` — `null` on success, otherwise the encountered error. + */ + 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 bccdfa48919..5b0973f9a57 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,22 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; + /** + * Submits a proof token to resolve a Clerk Protect challenge (`protect_check`) during sign-up. + * + * @param params - The proof token parameters. + * @param params.proofToken - The proof token produced by the Protect challenge SDK. + * @returns A promise resolving to the updated `SignUp` resource (gate cleared, a chained + * challenge, or the completed flow). + */ + 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 +513,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 +555,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 +814,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 ( @@ -1141,6 +1181,22 @@ class SignUpFuture implements SignUpFutureResource { }); } + /** + * Submits a proof token to resolve a Clerk Protect challenge (`protect_check`) during sign-up. + * + * @param params - The proof token parameters. + * @param params.proofToken - The proof token produced by the Protect challenge SDK. + * @returns A promise resolving to `{ error }` — `null` on success, otherwise the encountered error. + */ + 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, strategy: 'ticket', 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 2ecd83eec75..1431363cb1a 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -2785,4 +2785,180 @@ describe('SignIn', () => { }); }); }); + + describe('protectCheck', () => { + const originalFetch = BaseResource._fetch; + + afterEach(() => { + // Restore the patched _fetch so the mock can't leak into any block added below. + BaseResource._fetch = originalFetch; + vi.clearAllMocks(); + }); + + 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', + path: '/client/sign_ins/signin_123/protect_check', + 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 4e3f7911fe8..d129e065a73 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -1788,4 +1788,247 @@ describe('SignUp', () => { }); }); }); + + describe('protectCheck', () => { + const originalFetch = BaseResource._fetch; + + afterEach(() => { + // Restore the patched _fetch so the mock can't leak into any block added below. + BaseResource._fetch = originalFetch; + vi.clearAllMocks(); + }); + + 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', + path: '/client/sign_ups/signup_123/protect_check', + 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 518b9d21cbc..c0f7edb5a53 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1374,6 +1374,12 @@ export const enUS: LocalizationResource = { subtitle: 'Select a wallet below to sign in', title: 'Sign in with Solana', }, + protectCheck: { + loading: 'Loading…', + retryButton: 'Try again', + subtitle: 'Please wait while we verify your request.', + title: 'Verifying your request', + }, }, signInEnterPasswordTitle: 'Enter your password', signUp: { @@ -1470,6 +1476,12 @@ export const enUS: LocalizationResource = { subtitle: 'Select a wallet below to sign up', title: 'Sign up with Solana', }, + protectCheck: { + loading: 'Loading…', + retryButton: 'Try again', + subtitle: 'Please wait while we verify your request.', + title: 'Verifying your request', + }, }, socialButtonsBlockButton: 'Continue with {{provider|titleize}}', socialButtonsBlockButtonManyInView: '{{provider|titleize}}', @@ -1590,6 +1602,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: @@ -1659,6 +1672,16 @@ 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.", + protect_check_timed_out: "Verification didn't complete in time. Please try again.", + protect_check_unsupported_environment: + "Verification isn't supported in this environment. Please continue in a standard browser 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/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index a51dbdc5519..47832c63227 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -208,12 +208,16 @@ export class StateProxy implements State { get canBeDiscarded() { return gateProperty(target, 'canBeDiscarded', false); }, + get protectCheck() { + return gateProperty(target, 'protectCheck', null); + }, create: this.gateMethod(target, 'create'), password: this.gateMethod(target, 'password'), sso: this.gateMethod(target, 'sso'), finalize: this.gateMethod(target, 'finalize'), reset: this.gateMethod(target, 'reset'), + submitProtectCheck: this.gateMethod(target, 'submitProtectCheck'), emailCode: this.wrapMethods(() => target().emailCode, ['sendCode', 'verifyCode'] as const), emailLink: this.wrapStruct( @@ -320,6 +324,9 @@ export class StateProxy implements State { get canBeDiscarded() { return gateProperty(target, 'canBeDiscarded', false); }, + get protectCheck() { + return gateProperty(target, 'protectCheck', null); + }, create: gateMethod(target, 'create'), update: gateMethod(target, 'update'), @@ -329,6 +336,7 @@ export class StateProxy implements State { web3: gateMethod(target, 'web3'), finalize: gateMethod(target, 'finalize'), reset: gateMethod(target, 'reset'), + submitProtectCheck: gateMethod(target, 'submitProtectCheck'), verifications: this.wrapStruct( () => target().verifications, 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..bc019ed150a 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(); + // The protect_check field is the authoritative gating signal. Sign-up also surfaces it + // via a 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..f9ca680fe97 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -45,6 +45,9 @@ 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', + PROTECT_CHECK_TIMED_OUT: 'protect_check_timed_out', + PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT: 'protect_check_unsupported_environment', 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..9ec7bd7370a --- /dev/null +++ b/packages/shared/src/internal/clerk-js/protectCheck.ts @@ -0,0 +1,152 @@ +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 + * always returns an HTTPS URL, 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) + * + * Only the minimal 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/clerk.ts b/packages/shared/src/types/clerk.ts index 1d7d9355e0c..9f50b9e4091 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -2717,6 +2717,15 @@ export interface ClerkAuthenticateWithWeb3Params { * The URL to navigate to if [second factor](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options#multi-factor-authentication) is required. */ secondFactorUrl?: string; + /** + * The URL to navigate to if a Clerk Protect challenge gates the sign-in flow. + */ + protectCheckUrl?: string; + /** + * The URL to navigate to if a Clerk Protect challenge gates the sign-up flow (when the web3 + * attempt falls back to sign-up). + */ + signUpProtectCheckUrl?: string; /** * The name of the wallet to use for authentication. */ diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index dff688c85e1..ea9d733da0a 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -143,6 +143,21 @@ 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; + /** + * Unix epoch timestamp in **milliseconds** at which the challenge expires. + */ + expires_at?: number; + ui_hints?: Record; } /** diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 423e5f45832..764e1e0cc39 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -409,6 +409,12 @@ export type __internal_LocalizationResource = { subtitle: LocalizationValue; noAvailableWallets: LocalizationValue; }; + protectCheck: { + title: LocalizationValue; + subtitle: LocalizationValue; + loading: LocalizationValue; + retryButton: LocalizationValue; + }; }; signIn: { start: { @@ -591,6 +597,12 @@ export type __internal_LocalizationResource = { title: LocalizationValue; subtitle: LocalizationValue; }; + protectCheck: { + title: LocalizationValue; + subtitle: LocalizationValue; + loading: LocalizationValue; + retryButton: LocalizationValue; + }; }; reverification: { password: { @@ -1980,11 +1992,20 @@ 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; + protect_check_timed_out: LocalizationValue; + protect_check_unsupported_environment: 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 3bb6798c6c8..597631e522c 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'; @@ -388,6 +389,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). * @@ -560,6 +566,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 }>; + /** * Converts a sign-in with `status === 'complete'` into an active session. Will cause anything observing the session state (such as the [`useUser()`](https://clerk.com/docs/reference/hooks/use-user) 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 db5ca815f03..d8139b17006 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -25,8 +25,24 @@ import type { VerificationResource } from './verification'; /** @inline */ export type SignUpStatus = 'missing_requirements' | 'complete' | 'abandoned'; +export type ProtectCheckField = 'protect_check'; + /** @inline */ -export type SignUpField = SignUpAttributeField | SignUpIdentificationField; +export type SignUpField = SignUpAttributeField | SignUpIdentificationField | ProtectCheckField; + +export interface ProtectCheckResource { + /** + * Always `'pending'` when surfaced to clients. + */ + status: 'pending'; + token: string; + sdkUrl: string; + /** + * Unix epoch timestamp in **milliseconds** at which the challenge expires. + */ + expiresAt?: number; + uiHints?: Record; +} export type PrepareVerificationParams = | { diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index 27a47c5756f..39939b64ecd 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, @@ -461,6 +467,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). * @@ -511,6 +522,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 }>; + /** * Converts a sign-up with `status === 'complete'` into an active session. Will cause anything observing the session state (such as the [`useUser()`](https://clerk.com/docs/reference/hooks/use-user) hook) to update automatically. */ diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index ae8ea4a9c4e..035707e6a57 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -7,7 +7,7 @@ { "path": "./dist/vendors*.js", "maxSize": "73KB" }, { "path": "./dist/ui-common*.js", "maxSize": "130KB" }, { "path": "./dist/signin*.js", "maxSize": "16KB" }, - { "path": "./dist/signup*.js", "maxSize": "11KB" }, + { "path": "./dist/signup*.js", "maxSize": "12KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, { "path": "./dist/organizationprofile*.js", "maxSize": "13KB" }, { "path": "./dist/userbutton*.js", "maxSize": "3.5KB" }, 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..ae2d53d1786 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 { navigateOnSignInProtectGate } 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 (navigateOnSignInProtectGate(res, navigate, '../protect-check')) { + return; + } switch (status) { case 'complete': diff --git a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx index c7a112ca08b..a16607838d6 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 { navigateOnSignInProtectGate } from './handleProtectCheck'; export type SignInFactorOneAlternativeChannelCodeCard = Pick< VerificationCodeCardProps, @@ -63,6 +64,10 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne .then(async res => { await resolve(); + if (navigateOnSignInProtectGate(res, navigate, '../protect-check')) { + return; + } + switch (res.status) { case 'complete': return setActive({ diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index e057f588c29..0bd2eb1ff0f 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 { navigateOnSignInProtectGate } from './handleProtectCheck'; export type SignInFactorOneCodeCard = Pick< VerificationCodeCardProps, @@ -103,6 +104,10 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => .then(async res => { await resolve(); + if (navigateOnSignInProtectGate(res, navigate, '../protect-check')) { + return; + } + switch (res.status) { case 'complete': return setActive({ diff --git a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx index 25a158a5044..339b6412306 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -14,6 +14,7 @@ import { Flow, localizationKeys, useLocalizations } from '../../customizables'; import { useCardState } from '../../elements/contexts'; import { useEmailLink } from '../../hooks/useEmailLink'; import { useRouter } from '../../router/RouteContext'; +import { navigateOnSignInProtectGate } from './handleProtectCheck'; type SignInFactorOneEmailLinkCardProps = Pick & { factor: EmailLinkFactor; @@ -71,6 +72,11 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard }; const completeSignInFlow = async (si: SignInResource) => { + // An email-link verification can resolve into a protect_check gate; route to it before + // dispatching on the underlying status, otherwise the user is stranded on the link card. + if (navigateOnSignInProtectGate(si, navigate, '../protect-check')) { + return; + } if (si.status === 'complete') { return setActive({ session: si.createdSessionId, diff --git a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx index f4a453b4fe5..9029a2b8fcc 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 { navigateOnSignInProtectGate } 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 (navigateOnSignInProtectGate(res, navigate, '../protect-check')) { + return; + } switch (res.status) { case 'complete': return setActive({ diff --git a/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx index 4a94a0350b9..9ebe619d6ef 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx @@ -67,6 +67,8 @@ const SignInFactorOneSolanaWalletsCardInner = () => { customNavigate: router.navigate, redirectUrl: ctx.afterSignInUrl || '/', secondFactorUrl: 'factor-two', + protectCheckUrl: 'protect-check', + signUpProtectCheckUrl: ctx.isCombinedFlow ? 'create/protect-check' : undefined, signUpContinueUrl: ctx.isCombinedFlow ? 'create/continue' : ctx.signUpContinueUrl, strategy: 'web3_solana_signature', walletName, diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx index 119eb6f3308..ddd37690b03 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 { navigateOnSignInProtectGate } 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 (navigateOnSignInProtectGate(res, navigate, '../protect-check')) { + return; + } switch (res.status) { case 'complete': if (isResettingPassword(res) && res.createdSessionId) { diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx index 0cf1ad32f57..46a72e3d9fb 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 { navigateOnSignInProtectGate } 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 (navigateOnSignInProtectGate(res, navigate, '../protect-check')) { + return; + } switch (res.status) { case 'complete': if (isResettingPassword(res) && res.createdSessionId) { diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx new file mode 100644 index 00000000000..86de2bf885d --- /dev/null +++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx @@ -0,0 +1,139 @@ +import { useClerk } from '@clerk/shared/react'; +import type { SignInResource } from '@clerk/shared/types'; + +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; + +import { withRedirectToAfterSignIn } from '../../common'; +import { useCoreSignIn, useSignInContext } from '../../contexts'; +import { + Box, + Button, + Col, + descriptors, + Flex, + Flow, + localizationKeys, + Spinner, + useLocalizations, +} from '../../customizables'; +import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner'; +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). + * + * 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): Promise { + // Chained challenge — stay here and re-run the new challenge on next render. Both + // signals are checked: `protectCheck` is the authoritative field, and + // `'needs_protect_check'` is the SDK-version-gated status. + 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 { afterSignInUrl, navigateOnSetActive } = useSignInContext(); + + const { containerRef, isRunning, hasError, retry } = useProtectCheckRunner({ + getProtectCheck: () => signIn.protectCheck, + getResource: () => signIn, + reload: () => signIn.reload(), + submitProtectCheck: params => signIn.submitProtectCheck(params), + // Routes on the resolved resource. This single path finalizes `complete` from both the normal + // success and the `protect_check_already_resolved` reload, so neither strands the user with an + // unactivated session. + onResolved: async (updatedSignIn, isCancelled) => { + if (isCancelled()) { + return; + } + if (updatedSignIn.status === 'complete' && updatedSignIn.createdSessionId) { + await setActive({ + session: updatedSignIn.createdSessionId, + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); + }, + }); + return; + } + await navigateNext(updatedSignIn, navigate); + }, + }); + + return ( + + + + + + + + {card.error} + + + {isRunning && !hasError ? ( + + + {t(localizationKeys('signIn.protectCheck.loading'))} + + ) : null} + {hasError ? ( +