diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 8ea4af67d31..56c46207c62 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -1,5 +1,6 @@ import { inBrowser } from '@clerk/shared/browser'; import { type ClerkError, ClerkRuntimeError, isCaptchaError, isClerkAPIResponseError } from '@clerk/shared/error'; +import { PROTECT_CHECK_CONTAINER_ID } from '@clerk/shared/internal/clerk-js/constants'; import { createValidatePassword } from '@clerk/shared/internal/clerk-js/passwords/password'; import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { Poller } from '@clerk/shared/poller'; @@ -56,6 +57,7 @@ import { } from '../../utils/authenticateWithPopup'; import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { normalizeUnsafeMetadata } from '../../utils/resourceParams'; +import { executeProtectCheck } from '../../utils/protectCheck'; import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; import { loadZxcvbn } from '../../utils/zxcvbn'; import { @@ -480,6 +482,36 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; + runProtectCheck = async (): Promise => { + // 1. Prepare — backend returns script URL in verifications.protect_check + await this._basePost({ action: 'prepare_protect_check' }); + + const scriptUrl = this.verifications.protectCheck?.url; + if (!scriptUrl) { + throw new ClerkRuntimeError('No protect check script URL returned', { + code: 'protect_check_missing_url', + }); + } + + // 2. Get or create container + const container = this.getOrCreateProtectCheckContainer(); + + try { + // 3. Load and execute script + const result = await executeProtectCheck(scriptUrl, this, container); + + // 4. Attempt with result + await this._basePost({ + body: result, + action: 'attempt_protect_check', + }); + } finally { + this.cleanupProtectCheckContainer(container); + } + + return this; + }; + upsert = (params: SignUpCreateParams | SignUpUpdateParams): Promise => { return this.id ? this.update(params) : this.create(params); }; @@ -583,6 +615,25 @@ export class SignUp extends BaseResource implements SignUpResource { return false; } + private getOrCreateProtectCheckContainer(): HTMLDivElement { + let el = document.getElementById(PROTECT_CHECK_CONTAINER_ID) as HTMLDivElement | null; + if (!el) { + el = document.createElement('div'); + el.id = PROTECT_CHECK_CONTAINER_ID; + document.body.appendChild(el); + } + return el; + } + + private cleanupProtectCheckContainer(el: HTMLDivElement) { + // Only remove from DOM if we created it (i.e., the UI didn't provide it) + if (el.parentNode && !document.getElementById(PROTECT_CHECK_CONTAINER_ID)) { + el.remove(); + } + // Always clear inner content + el.innerHTML = ''; + } + __experimental_getEnterpriseConnections = (): Promise => { return BaseResource._fetch({ path: `/client/sign_ups/${this.id}/enterprise_connections`, @@ -603,6 +654,7 @@ type SignUpFutureVerificationsMethods = Pick< | 'waitForEmailLinkVerification' | 'sendPhoneCode' | 'verifyPhoneCode' + | 'runProtectCheck' >; class SignUpFutureVerifications implements SignUpFutureVerificationsType { @@ -614,6 +666,7 @@ class SignUpFutureVerifications implements SignUpFutureVerificationsType { waitForEmailLinkVerification: SignUpFutureVerificationsType['waitForEmailLinkVerification']; sendPhoneCode: SignUpFutureVerificationsType['sendPhoneCode']; verifyPhoneCode: SignUpFutureVerificationsType['verifyPhoneCode']; + runProtectCheck: SignUpFutureVerificationsType['runProtectCheck']; constructor(resource: SignUp, methods: SignUpFutureVerificationsMethods) { this.#resource = resource; @@ -623,6 +676,7 @@ class SignUpFutureVerifications implements SignUpFutureVerificationsType { this.waitForEmailLinkVerification = methods.waitForEmailLinkVerification; this.sendPhoneCode = methods.sendPhoneCode; this.verifyPhoneCode = methods.verifyPhoneCode; + this.runProtectCheck = methods.runProtectCheck; } get emailAddress() { @@ -641,6 +695,10 @@ class SignUpFutureVerifications implements SignUpFutureVerificationsType { return this.#resource.verifications.externalAccount; } + get protectCheck() { + return this.#resource.verifications.protectCheck; + } + get emailLinkVerification() { if (!inBrowser()) { return null; @@ -681,6 +739,7 @@ class SignUpFuture implements SignUpFutureResource { waitForEmailLinkVerification: this.waitForEmailLinkVerification.bind(this), sendPhoneCode: this.sendPhoneCode.bind(this), verifyPhoneCode: this.verifyPhoneCode.bind(this), + runProtectCheck: this._runProtectCheck.bind(this), }); } @@ -832,6 +891,46 @@ class SignUpFuture implements SignUpFutureResource { return { captchaToken, captchaWidgetType, captchaError }; } + private async _runProtectCheck(): Promise<{ error: ClerkError | null }> { + return runAsyncResourceTask(this.#resource, async () => { + // 1. Prepare + await this.#resource.__internal_basePost({ action: 'prepare_protect_check' }); + + const scriptUrl = this.#resource.verifications.protectCheck?.url; + if (!scriptUrl) { + throw new ClerkRuntimeError('No protect check script URL returned', { + code: 'protect_check_missing_url', + }); + } + + // 2. Container + let container = document.getElementById(PROTECT_CHECK_CONTAINER_ID) as HTMLDivElement | null; + const createdContainer = !container; + if (!container) { + container = document.createElement('div'); + container.id = PROTECT_CHECK_CONTAINER_ID; + document.body.appendChild(container); + } + + try { + // 3. Execute script + const result = await executeProtectCheck(scriptUrl, this.#resource, container); + + // 4. Attempt + await this.#resource.__internal_basePost({ + body: result, + action: 'attempt_protect_check', + }); + } finally { + if (createdContainer) { + container.remove(); + } else { + container.innerHTML = ''; + } + } + }); + } + private async _create(params: SignUpFutureCreateParams): Promise { const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params); diff --git a/packages/clerk-js/src/core/resources/Verification.ts b/packages/clerk-js/src/core/resources/Verification.ts index af1f61a4f88..7e7b0b86290 100644 --- a/packages/clerk-js/src/core/resources/Verification.ts +++ b/packages/clerk-js/src/core/resources/Verification.ts @@ -107,6 +107,7 @@ export class SignUpVerifications implements SignUpVerificationsResource { phoneNumber: SignUpVerificationResource; web3Wallet: SignUpVerificationResource; externalAccount: VerificationResource; + protectCheck: { url: string } | null; constructor(data: SignUpVerificationsJSON | SignUpVerificationsJSONSnapshot | null) { if (data) { @@ -114,11 +115,13 @@ export class SignUpVerifications implements SignUpVerificationsResource { this.phoneNumber = new SignUpVerification(data.phone_number); this.web3Wallet = new SignUpVerification(data.web3_wallet); this.externalAccount = new Verification(data.external_account); + this.protectCheck = data.protect_check ?? null; } else { this.emailAddress = new SignUpVerification(null); this.phoneNumber = new SignUpVerification(null); this.web3Wallet = new SignUpVerification(null); this.externalAccount = new Verification(null); + this.protectCheck = null; } } @@ -128,6 +131,7 @@ export class SignUpVerifications implements SignUpVerificationsResource { phone_number: this.phoneNumber.__internal_toSnapshot(), web3_wallet: this.web3Wallet.__internal_toSnapshot(), external_account: this.externalAccount.__internal_toSnapshot(), + protect_check: this.protectCheck, }; } } 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 357a6b635e6..e660b8ceb9f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -28,6 +28,12 @@ vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({ })), })); +// Mock the protectCheck module +const mockExecuteProtectCheck = vi.fn(); +vi.mock('../../../utils/protectCheck', () => ({ + executeProtectCheck: (...args: any[]) => mockExecuteProtectCheck(...args), +})); + describe('SignUp', () => { it('can be serialized with JSON.stringify', () => { const signUp = new SignUp(); @@ -1096,4 +1102,422 @@ describe('SignUp', () => { }); }); }); + + describe('protect check', () => { + describe('fromJSON and toSnapshot', () => { + it('deserializes protect_check from verifications JSON', () => { + const signUp = new SignUp({ + id: 'signup_123', + status: 'missing_requirements', + verifications: { + protect_check: { url: 'https://cdn.example.com/check.mjs' }, + }, + } as any); + + expect(signUp.verifications.protectCheck).toEqual({ url: 'https://cdn.example.com/check.mjs' }); + }); + + it('deserializes protect_check as null when not present', () => { + const signUp = new SignUp({ + id: 'signup_123', + status: 'missing_requirements', + } as any); + + expect(signUp.verifications.protectCheck).toBeNull(); + }); + + it('deserializes protect_check as null when explicitly null', () => { + const signUp = new SignUp({ + id: 'signup_123', + status: 'missing_requirements', + verifications: { + protect_check: null, + }, + } as any); + + expect(signUp.verifications.protectCheck).toBeNull(); + }); + + it('serializes protect_check in toSnapshot', () => { + const signUp = new SignUp({ + id: 'signup_123', + status: 'missing_requirements', + verifications: { + protect_check: { url: 'https://cdn.example.com/check.mjs' }, + }, + } as any); + + const snapshot = signUp.__internal_toSnapshot(); + expect(snapshot.verifications.protect_check).toEqual({ url: 'https://cdn.example.com/check.mjs' }); + }); + + it('serializes protect_check as null in toSnapshot when not set', () => { + const signUp = new SignUp({ + id: 'signup_123', + status: 'missing_requirements', + } as any); + + const snapshot = signUp.__internal_toSnapshot(); + expect(snapshot.verifications.protect_check).toBeNull(); + }); + }); + + describe('SignUpFuture.verifications.protectCheck getter', () => { + it('exposes protectCheck from the underlying resource verifications', () => { + const signUp = new SignUp({ + id: 'signup_123', + verifications: { + protect_check: { url: 'https://cdn.example.com/check.mjs' }, + }, + } as any); + + expect(signUp.__internal_future.verifications.protectCheck).toEqual({ + url: 'https://cdn.example.com/check.mjs', + }); + }); + + it('returns null when no protect check', () => { + const signUp = new SignUp({ id: 'signup_123' } as any); + expect(signUp.__internal_future.verifications.protectCheck).toBeNull(); + }); + }); + + describe('SignUp.runProtectCheck', () => { + beforeEach(() => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { captchaOauthBypass: [] }, + }, + client: { captchaBypass: true }, + } as any; + mockExecuteProtectCheck.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + SignUp.clerk = {} as any; + }); + + it('calls prepare_protect_check, executes script, and calls attempt_protect_check', async () => { + mockExecuteProtectCheck.mockResolvedValue({ token: 'solved_abc' }); + + const mockFetch = vi + .fn() + // prepare_protect_check response + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + missing_fields: ['protect_check'], + verifications: { protect_check: { url: 'https://cdn.example.com/check.mjs' } }, + }, + }) + // attempt_protect_check response + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + missing_fields: [], + verifications: { protect_check: null }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ + id: 'signup_123', + status: 'missing_requirements', + missing_fields: ['protect_check'], + } as any); + await signUp.runProtectCheck(); + + // executeProtectCheck was called with the URL, the signUp resource, and a container div + expect(mockExecuteProtectCheck).toHaveBeenCalledTimes(1); + expect(mockExecuteProtectCheck).toHaveBeenCalledWith( + 'https://cdn.example.com/check.mjs', + signUp, + expect.any(HTMLDivElement), + ); + + // First call: prepare_protect_check + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toEqual( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ups/signup_123/prepare_protect_check', + }), + ); + + // Second call: attempt_protect_check with result + expect(mockFetch.mock.calls[1][0]).toEqual( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ups/signup_123/attempt_protect_check', + body: { token: 'solved_abc' }, + }), + ); + }); + + it('cleans up the protect check container after execution', async () => { + mockExecuteProtectCheck.mockResolvedValue({ token: 'solved' }); + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + verifications: { protect_check: { url: 'https://cdn.example.com/check.mjs' } }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements', verifications: { protect_check: null } }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + await signUp.runProtectCheck(); + + const container = mockExecuteProtectCheck.mock.calls[0][2] as HTMLDivElement; + expect(container.innerHTML).toBe(''); + }); + + it('cleans up container even when executeProtectCheck throws', async () => { + mockExecuteProtectCheck.mockRejectedValue(new Error('script failed')); + + const mockFetch = vi.fn().mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + verifications: { protect_check: { url: 'https://cdn.example.com/check.mjs' } }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + await expect(signUp.runProtectCheck()).rejects.toThrow('script failed'); + + const container = mockExecuteProtectCheck.mock.calls[0][2] as HTMLDivElement; + expect(container.innerHTML).toBe(''); + }); + + it('throws when prepare returns no protect_check URL', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + verifications: { protect_check: null }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + await expect(signUp.runProtectCheck()).rejects.toThrow('No protect check script URL returned'); + + expect(mockExecuteProtectCheck).not.toHaveBeenCalled(); + }); + }); + + describe('SignUp.create does not auto-execute protect check', () => { + beforeEach(() => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { captchaOauthBypass: [] }, + }, + client: { captchaBypass: true }, + } as any; + mockExecuteProtectCheck.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + SignUp.clerk = {} as any; + }); + + it('does not auto-execute protect check even when protect_check is in response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + missing_fields: ['protect_check'], + verifications: { protect_check: { url: 'https://cdn.example.com/check.mjs' } }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp(); + await signUp.create({ emailAddress: 'user@example.com' }); + + expect(mockExecuteProtectCheck).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('SignUpFuture.verifications.runProtectCheck', () => { + beforeEach(() => { + SignUp.clerk = { + client: { captchaBypass: true }, + } as any; + mockExecuteProtectCheck.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + SignUp.clerk = {} as any; + }); + + it('runs prepare → execute → attempt flow', async () => { + mockExecuteProtectCheck.mockResolvedValue({ token: 'solved_xyz' }); + + const mockFetch = vi + .fn() + // prepare_protect_check response + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + verifications: { protect_check: { url: 'https://cdn.example.com/check.mjs' } }, + }, + }) + // attempt_protect_check response + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + verifications: { protect_check: null }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ + id: 'signup_123', + status: 'missing_requirements', + missing_fields: ['protect_check'], + } as any); + const result = await signUp.__internal_future.verifications.runProtectCheck(); + + expect(result).toHaveProperty('error', null); + expect(mockExecuteProtectCheck).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // First call: prepare_protect_check + expect(mockFetch.mock.calls[0][0]).toEqual( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ups/signup_123/prepare_protect_check', + }), + ); + + // Second call: attempt_protect_check with result + expect(mockFetch.mock.calls[1][0]).toEqual( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ups/signup_123/attempt_protect_check', + body: { token: 'solved_xyz' }, + }), + ); + }); + + it('returns error when prepare returns no URL', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + verifications: { protect_check: null }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + const result = await signUp.__internal_future.verifications.runProtectCheck(); + + expect(result.error).toBeTruthy(); + expect(mockExecuteProtectCheck).not.toHaveBeenCalled(); + }); + + it('does not auto-execute protect check from create', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + missing_fields: ['protect_check'], + verifications: { protect_check: { url: 'https://cdn.example.com/check.mjs' } }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp(); + await signUp.__internal_future.create({ emailAddress: 'user@example.com' }); + + expect(mockExecuteProtectCheck).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('does not auto-execute protect check from password', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signup_123', + status: 'missing_requirements', + missing_fields: ['protect_check'], + verifications: { protect_check: { url: 'https://cdn.example.com/check.mjs' } }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp(); + await signUp.__internal_future.password({ + password: 'secure-password', + emailAddress: 'user@example.com', + }); + + expect(mockExecuteProtectCheck).not.toHaveBeenCalled(); + }); + + it('does not auto-execute protect check from sso', async () => { + vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); + SignUp.clerk = { + buildUrlWithAuth: vi.fn().mockReturnValue('https://example.com/sso-callback'), + __internal_environment: { + displayConfig: { captchaOauthBypass: [] }, + }, + client: { captchaBypass: true }, + } as any; + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signup_123', + verifications: { + externalAccount: { status: 'complete' }, + protect_check: { url: 'https://cdn.example.com/check.mjs' }, + }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp(); + await signUp.__internal_future.sso({ + strategy: 'oauth_google', + redirectUrl: '/complete', + redirectCallbackUrl: '/sso-callback', + }); + + expect(mockExecuteProtectCheck).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/clerk-js/src/utils/__tests__/protectCheck.test.ts b/packages/clerk-js/src/utils/__tests__/protectCheck.test.ts new file mode 100644 index 00000000000..a525bcfc14e --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/protectCheck.test.ts @@ -0,0 +1,75 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import type { SignUpResource } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// We can't intercept native dynamic import() in vitest/jsdom, so we mock +// the entire executeProtectCheck module and test the contract through integration. +// Instead, we test executeProtectCheck by mocking the module it imports at a higher level. +// The approach: we re-implement the logic inline with a mockable import function. + +describe('executeProtectCheck', () => { + let containerEl: HTMLDivElement; + let mockSignUp: SignUpResource; + + beforeEach(() => { + containerEl = document.createElement('div'); + containerEl.id = 'clerk-protect-check'; + mockSignUp = { id: 'signup_123', status: 'missing_requirements' } as unknown as SignUpResource; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('contract tests via module mock', () => { + // Since dynamic import() can't be easily mocked in jsdom, we test the + // executeProtectCheck function by mocking it at the module level and testing + // the calling contract from SignUp.ts. Those tests are in SignUp.test.ts. + // Here we test the error code classification directly. + + it('ClerkRuntimeError for script load failure has correct code', () => { + const error = new ClerkRuntimeError('Protect check script failed to load', { + code: 'protect_check_script_load_failed', + }); + expect(error.code).toBe('protect_check_script_load_failed'); + expect(error.message).toContain('Protect check script failed to load'); + }); + + it('ClerkRuntimeError for invalid script has correct code', () => { + const error = new ClerkRuntimeError('Protect check script has no default export', { + code: 'protect_check_invalid_script', + }); + expect(error.code).toBe('protect_check_invalid_script'); + expect(error.message).toContain('Protect check script has no default export'); + }); + + it('ClerkRuntimeError for execution failure has correct code', () => { + const error = new ClerkRuntimeError('Protect check script execution failed', { + code: 'protect_check_execution_failed', + }); + expect(error.code).toBe('protect_check_execution_failed'); + expect(error.message).toContain('Protect check script execution failed'); + }); + }); + + describe('executeProtectCheck function behavior', () => { + // We import the actual function and use vi.stubGlobal to mock the + // dynamic import by replacing it with our own implementation. + // Note: This only works if the test environment supports it. + + it('throws protect_check_script_load_failed when import rejects', async () => { + // We test this by calling the function with an invalid URL that will fail + const { executeProtectCheck } = await import('../protectCheck/executeProtectCheck'); + + await expect(executeProtectCheck('data:text/javascript,INVALID', mockSignUp, containerEl)).rejects.toThrow( + ClerkRuntimeError, + ); + }); + + it('exports executeProtectCheck from the barrel', async () => { + const barrel = await import('../protectCheck/index'); + expect(barrel.executeProtectCheck).toBeDefined(); + expect(typeof barrel.executeProtectCheck).toBe('function'); + }); + }); +}); diff --git a/packages/clerk-js/src/utils/protectCheck/executeProtectCheck.ts b/packages/clerk-js/src/utils/protectCheck/executeProtectCheck.ts new file mode 100644 index 00000000000..f59aada5795 --- /dev/null +++ b/packages/clerk-js/src/utils/protectCheck/executeProtectCheck.ts @@ -0,0 +1,32 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import type { SignUpResource } from '@clerk/shared/types'; + +export async function executeProtectCheck( + scriptUrl: string, + signUp: SignUpResource, + containerEl: HTMLDivElement, +): Promise { + let mod: Record; + try { + mod = await import(/* webpackIgnore: true */ scriptUrl); + } catch (e) { + throw new ClerkRuntimeError('Protect check script failed to load', { + code: 'protect_check_script_load_failed', + }); + } + + if (typeof mod.default !== 'function') { + throw new ClerkRuntimeError('Protect check script has no default export', { + code: 'protect_check_invalid_script', + }); + } + + try { + const result = await mod.default(containerEl, signUp); + return result; + } catch (e) { + throw new ClerkRuntimeError('Protect check script execution failed', { + code: 'protect_check_execution_failed', + }); + } +} diff --git a/packages/clerk-js/src/utils/protectCheck/index.ts b/packages/clerk-js/src/utils/protectCheck/index.ts new file mode 100644 index 00000000000..c96ac057b01 --- /dev/null +++ b/packages/clerk-js/src/utils/protectCheck/index.ts @@ -0,0 +1 @@ +export { executeProtectCheck } from './executeProtectCheck'; diff --git a/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts b/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts index 09b39203e0a..eb840a1fb8c 100644 --- a/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts +++ b/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts @@ -27,6 +27,22 @@ export const completeSignUpFlow = ({ if (signUp.status === 'complete') { return handleComplete && handleComplete(); } else if (signUp.status === 'missing_requirements') { + if (signUp.missingFields.some(mf => mf === 'protect_check')) { + return signUp.runProtectCheck().then(() => { + return completeSignUpFlow({ + signUp, + verifyEmailPath, + verifyPhonePath, + continuePath, + navigate, + handleComplete, + redirectUrl, + redirectUrlComplete, + oidcPrompt, + }); + }); + } + if (signUp.missingFields.some(mf => mf === 'enterprise_sso')) { return signUp.authenticateWithRedirect({ strategy: 'enterprise_sso', diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index f81693798e1..b1ae1060755 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -62,7 +62,8 @@ export const SIGN_UP_MODES = { } satisfies Record; // This is the currently supported version of the Frontend API -export const SUPPORTED_FAPI_VERSION = '2025-11-10'; +export const SUPPORTED_FAPI_VERSION = '2026-03-01'; export const CAPTCHA_ELEMENT_ID = 'clerk-captcha'; export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha'; +export const PROTECT_CHECK_CONTAINER_ID = 'clerk-protect-check'; diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 9b3ca7d6ee3..85543bf3bbb 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -351,6 +351,7 @@ export interface SignUpVerificationsJSON { phone_number: SignUpVerificationJSON; web3_wallet: SignUpVerificationJSON; external_account: VerificationJSON; + protect_check: { url: string } | null; } export interface SignUpVerificationJSON extends VerificationJSON { diff --git a/packages/shared/src/types/signUp.ts b/packages/shared/src/types/signUp.ts index 38da8659e9b..4426ae36617 100644 --- a/packages/shared/src/types/signUp.ts +++ b/packages/shared/src/types/signUp.ts @@ -109,6 +109,9 @@ export interface SignUpResource extends ClerkResource { authenticateWithOKXWallet: (params?: SignUpAuthenticateWithWeb3Params) => Promise; authenticateWithBase: (params?: SignUpAuthenticateWithWeb3Params) => Promise; authenticateWithSolana: (params: SignUpAuthenticateWithSolanaParams) => Promise; + + runProtectCheck: () => Promise; + __internal_toSnapshot: () => SignUpJSONSnapshot; /** diff --git a/packages/shared/src/types/signUpCommon.ts b/packages/shared/src/types/signUpCommon.ts index 41c15035b46..bbcad33c045 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -24,7 +24,9 @@ import type { VerificationResource } from './verification'; export type SignUpStatus = 'missing_requirements' | 'complete' | 'abandoned'; -export type SignUpField = SignUpAttributeField | SignUpIdentificationField; +export type ProtectCheckField = 'protect_check'; + +export type SignUpField = SignUpAttributeField | SignUpIdentificationField | ProtectCheckField; export type PrepareVerificationParams = | { @@ -125,6 +127,7 @@ export interface SignUpVerificationsResource { phoneNumber: SignUpVerificationResource; externalAccount: VerificationResource; web3Wallet: VerificationResource; + protectCheck: { url: string } | null; __internal_toSnapshot: () => SignUpVerificationsJSONSnapshot; } diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index 099fe0e21a4..1fb3d560dc0 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -258,6 +258,12 @@ export interface SignUpFutureFinalizeParams { * An object that contains information about all available verification strategies. */ export interface SignUpFutureVerifications { + /** + * A protect check challenge returned by the backend, containing the URL of the script to execute. + * Null if no protect check is required. + */ + readonly protectCheck: { url: string } | null; + /** * An object holding information about the email address verification. */ @@ -327,6 +333,12 @@ export interface SignUpFutureVerifications { * Used to verify a code sent via phone. */ verifyPhoneCode: (params: SignUpFuturePhoneCodeVerifyParams) => Promise<{ error: ClerkError | null }>; + + /** + * Runs the protect check flow: prepares the check, executes the script, and attempts verification. + * This is called when 'protect_check' appears in the sign-up's missing fields. + */ + runProtectCheck: () => Promise<{ error: ClerkError | null }>; } /** diff --git a/packages/ui/src/components/SignUp/SignUpForm.tsx b/packages/ui/src/components/SignUp/SignUpForm.tsx index 6f8b09453d2..ad1572b1427 100644 --- a/packages/ui/src/components/SignUp/SignUpForm.tsx +++ b/packages/ui/src/components/SignUp/SignUpForm.tsx @@ -6,6 +6,7 @@ import type { FormControlState } from '@/ui/utils/useFormControl'; import { Col, localizationKeys, useAppearance } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; +import { ProtectCheckElement } from '../../elements/ProtectCheckElement'; import { mqu } from '../../styledSystem'; import type { ActiveIdentifier, Fields } from './signUpFormHelpers'; @@ -116,6 +117,7 @@ export const SignUpForm = (props: SignUpFormProps) => { )} + )} - {!shouldShowForm && } + {!shouldShowForm && ( + <> + + + + )} diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx index 39012596dbb..344ffac0e8e 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx @@ -1,3 +1,4 @@ +import { PROTECT_CHECK_CONTAINER_ID } from '@clerk/shared/internal/clerk-js/constants'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { SignUpResource } from '@clerk/shared/types'; import { describe, expect, it, vi } from 'vitest'; @@ -521,4 +522,23 @@ describe('SignUpStart', () => { await waitFor(() => screen.getByText(/create your account/i)); }); }); + + describe('Protect check container', () => { + it('renders the protect check container element when form is shown', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress({ required: true }); + f.withPassword({ required: true }); + }); + const { container } = render(, { wrapper }); + expect(container.querySelector(`#${PROTECT_CHECK_CONTAINER_ID}`)).toBeTruthy(); + }); + + it('renders the protect check container element when only OAuth is shown', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + const { container } = render(, { wrapper }); + expect(container.querySelector(`#${PROTECT_CHECK_CONTAINER_ID}`)).toBeTruthy(); + }); + }); }); diff --git a/packages/ui/src/elements/ProtectCheckElement.tsx b/packages/ui/src/elements/ProtectCheckElement.tsx new file mode 100644 index 00000000000..612c231c9fe --- /dev/null +++ b/packages/ui/src/elements/ProtectCheckElement.tsx @@ -0,0 +1,54 @@ +import { PROTECT_CHECK_CONTAINER_ID } from '@clerk/shared/internal/clerk-js/constants'; +import { useEffect, useRef } from 'react'; + +import { Box } from '../customizables'; + +/** + * This component provides a container div for protect check scripts to render into. + * Uses a MutationObserver (same pattern as CaptchaElement) to respond to style changes + * made by protect check scripts outside React's lifecycle. + */ +export const ProtectCheckElement = () => { + const elementRef = useRef(null); + const maxHeightValueRef = useRef('0'); + const minHeightValueRef = useRef('unset'); + const marginBottomValueRef = useRef('unset'); + + useEffect(() => { + if (!elementRef.current) { + return; + } + + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + const target = mutation.target as HTMLDivElement; + if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) { + maxHeightValueRef.current = target.style.maxHeight || '0'; + minHeightValueRef.current = target.style.minHeight || 'unset'; + marginBottomValueRef.current = target.style.marginBottom || 'unset'; + } + }); + }); + + observer.observe(elementRef.current, { + attributes: true, + attributeFilter: ['style'], + }); + + return () => observer.disconnect(); + }, []); + + return ( + + ); +}; diff --git a/packages/ui/src/elements/__tests__/ProtectCheckElement.test.tsx b/packages/ui/src/elements/__tests__/ProtectCheckElement.test.tsx new file mode 100644 index 00000000000..51e8f735e6f --- /dev/null +++ b/packages/ui/src/elements/__tests__/ProtectCheckElement.test.tsx @@ -0,0 +1,23 @@ +import { PROTECT_CHECK_CONTAINER_ID } from '@clerk/shared/internal/clerk-js/constants'; +import { describe, expect, it } from 'vitest'; + +import { render } from '@/test/utils'; + +import { ProtectCheckElement } from '../ProtectCheckElement'; + +describe('ProtectCheckElement', () => { + it('renders a div with the correct id', () => { + const { container } = render(); + const el = container.querySelector(`#${PROTECT_CHECK_CONTAINER_ID}`); + expect(el).toBeTruthy(); + expect(el?.tagName).toBe('DIV'); + }); + + it('renders with initial hidden styles', () => { + const { container } = render(); + const el = container.querySelector(`#${PROTECT_CHECK_CONTAINER_ID}`) as HTMLElement; + expect(el.style.display).toBe('block'); + expect(el.style.alignSelf).toBe('center'); + expect(el.style.maxHeight).toBe('0'); + }); +});