diff --git a/.changeset/protect-check-support.md b/.changeset/protect-check-support.md new file mode 100644 index 00000000000..8376d734734 --- /dev/null +++ b/.changeset/protect-check-support.md @@ -0,0 +1,30 @@ +--- +'@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. **Upgrading this +package is type-only and does not change runtime behavior**: the server returns the new status +(and the `protectCheck` field) only for instances where Protect mid-flow challenges have been +explicitly enabled — the feature is off by default and is not enabled for existing instances by +upgrading. The server additionally only emits the new status value to SDK versions that +understand it, so older clients never receive an unknown status. + +If an exhaustive `switch` on `signIn.status` flags the new value after upgrading, handle it by +running the challenge described by `protectCheck` and submitting the proof via +`submitProtectCheck()`. 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/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index fd6ebbc5be7..22b3ddb260b 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "./dist/clerk.js", "maxSize": "549KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "74KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" }, { "path": "./dist/clerk.native.js", "maxSize": "73KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 65a63bbaa3e..da058140b13 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2256,6 +2256,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 80b6967f256..d9e2f39c708 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2379,6 +2379,7 @@ export class Clerk implements ClerkInterface { externalAccountErrorCode: externalAccount.error?.code, externalAccountSessionId: externalAccount.error?.meta?.sessionId, sessionId: signUp.createdSessionId, + protectCheck: signUp.protectCheck, }; const si = { @@ -2387,6 +2388,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); @@ -2410,6 +2412,11 @@ export class Clerk implements ClerkInterface { buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }), ); + const navigateToSignInProtectCheck = makeNavigate( + params.signInProtectCheckUrl || + buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }), + ); + const redirectUrls = new RedirectUrls(this.#options, params); const navigateToContinueSignUp = makeNavigate( @@ -2423,7 +2430,19 @@ export class Clerk implements ClerkInterface { ), ); + const navigateToSignUpProtectCheck = makeNavigate( + params.signUpProtectCheckUrl || + 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(); } @@ -2442,6 +2461,9 @@ export class Clerk implements ClerkInterface { verifyPhonePath: params.verifyPhoneNumberUrl || buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }), + protectCheckPath: + params.signUpProtectCheckUrl || + buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), navigate, }); }; @@ -2492,11 +2514,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({ @@ -2755,6 +2801,8 @@ export class Clerk implements ClerkInterface { strategy, legalAccepted, secondFactorUrl, + protectCheckUrl, + signUpProtectCheckUrl, walletName, }: ClerkAuthenticateWithWeb3Params): Promise => { if (!this.client || !this.environment) { @@ -2797,6 +2845,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( @@ -2809,6 +2866,7 @@ export class Clerk implements ClerkInterface { ); let signInOrSignUp: SignInResource | SignUpResource; + let viaSignUp = false; try { signInOrSignUp = await this.client.signIn.authenticateWithWeb3({ identifier, @@ -2818,6 +2876,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, @@ -2830,7 +2889,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(); } @@ -2851,6 +2913,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 27397193194..df4b058e705 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, @@ -113,6 +114,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. @@ -154,6 +156,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. * @@ -258,6 +268,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; @@ -607,6 +633,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 }); @@ -667,6 +702,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, }; } } @@ -796,6 +840,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 99c6b09d35a..8b64f525149 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, @@ -93,6 +94,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; @@ -196,6 +198,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' }); }; @@ -508,6 +526,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; @@ -541,6 +568,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, @@ -791,6 +827,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 ( @@ -1154,6 +1194,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 70cd8141ed0..eac2cdfeea5 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -2826,4 +2826,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 bc04f32396c..a84014917af 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -1831,4 +1831,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 19e2183042c..2b8563871cb 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1416,6 +1416,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: { @@ -1512,6 +1518,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}}', @@ -1632,6 +1644,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: @@ -1702,6 +1715,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..48be6c9e781 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts @@ -0,0 +1,198 @@ +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 append the underlying import error (which can embed the sdkUrl) to the message', async () => { + // Node's import error omits the URL, so the not-toContain checks below are vacuous on their own. + // The `Original error` guard is the real one: a browser embeds the sdk_url in the import failure, + // which must never reach the user-facing message. + try { + await executeProtectCheck( + protectCheck({ sdkUrl: 'https://attacker-controlled.example/evil.js' }), + fakeContainer(), + ); + throw new Error('should have rejected'); + } catch (err: any) { + expect(err.code).toBe('protect_check_script_load_failed'); + expect(err.message).toContain('invalid module.'); + expect(err.message).not.toMatch(/original error/i); + 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 652b63db067..be4ed68e29a 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..ed1da1a3931 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/protectCheck.ts @@ -0,0 +1,148 @@ +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 { + // Surface a generic message and deliberately omit the original error: Chromium/Firefox embed + // the sdk_url in the dynamic-import failure text, which a tampered response could plant in the UI. + 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.', + { 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 595aea197c4..9efda6096cb 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1242,6 +1242,16 @@ export type HandleOAuthCallbackParams = TransferableOption & * The full URL or path to navigate to after requesting phone verification. */ verifyPhoneNumberUrl?: string | null; + /** + * The full URL or path to navigate to if the sign-in is gated by a Clerk Protect challenge + * (`protect_check`). Defaults to the `protect-check` route on the mounted sign-in component. + */ + signInProtectCheckUrl?: string | null; + /** + * The full URL or path to navigate to if the sign-up is gated by a Clerk Protect challenge + * (`protect_check`). Defaults to the `protect-check` route on the mounted sign-up component. + */ + signUpProtectCheckUrl?: string | null; /** * The underlying resource to optionally reload before processing an OAuth callback. */ @@ -2771,6 +2781,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 765ca1d6208..b55dabc619c 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -147,6 +147,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 89859af9e7c..ddf8c0d3153 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -412,6 +412,12 @@ export type __internal_LocalizationResource = { subtitle: LocalizationValue; noAvailableWallets: LocalizationValue; }; + protectCheck: { + title: LocalizationValue; + subtitle: LocalizationValue; + loading: LocalizationValue; + retryButton: LocalizationValue; + }; }; signIn: { start: { @@ -594,6 +600,12 @@ export type __internal_LocalizationResource = { title: LocalizationValue; subtitle: LocalizationValue; }; + protectCheck: { + title: LocalizationValue; + subtitle: LocalizationValue; + loading: LocalizationValue; + retryButton: LocalizationValue; + }; }; reverification: { password: { @@ -1999,11 +2011,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..9078b4accdb 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'; @@ -36,6 +38,11 @@ import type { AuthenticateWithWeb3Params } from './web3Wallet'; export interface SignInResource extends ClerkResource { /** * The current status of the sign-in. + * + * The `'needs_protect_check'` status is only returned when Protect mid-flow challenges are + * explicitly enabled for the instance; upgrading the SDK alone does not enable it. When + * surfaced, run the challenge described by `protectCheck` and resolve it via + * `submitProtectCheck()`. The pre-built components handle this automatically. */ status: SignInStatus | null; /** @@ -50,6 +57,14 @@ 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`. + * Only populated when Protect mid-flow challenges are explicitly enabled for the instance; + * upgrading the SDK alone does not enable it. + */ + protectCheck: ProtectCheckResource | null; create: (params: SignInCreateParams) => Promise; @@ -63,6 +78,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 +133,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..51f77bdf1ab 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'; @@ -338,6 +339,7 @@ export interface SignInFutureResource { *
  • `'needs_first_factor'` - One of the following [first factor verification](!first-factor-verification) strategies is missing: `'email_link'`, `'email_code'`, `passkey`, `password`, `'phone_code'`, `'web3_base_signature'`, `'web3_metamask_signature'`, `'web3_coinbase_wallet_signature'`, `'web3_okx_wallet_signature'`, `'web3_solana_signature'`, [`OAuthStrategy`](https://clerk.com/docs/reference/types/sso#o-auth-strategy), or `'enterprise_sso'`.
  • *
  • `'needs_second_factor'` - One of the following [second factor verification](!second-factor-verification) strategies is missing: `'phone_code'`, `'totp'`, `'backup_code'`, `'email_code'`, or `'email_link'`.
  • *
  • `'needs_new_password'` - The user needs to set a new password. See the [dedicated custom flow](/docs/guides/development/custom-flows/authentication/forgot-password) guide for more information.
  • + *
  • `'needs_protect_check'` - A Clerk Protect challenge must be resolved before the sign-in can continue. This status is only returned when Protect mid-flow challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. Run the challenge described by `protectCheck` and resolve it via `submitProtectCheck()`. The pre-built components handle this automatically.
  • * */ readonly status: SignInStatus; @@ -388,6 +390,12 @@ export interface SignInFutureResource { */ readonly userData: UserData; + /** + * The current protect check challenge, if one is pending. Only populated when Protect mid-flow + * challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. + */ + readonly protectCheck: ProtectCheckResource | null; + /** * Indicates that the sign-in can be discarded (has been finalized or explicitly reset). * @@ -560,6 +568,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..92d4bd31c1e 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,14 @@ export interface SignUpResource extends ClerkResource { missingFields: SignUpField[]; unverifiedFields: SignUpIdentificationField[]; verifications: SignUpVerificationsResource; + /** + * 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`. + * Only populated when Protect mid-flow challenges are explicitly enabled for the instance; + * upgrading the SDK alone does not enable it. + */ + protectCheck: ProtectCheckResource | null; username: string | null; firstName: string | null; @@ -104,6 +113,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..a50b591d860 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -25,8 +25,28 @@ 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; + +/** + * A pending Clerk Protect mid-flow challenge. Only surfaced when Protect mid-flow challenges are + * explicitly enabled for the instance; upgrading the SDK alone does not enable it. + */ +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..ae98a8fa484 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,12 @@ export interface SignUpFutureResource { */ readonly locale: string | null; + /** + * The current protect check challenge, if one is pending. Only populated when Protect mid-flow + * challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. + */ + readonly protectCheck: ProtectCheckResource | null; + /** * Indicates that the sign-up can be discarded (has been finalized or explicitly reset). * @@ -511,6 +523,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 562da88391f..30f465fe98f 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -6,7 +6,7 @@ { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, { "path": "./dist/ui-common*.js", "maxSize": "130KB" }, - { "path": "./dist/signin*.js", "maxSize": "16KB" }, + { "path": "./dist/signin*.js", "maxSize": "17KB" }, { "path": "./dist/signup*.js", "maxSize": "13KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, { "path": "./dist/organizationprofile*.js", "maxSize": "13KB" }, 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 d9d2a623867..ba556f72f8c 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 8b543f8326c..809a0aa0cd7 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -1,7 +1,7 @@ import { isUserLockedError } from '@clerk/shared/error'; import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors'; import { useClerk } from '@clerk/shared/react'; -import type { EmailCodeFactor, PhoneCodeFactor, ResetPasswordCodeFactor } from '@clerk/shared/types'; +import type { EmailCodeFactor, PhoneCodeFactor, ResetPasswordCodeFactor, SignInResource } from '@clerk/shared/types'; import { useMemo } from 'react'; import { useCardState } from '@/ui/elements/contexts'; @@ -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, @@ -81,6 +82,16 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => return navigate('../'); }; + // A `prepare` (the code-send itself, on mount and on resend) can come back Protect-gated, not + // just `attempt` below. Route it through the same choke point so the gate isn't dropped — a + // no-op when the response isn't gated. + const handlePrepareResult = (res: SignInResource) => { + if (navigateOnSignInProtectGate(res, navigate, '../protect-check')) { + return; + } + props.onFactorPrepare(); + }; + const prepare = () => { if (shouldAvoidPrepare) { return; @@ -88,13 +99,13 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => void signIn .prepareFirstFactor(props.factor) - .then(() => props.onFactorPrepare()) + .then(handlePrepareResult) .catch(err => handleError(err, [], card.setError)); }; useFetch(shouldAvoidInitialPrepare ? undefined : () => signIn?.prepareFirstFactor(props.factor), cacheKey, { staleTime: 100, - onSuccess: () => props.onFactorPrepare(), + onSuccess: handlePrepareResult, onError: err => handleError(err, [], card.setError), }); @@ -104,6 +115,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 f8aac05303d..204415685bb 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 fcd00e88792..a616aebf9d5 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..56efdfc8341 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx @@ -66,8 +66,13 @@ const SignInFactorOneSolanaWalletsCardInner = () => { .authenticateWithWeb3({ customNavigate: router.navigate, redirectUrl: ctx.afterSignInUrl || '/', - secondFactorUrl: 'factor-two', - signUpContinueUrl: ctx.isCombinedFlow ? 'create/continue' : ctx.signUpContinueUrl, + // This card is mounted one level deep at `/sign-in/choose-wallet`, so every + // relative target needs to climb out of `choose-wallet` first (unlike + // `SignInSocialButtons`, which lives at the sign-in root and omits the `../`). + 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 c6a8f3baa7a..69dd2a85dd0 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/SignInFactorTwoEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoEmailLinkCard.tsx index 4d784ac10e0..04ef707141e 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwoEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwoEmailLinkCard.tsx @@ -13,6 +13,8 @@ import { useCoreSignIn, useSignInContext } from '../../contexts'; import { Flow, localizationKeys, useLocalizations } from '../../customizables'; import { useCardState } from '../../elements/contexts'; import { useEmailLink } from '../../hooks/useEmailLink'; +import { useRouter } from '../../router'; +import { navigateOnSignInProtectGate } from './handleProtectCheck'; type SignInFactorTwoEmailLinkCardProps = Pick & { showClientTrustNotice?: boolean; @@ -31,6 +33,7 @@ export const SignInFactorTwoEmailLinkCard = (props: SignInFactorTwoEmailLinkCard const signInContext = useSignInContext(); const { signInUrl } = signInContext; const { afterSignInUrl } = useSignInContext(); + const { navigate } = useRouter(); const { setActive } = useClerk(); const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn); const [showVerifyModal, setShowVerifyModal] = React.useState(false); @@ -65,6 +68,11 @@ export const SignInFactorTwoEmailLinkCard = (props: SignInFactorTwoEmailLinkCard const ver = si.secondFactorVerification; if (ver.status === 'expired') { card.setError(t(localizationKeys('formFieldError__verificationLinkExpired'))); + } else if (navigateOnSignInProtectGate(si, navigate, '../protect-check')) { + // A Protect gate can surface when the 2FA email link resolves. Unlike the other second-factor + // cards this one finalizes inline (no `completeSignInFlow`), so route to the challenge here + // instead of falling through to `setActive` with a null `createdSessionId`. + return; } else if (ver.verifiedFromTheSameClient()) { setShowVerifyModal(true); } else { 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 ? ( + + ), +})); + +describe('SignInFactorOneSolanaWalletsCard', () => { + it('forwards protect-check / second-factor targets relative to the choose-wallet mount', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + fixtures.clerk.authenticateWithWeb3.mockResolvedValueOnce(undefined); + + const { userEvent } = render(, { wrapper }); + + await userEvent.click(await screen.findByText('Connect Wallet')); + + // This card is mounted at `/sign-in/choose-wallet`, so the gate targets must climb out of + // `choose-wallet` first — a bare `protect-check` would have resolved to the wrong route. + await waitFor(() => { + expect(fixtures.clerk.authenticateWithWeb3).toHaveBeenCalledWith( + expect.objectContaining({ + strategy: 'web3_solana_signature', + walletName: 'test-wallet', + secondFactorUrl: '../factor-two', + protectCheckUrl: '../protect-check', + }), + ); + }); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorTwoEmailLinkCard.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorTwoEmailLinkCard.test.tsx new file mode 100644 index 00000000000..dd55f8de5e5 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorTwoEmailLinkCard.test.tsx @@ -0,0 +1,68 @@ +import type { EmailLinkFactor, SignInResource } from '@clerk/shared/types'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, waitFor } from '@/test/utils'; + +import { CardStateProvider } from '../../../elements/contexts'; +import { SignInFactorTwoEmailLinkCard } from '../SignInFactorTwoEmailLinkCard'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +const factor: EmailLinkFactor = { + strategy: 'email_link', + emailAddressId: 'idn_123', + safeIdentifier: 'test@clerk.com', +}; + +const renderCard = (component: React.ReactElement, options?: any) => + render({component}, options); + +describe('SignInFactorTwoEmailLinkCard', () => { + it('routes to the protect-check card when the link verification resolves gated by Clerk Protect', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withEmailLink(); + f.startSignInWithEmailAddress({ supportEmailLink: true }); + }); + + // Unlike the other second-factor cards this one finalizes inline, so a Protect gate on the + // resolved resource must route to the challenge instead of `setActive`-ing a null session. + fixtures.signIn.createEmailLinkFlow.mockImplementation( + () => + ({ + startEmailLinkFlow: vi.fn(() => + Promise.resolve({ + status: 'needs_protect_check', + createdSessionId: null, + secondFactorVerification: { + status: 'unverified', + verifiedFromTheSameClient: () => false, + }, + protectCheck: { + status: 'pending', + token: 'challenge-token', + sdkUrl: 'https://protect.example.com/sdk.js', + }, + } as unknown as SignInResource), + ), + cancelEmailLinkFlow: vi.fn(), + }) as any, + ); + + renderCard( + , + { wrapper }, + ); + + await waitFor(() => { + expect(fixtures.router.navigate).toHaveBeenCalledWith('../protect-check'); + // and must not finalize with a null session + expect(fixtures.clerk.setActive).not.toHaveBeenCalled(); + }); + }); +}); 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..b1e50c1806e --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx @@ -0,0 +1,279 @@ +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 { fireEvent, 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('routes on the refreshed resource when an expired-challenge reload clears the gate', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck({ expiresAt: Date.now() - 60_000 }); + }); + // The reload picks up a sign-in whose gate has cleared (the server advanced the flow on read). + const reloadMock = vi.fn().mockImplementation(async () => { + (fixtures.signIn as any).status = 'needs_first_factor'; + (fixtures.signIn as any).protectCheck = null; + return fixtures.signIn; + }); + (fixtures.signIn as any).reload = reloadMock; + + render(, { wrapper }); + + // Must continue the flow on the refreshed resource, not fail/strand on the still-expired branch. + await waitFor(() => { + expect(reloadMock).toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + expect(fixtures.router.navigate).toHaveBeenCalledWith('../factor-one'); + }); + }); + + 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(); + // 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(); + }); + + it('finalizes the session when an already_resolved reload reveals a complete status', 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', + }), + ); + // The reload surfaces a sign-in that has progressed straight to `complete`. + const reloadMock = vi.fn().mockImplementation(async () => { + (fixtures.signIn as any).status = 'complete'; + (fixtures.signIn as any).createdSessionId = 'sess_done'; + (fixtures.signIn as any).protectCheck = null; + return fixtures.signIn; + }); + (fixtures.signIn as any).reload = reloadMock; + + render(, { wrapper }); + + // Must finalize (setActive), not bounce to the start form with an unactivated session. + await waitFor(() => { + expect(reloadMock).toHaveBeenCalled(); + expect(fixtures.clerk.setActive).toHaveBeenCalled(); + }); + }); + + it('shows a retry control after a failure and re-runs the challenge when clicked', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + mockExecute + .mockRejectedValueOnce( + new ClerkRuntimeError('Protect check script failed to load', { code: 'protect_check_script_load_failed' }), + ) + .mockResolvedValue('proof-retry'); + fixtures.signIn.submitProtectCheck.mockResolvedValue({ + status: 'needs_first_factor', + protectCheck: null, + createdSessionId: null, + } as unknown as SignInResource); + + const { findByRole } = render(, { wrapper }); + + // Target the button by role: the error message itself also contains "try again". + const retryButton = await findByRole('button', { name: /try again/i }); + fireEvent.click(retryButton); + + await waitFor(() => { + expect(mockExecute).toHaveBeenCalledTimes(2); + expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-retry' }); + }); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts index 5ee58529895..b8f8b2cb088 100644 --- a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts @@ -15,6 +15,7 @@ describe('buildSignInOAuthCallbackParams', () => { afterSignInUrl: '/after-in', afterSignUpUrl: '/after-up', signUpContinueUrl: '/continue', + signUpProtectCheckUrl: '/sign-up-protect-check', transferable: true, unsafeMetadata: { a: 1 }, } as any; @@ -29,6 +30,8 @@ describe('buildSignInOAuthCallbackParams', () => { firstFactorUrl: '../factor-one', secondFactorUrl: '../factor-two', resetPasswordUrl: '../reset-password', + signInProtectCheckUrl: '../protect-check', + signUpProtectCheckUrl: '/sign-up-protect-check', unsafeMetadata: { a: 1 }, }); }); @@ -47,6 +50,7 @@ describe('buildSignInOAuthTransportCallbackParams', () => { afterSignInUrl: '/after-in', afterSignUpUrl: '/after-up', signUpContinueUrl: '/continue', + signUpProtectCheckUrl: '/sign-up-protect-check', transferable: true, unsafeMetadata: { a: 1 }, } as any; @@ -61,6 +65,9 @@ describe('buildSignInOAuthTransportCallbackParams', () => { firstFactorUrl: 'factor-one', secondFactorUrl: 'factor-two', resetPasswordUrl: 'reset-password', + // Relative to the SignIn start route; the sign-up gate URL stays absolute (combined-aware). + signInProtectCheckUrl: 'protect-check', + signUpProtectCheckUrl: '/sign-up-protect-check', unsafeMetadata: { a: 1 }, }); }); @@ -86,6 +93,7 @@ describe('buildSignUpOAuthCallbackParams', () => { continueSignUpUrl: '../continue', verifyEmailAddressUrl: '../verify-email-address', verifyPhoneNumberUrl: '../verify-phone-number', + signUpProtectCheckUrl: '../protect-check', unsafeMetadata: { b: 2 }, }); }); @@ -116,6 +124,7 @@ describe('buildSignUpOAuthTransportCallbackParams', () => { continueSignUpUrl: 'continue', verifyEmailAddressUrl: 'verify-email-address', verifyPhoneNumberUrl: 'verify-phone-number', + signUpProtectCheckUrl: 'protect-check', unsafeMetadata: { b: 2 }, }); }); diff --git a/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts b/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts new file mode 100644 index 00000000000..154bd70731d --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts @@ -0,0 +1,70 @@ +import type { ProtectCheckResource, SignInResource } from '@clerk/shared/types'; +import { describe, expect, it, vi } from 'vitest'; + +import { isSignInProtectGated, navigateOnSignInProtectGate } from '../handleProtectCheck'; + +const PENDING_CHECK: ProtectCheckResource = { + status: 'pending', + token: 'challenge-token', + sdkUrl: 'https://protect.example.com/sdk.js', +}; + +const asSignIn = (partial: Partial): SignInResource => partial as unknown as SignInResource; + +describe('isSignInProtectGated', () => { + it('is true when the protectCheck field is present', () => { + expect(isSignInProtectGated(asSignIn({ status: 'needs_first_factor', protectCheck: PENDING_CHECK }))).toBe(true); + }); + + it("is true when the status is 'needs_protect_check' (SDK-version-gated signal)", () => { + expect(isSignInProtectGated(asSignIn({ status: 'needs_protect_check', protectCheck: null }))).toBe(true); + }); + + it('is false when neither signal is present', () => { + expect(isSignInProtectGated(asSignIn({ status: 'needs_first_factor', protectCheck: null }))).toBe(false); + }); +}); + +describe('navigateOnSignInProtectGate', () => { + it('navigates to the provided path and returns true when gated by the protectCheck field', () => { + const navigate = vi.fn().mockResolvedValue(undefined); + const handled = navigateOnSignInProtectGate( + asSignIn({ status: 'needs_first_factor', protectCheck: PENDING_CHECK }), + navigate, + '../protect-check', + ); + + expect(handled).toBe(true); + expect(navigate).toHaveBeenCalledWith('../protect-check'); + }); + + it("navigates and returns true when gated by the 'needs_protect_check' status", () => { + const navigate = vi.fn().mockResolvedValue(undefined); + const handled = navigateOnSignInProtectGate( + asSignIn({ status: 'needs_protect_check', protectCheck: null }), + navigate, + 'protect-check', + ); + + expect(handled).toBe(true); + expect(navigate).toHaveBeenCalledWith('protect-check'); + }); + + it('honors the per-caller path verbatim (index mount vs factor mount)', () => { + const navigate = vi.fn().mockResolvedValue(undefined); + navigateOnSignInProtectGate(asSignIn({ protectCheck: PENDING_CHECK }), navigate, 'protect-check'); + expect(navigate).toHaveBeenCalledWith('protect-check'); + }); + + it('does not navigate and returns false when not gated', () => { + const navigate = vi.fn().mockResolvedValue(undefined); + const handled = navigateOnSignInProtectGate( + asSignIn({ status: 'needs_first_factor', protectCheck: null }), + navigate, + '../protect-check', + ); + + expect(handled).toBe(false); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts index 7588867a9ca..334e604300f 100644 --- a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts +++ b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts @@ -14,6 +14,10 @@ export function buildSignInOAuthCallbackParams(ctx: SignInContextType): HandleOA firstFactorUrl: '../factor-one', secondFactorUrl: '../factor-two', resetPasswordUrl: '../reset-password', + signInProtectCheckUrl: '../protect-check', + // Absolute + combined-flow-aware (see SignIn context), so it stays correct regardless of the + // callback route's depth. + signUpProtectCheckUrl: ctx.signUpProtectCheckUrl, unsafeMetadata: ctx.unsafeMetadata, }; } @@ -24,6 +28,7 @@ export function buildSignInOAuthTransportCallbackParams(ctx: SignInContextType): firstFactorUrl: 'factor-one', secondFactorUrl: 'factor-two', resetPasswordUrl: 'reset-password', + signInProtectCheckUrl: 'protect-check', }; } @@ -37,6 +42,7 @@ export function buildSignUpOAuthCallbackParams(ctx: SignUpContextType): HandleOA continueSignUpUrl: '../continue', verifyEmailAddressUrl: '../verify-email-address', verifyPhoneNumberUrl: '../verify-phone-number', + signUpProtectCheckUrl: '../protect-check', unsafeMetadata: ctx.unsafeMetadata, }; } @@ -47,5 +53,6 @@ export function buildSignUpOAuthTransportCallbackParams(ctx: SignUpContextType): continueSignUpUrl: 'continue', verifyEmailAddressUrl: 'verify-email-address', verifyPhoneNumberUrl: 'verify-phone-number', + signUpProtectCheckUrl: 'protect-check', }; } 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..1c061cc66b8 --- /dev/null +++ b/packages/ui/src/components/SignIn/handleProtectCheck.ts @@ -0,0 +1,38 @@ +import type { SignInResource } from '@clerk/shared/types'; + +/** + * Detects whether a sign-in response is gated by Clerk Protect. + * + * 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'; +} + +/** + * Single choke point for routing a Clerk Protect gate during sign-in. + * + * Every sign-in operation that returns a `SignInResource` (create, attempt/prepare first/second + * factor, passkey, reset-password, web3, …) can be gated mid-flow, and a missed call site strands + * the user at the previous step. Funnel them all through this helper: call it right after the + * operation resolves and `return` when it returns `true`, before dispatching on `signIn.status`. + * + * The `protectCheckPath` is supplied per call site because the prebuilt UI mounts sign-in steps at + * different route depths — `SignInStart` (index) reaches the route at `'protect-check'`, the factor + * cards reach it at `'../protect-check'`. + * + * @returns `true` if the response was gated and navigation was issued (caller should stop). + */ +export function navigateOnSignInProtectGate( + signIn: SignInResource, + navigate: (to: string) => Promise, + protectCheckPath: string, +): boolean { + if (isSignInProtectGated(signIn)) { + void navigate(protectCheckPath); + return true; + } + return false; +} diff --git a/packages/ui/src/components/SignIn/index.tsx b/packages/ui/src/components/SignIn/index.tsx index 9fd2fee9239..80386f08fa0 100644 --- a/packages/ui/src/components/SignIn/index.tsx +++ b/packages/ui/src/components/SignIn/index.tsx @@ -23,6 +23,7 @@ import { normalizeRoutingOptions } from '@/utils/normalizeRoutingOptions'; import { buildSignInOAuthCallbackParams, buildSignUpOAuthCallbackParams } from './buildOAuthCallbackParams'; import { LazySignUpContinue, + LazySignUpProtectCheck, LazySignUpSSOCallback, LazySignUpStart, LazySignUpVerifyEmail, @@ -35,6 +36,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'; @@ -53,6 +55,12 @@ function SignInRoutes(): JSX.Element { return ( + !!clerk.client.signIn.protectCheck} + > + + @@ -86,6 +94,12 @@ function SignInRoutes(): JSX.Element { {signInContext.isCombinedFlow && ( + !!clerk.client.signUp.protectCheck} + > + + !!clerk.client.signUp.emailAddress} @@ -110,6 +124,13 @@ function SignInRoutes(): JSX.Element { /> + !!clerk.client.signUp.protectCheck} + > + {/* Under `create/continue`, the continue index is `..`, not `../continue`. */} + + !!clerk.client.signUp.emailAddress} diff --git a/packages/ui/src/components/SignIn/lazy-sign-up.ts b/packages/ui/src/components/SignIn/lazy-sign-up.ts index 536604baa6b..bf503d90450 100644 --- a/packages/ui/src/components/SignIn/lazy-sign-up.ts +++ b/packages/ui/src/components/SignIn/lazy-sign-up.ts @@ -7,6 +7,7 @@ const LazySignUpVerifyEmail = lazy(() => preloadSignUp().then(m => ({ default: m const LazySignUpStart = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpStart }))); const LazySignUpSSOCallback = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpSSOCallback }))); const LazySignUpContinue = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpContinue }))); +const LazySignUpProtectCheck = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpProtectCheck }))); const lazyCompleteSignUpFlow = () => import(/* webpackChunkName: "signup" */ '../SignUp/util').then(m => m.completeSignUpFlow); @@ -18,5 +19,6 @@ export { LazySignUpStart, LazySignUpSSOCallback, LazySignUpContinue, + LazySignUpProtectCheck, lazyCompleteSignUpFlow, }; diff --git a/packages/ui/src/components/SignIn/shared.ts b/packages/ui/src/components/SignIn/shared.ts index 0d16c3589cb..33cb2026be8 100644 --- a/packages/ui/src/components/SignIn/shared.ts +++ b/packages/ui/src/components/SignIn/shared.ts @@ -2,7 +2,7 @@ import { isClerkRuntimeError, isUserLockedError } from '@clerk/shared/error'; import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors'; import { __internal_WebAuthnAbortService } from '@clerk/shared/internal/clerk-js/passkeys'; import { useClerk } from '@clerk/shared/react'; -import type { EnterpriseSSOFactor, SignInFirstFactor } from '@clerk/shared/types'; +import type { EnterpriseSSOFactor, SignInFirstFactor, SignInResource } from '@clerk/shared/types'; import { useCallback, useEffect } from 'react'; import { useCardState } from '@/ui/elements/contexts'; @@ -10,17 +10,30 @@ import { handleError } from '@/ui/utils/errorHandler'; import { useCoreSignIn, useSignInContext } from '../../contexts'; import { useSupportEmail } from '../../hooks/useSupportEmail'; +import { useRouter } from '../../router'; +import { navigateOnSignInProtectGate } from './handleProtectCheck'; /** Search param set when navigating from the start page "Forgot password?" action. */ export const SIGN_IN_RESET_PASSWORD_INTENT_PARAM = '__clerk_reset_password'; -function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise) { +/** + * @param onSecondFactor - invoked when the passkey attempt resolves to a second factor. + * @param protectCheckPath - route to the protect-check card relative to the caller's mount. + * Defaults to the factor-one mount (`'../protect-check'`); `SignInStart` (index route) must pass + * `'protect-check'`, otherwise an autofill-triggered, gated passkey sign-in dead-ends at the app + * root instead of `/sign-in/protect-check`. + */ +function useHandleAuthenticateWithPasskey( + onSecondFactor: () => Promise, + protectCheckPath = '../protect-check', +): (...args: Parameters) => Promise { const card = useCardState(); // @ts-expect-error -- private method for the time being const { setActive, __internal_navigateWithError } = useClerk(); const supportEmail = useSupportEmail(); const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); const { authenticateWithPasskey } = useCoreSignIn(); + const { navigate } = useRouter(); useEffect(() => { return () => { @@ -31,6 +44,11 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise return useCallback(async (...args: Parameters) => { try { const res = await authenticateWithPasskey(...args); + // A protect_check gate can fire on attempt_first_factor, which is what + // authenticateWithPasskey calls under the hood. + if (navigateOnSignInProtectGate(res, navigate, protectCheckPath)) { + return; + } switch (res.status) { case 'complete': return setActive({ 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 3105dc1333f..6d905da85db 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..67b9f5615e7 --- /dev/null +++ b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx @@ -0,0 +1,137 @@ +import { useClerk } from '@clerk/shared/react'; +import type { SignUpProps, SignUpResource } from '@clerk/shared/types'; +import type { ComponentType } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; + +import { withRedirectToAfterSignUp } from '../../common'; +import { useCoreSignUp, useSignUpContext } from '../../contexts'; +import { + Box, + Button, + Col, + descriptors, + Flex, + Flow, + localizationKeys, + Spinner, + useLocalizations, +} from '../../customizables'; +import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner'; +import { useRouter } from '../../router'; +import { completeSignUpFlow } from './util'; + +/** + * Continuation paths default to the standalone `/sign-up/protect-check` mount. When the card is + * mounted deeper (e.g. `continue/protect-check` or the combined-flow `create/.../protect-check`), + * the nested route passes overrides so a resolved gate routes within the correct subtree instead + * of dead-ending. The verify/self paths resolve correctly from every mount; only `continuePath` + * differs (the `continue` index is `..`, not `../continue`, once we're already under `continue`). + */ +type SignUpProtectCheckProps = Partial & { + verifyEmailPath?: string; + verifyPhonePath?: string; + continuePath?: string; + protectCheckPath?: string; +}; + +function SignUpProtectCheckInternal({ + verifyEmailPath = '../verify-email-address', + verifyPhonePath = '../verify-phone-number', + continuePath = '../continue', + protectCheckPath = '.', +}: SignUpProtectCheckProps = {}): JSX.Element { + const card = useCardState(); + const { t } = useLocalizations(); + const signUp = useCoreSignUp(); + const { navigate } = useRouter(); + const { setActive } = useClerk(); + const { afterSignUpUrl, navigateOnSetActive } = useSignUpContext(); + + const { containerRef, isRunning, hasError, retry } = useProtectCheckRunner({ + getProtectCheck: () => signUp.protectCheck, + getResource: () => signUp, + reload: () => signUp.reload(), + submitProtectCheck: params => signUp.submitProtectCheck(params), + // Routes on the resolved resource. `completeSignUpFlow` handles the `complete` case (via + // `handleComplete`) as well as routing to the next missing-field / verification / chained- + // challenge step, so both the normal success and the `protect_check_already_resolved` reload + // land correctly. + onResolved: async (updatedSignUp, isCancelled) => { + if (isCancelled()) { + return; + } + await completeSignUpFlow({ + signUp: updatedSignUp, + verifyEmailPath, + verifyPhonePath, + protectCheckPath, // Defaults to '.' so a chained challenge re-runs this same route + continuePath, + handleComplete: () => + setActive({ + session: updatedSignUp.createdSessionId, + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); + }, + }), + navigate, + }); + }, + }); + + return ( + + + + + + + + {card.error} + + + {isRunning && !hasError ? ( + + + {t(localizationKeys('signUp.protectCheck.loading'))} + + ) : null} + {hasError ? ( +