From 893780e24210fda5172a6ab10316a8a2f1907d57 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:48:19 -0600 Subject: [PATCH] fix(clerk-js): Conditionally call captcha during sign-up --- .changeset/sour-walls-exist.md | 5 + .../clerk-js/src/core/resources/SignUp.ts | 37 ++++++- .../core/resources/__tests__/SignUp.test.ts | 99 +++++++++++++++++++ 3 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 .changeset/sour-walls-exist.md diff --git a/.changeset/sour-walls-exist.md b/.changeset/sour-walls-exist.md new file mode 100644 index 00000000000..61230414ba0 --- /dev/null +++ b/.changeset/sour-walls-exist.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fixes issue where captcha was always called during signup. diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index e5d384be69e..25b50a55683 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -786,11 +786,42 @@ class SignUpFuture implements SignUpFutureResource { return this.#canBeDiscarded; } - private async getCaptchaToken(): Promise<{ + private shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) { + if (!params.strategy) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const captchaOauthBypass = SignUp.clerk.__internal_environment!.displayConfig.captchaOauthBypass; + + if (captchaOauthBypass.some(strategy => strategy === params.strategy)) { + return true; + } + + if ( + params.transfer && + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + captchaOauthBypass.some(strategy => strategy === SignUp.clerk.client!.signIn.firstFactorVerification.strategy) + ) { + return true; + } + + return false; + } + + private async getCaptchaToken(params: { strategy?: string; transfer?: boolean } = {}): Promise<{ captchaToken?: string; captchaWidgetType?: CaptchaWidgetType; captchaError?: unknown; }> { + if (__BUILD_DISABLE_RHC__ || SignUp.clerk.client?.captchaBypass || this.shouldBypassCaptchaForAttempt(params)) { + return { + captchaToken: undefined, + captchaWidgetType: undefined, + captchaError: undefined, + }; + } + const captchaChallenge = new CaptchaChallenge(SignUp.clerk); const response = await captchaChallenge.managedOrInvisible({ action: 'signup' }); if (!response) { @@ -802,7 +833,7 @@ class SignUpFuture implements SignUpFutureResource { } private async _create(params: SignUpFutureCreateParams): Promise { - const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(); + const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params); const body: Record = { transfer: params.transfer, @@ -955,7 +986,7 @@ class SignUpFuture implements SignUpFutureResource { popup, } = params; return runAsyncResourceTask(this.#resource, async () => { - const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(); + const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken({ strategy }); let redirectUrlComplete = redirectUrl; try { 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 270af93a3e3..9723f8b751e 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -16,6 +16,7 @@ vi.mock('../../../utils/authenticateWithPopup', async () => { // Import the mocked function after mocking import { _futureAuthenticateWithPopup } from '../../../utils/authenticateWithPopup'; +import { CaptchaChallenge } from '../../../utils/captcha/CaptchaChallenge'; // Mock the CaptchaChallenge module vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({ @@ -42,9 +43,14 @@ describe('SignUp', () => { }); describe('create', () => { + beforeEach(() => { + SignUp.clerk = {} as any; + }); + afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); + SignUp.clerk = {} as any; }); it('includes locale in request when navigator.language is available', async () => { @@ -106,6 +112,46 @@ describe('SignUp', () => { expect(result).toHaveProperty('error', null); }); + + it('runs captcha challenge when bypass is not enabled', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp(); + await signUp.__internal_future.create({ emailAddress: 'user@example.com' }); + + expect(CaptchaChallenge).toHaveBeenCalledWith(SignUp.clerk); + const challengeInstance = vi.mocked(CaptchaChallenge).mock.results[0]?.value as { + managedOrInvisible: ReturnType; + }; + expect(challengeInstance.managedOrInvisible).toHaveBeenCalledWith({ action: 'signup' }); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaToken', 'mock_token'); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', 'invisible'); + }); + + it('skips captcha challenge when client captcha bypass is enabled', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + SignUp.clerk = { + client: { + captchaBypass: true, + }, + } as any; + + const signUp = new SignUp(); + await signUp.__internal_future.create({ emailAddress: 'user@example.com' }); + + expect(CaptchaChallenge).not.toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaToken', undefined); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', undefined); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined); + }); }); describe('sendPhoneCode', () => { @@ -333,6 +379,7 @@ describe('SignUp', () => { afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); + SignUp.clerk = {} as any; }); it('handles relative redirectUrl by converting to absolute', async () => { @@ -341,6 +388,11 @@ describe('SignUp', () => { const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback'); SignUp.clerk = { buildUrlWithAuth: mockBuildUrlWithAuth, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn().mockResolvedValue({ @@ -383,6 +435,11 @@ describe('SignUp', () => { const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback'); SignUp.clerk = { buildUrlWithAuth: mockBuildUrlWithAuth, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn().mockResolvedValue({ @@ -436,6 +493,9 @@ describe('SignUp', () => { buildUrl: vi.fn().mockImplementation(path => 'https://example.com' + path), frontendApi: 'clerk.example.com', __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, reload: vi.fn().mockResolvedValue({}), }, } as any; @@ -487,6 +547,45 @@ describe('SignUp', () => { }), ); }); + + it('skips captcha challenge for strategies configured in captcha oauth bypass', async () => { + vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); + + const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback'); + SignUp.clerk = { + buildUrlWithAuth: mockBuildUrlWithAuth, + __internal_environment: { + displayConfig: { + captchaOauthBypass: ['oauth_google'], + }, + }, + } as any; + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signup_123', + verifications: { + externalAccount: { + status: 'complete', + }, + }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp(); + await signUp.__internal_future.sso({ + strategy: 'oauth_google', + redirectUrl: '/complete', + redirectCallbackUrl: '/sso-callback', + }); + + expect(CaptchaChallenge).not.toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaToken', undefined); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', undefined); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined); + }); }); describe('web3', () => {