From 145d69775c031d5575d9607e1f47de39cf979e72 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Mar 2026 13:02:23 -0500 Subject: [PATCH 1/4] Revert "fix(clerk-js): Revert removal of transfer strategy passing (#7962)" This reverts commit 25d37b03605365395d5d7a667ce657ab243a0a68. --- .changeset/strict-needles-taste.md | 6 + .changeset/twelve-foxes-obey.md | 6 - .../clerk-js/src/core/resources/SignIn.ts | 140 +++++++- .../clerk-js/src/core/resources/SignUp.ts | 51 ++- .../core/resources/__tests__/SignIn.test.ts | 300 ++++++++++++++++++ .../core/resources/__tests__/SignUp.test.ts | 91 +++++- packages/clerk-js/src/utils/captcha/types.ts | 2 +- packages/shared/src/types/signInCommon.ts | 9 +- packages/shared/src/types/signInFuture.ts | 5 + 9 files changed, 567 insertions(+), 43 deletions(-) create mode 100644 .changeset/strict-needles-taste.md delete mode 100644 .changeset/twelve-foxes-obey.md diff --git a/.changeset/strict-needles-taste.md b/.changeset/strict-needles-taste.md new file mode 100644 index 00000000000..85d3b83791f --- /dev/null +++ b/.changeset/strict-needles-taste.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Support `sign_up_if_missing` on SignIn.create, including captcha diff --git a/.changeset/twelve-foxes-obey.md b/.changeset/twelve-foxes-obey.md deleted file mode 100644 index 7f22dbcc18e..00000000000 --- a/.changeset/twelve-foxes-obey.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@clerk/clerk-js': minor -'@clerk/shared': minor ---- - -Revert sign up if missing changes to fix Enterprise SSO captcha diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 8535b013cca..487ccb12f76 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -16,6 +16,7 @@ import type { AuthenticateWithPopupParams, AuthenticateWithRedirectParams, AuthenticateWithWeb3Params, + CaptchaWidgetType, ClientTrustState, CreateEmailLinkFlowReturn, EmailCodeConfig, @@ -82,6 +83,7 @@ import { _futureAuthenticateWithPopup, wrapWithPopupRoutes, } from '../../utils/authenticateWithPopup'; +import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; import { loadZxcvbn } from '../../utils/zxcvbn'; import { @@ -164,12 +166,34 @@ export class SignIn extends BaseResource implements SignInResource { this.fromJSON(data); } - create = (params: SignInCreateParams): Promise => { + create = async (params: SignInCreateParams): Promise => { debugLogger.debug('SignIn.create', { id: this.id, strategy: 'strategy' in params ? params.strategy : undefined }); - const locale = getBrowserLocale(); + + let body: Record = { ...params }; + + // Inject browser locale + const browserLocale = getBrowserLocale(); + if (browserLocale) { + body.locale = browserLocale; + } + + if ( + this.shouldRequireCaptcha(params) && + !__BUILD_DISABLE_RHC__ && + !this.clientBypass() && + !this.shouldBypassCaptchaForAttempt(params) + ) { + const captchaChallenge = new CaptchaChallenge(SignIn.clerk); + const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' }); + if (!captchaParams) { + throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); + } + body = { ...body, ...captchaParams }; + } + return this._basePost({ path: this.pathRoot, - body: locale ? { locale, ...params } : params, + body: body, }); }; @@ -576,6 +600,43 @@ export class SignIn extends BaseResource implements SignInResource { return this; } + private clientBypass() { + return SignIn.clerk.client?.captchaBypass; + } + + /** + * Determines whether captcha is required for sign in based on the provided params. + * Add new conditions here as captcha requirements evolve. + */ + private shouldRequireCaptcha(params: SignInCreateParams): boolean { + if ('signUpIfMissing' in params && params.signUpIfMissing) { + return true; + } + + return false; + } + + /** + * We delegate bot detection to the following providers, instead of relying on turnstile exclusively + */ + protected shouldBypassCaptchaForAttempt(params: SignInCreateParams) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const captchaOauthBypass = SignIn.clerk.__internal_environment!.displayConfig.captchaOauthBypass; + + // Check transfer strategy against bypass list + if (params.transfer && SignIn.clerk.client?.signUp?.verifications?.externalAccount?.status === 'transferable') { + const signUpStrategy = SignIn.clerk.client.signUp.verifications.externalAccount.strategy; + return signUpStrategy ? captchaOauthBypass.some(strategy => strategy === signUpStrategy) : false; + } + + // Check direct strategy against bypass list + if ('strategy' in params && params.strategy) { + return captchaOauthBypass.some(strategy => strategy === params.strategy); + } + + return false; + } + public __internal_updateFromJSON(data: SignInJSON | SignInJSONSnapshot | null): this { return this.fromJSON(data); } @@ -814,11 +875,80 @@ class SignInFuture implements SignInFutureResource { }); } + /** + * Determines whether captcha is required for sign in based on the provided params. + * Add new conditions here as captcha requirements evolve. + */ + private shouldRequireCaptcha(params: { signUpIfMissing?: boolean }): boolean { + if (params.signUpIfMissing) { + return true; + } + + return false; + } + + private shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const captchaOauthBypass = SignIn.clerk.__internal_environment!.displayConfig.captchaOauthBypass; + + // Check transfer strategy against bypass list + if (params.transfer && SignIn.clerk.client?.signUp?.verifications?.externalAccount?.status === 'transferable') { + const signUpStrategy = SignIn.clerk.client.signUp.verifications.externalAccount.strategy; + return signUpStrategy ? captchaOauthBypass.some(strategy => strategy === signUpStrategy) : false; + } + + // Check direct strategy against bypass list + if (params.strategy) { + return captchaOauthBypass.some(strategy => strategy === params.strategy); + } + + return false; + } + + private async getCaptchaToken( + params: { strategy?: string; transfer?: boolean; signUpIfMissing?: boolean } = {}, + ): Promise<{ + captchaToken?: string; + captchaWidgetType?: CaptchaWidgetType; + captchaError?: unknown; + }> { + if ( + !this.shouldRequireCaptcha(params) || + __BUILD_DISABLE_RHC__ || + SignIn.clerk.client?.captchaBypass || + this.shouldBypassCaptchaForAttempt(params) + ) { + return { + captchaToken: undefined, + captchaWidgetType: undefined, + captchaError: undefined, + }; + } + + const captchaChallenge = new CaptchaChallenge(SignIn.clerk); + const response = await captchaChallenge.managedOrInvisible({ action: 'signin' }); + if (!response) { + throw new Error('Captcha challenge failed'); + } + + const { captchaError, captchaToken, captchaWidgetType } = response; + return { captchaToken, captchaWidgetType, captchaError }; + } + private async _create(params: SignInFutureCreateParams): Promise { - const locale = getBrowserLocale(); + const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params); + + const body: Record = { + ...params, + captchaToken, + captchaWidgetType, + captchaError, + locale: getBrowserLocale() || undefined, + }; + await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, - body: locale ? { locale, ...params } : params, + body, }); } diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index d76d99ea7fd..048c0ffe6f8 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -173,13 +173,6 @@ export class SignUp extends BaseResource implements SignUpResource { finalParams = { ...finalParams, ...captchaParams }; } - if (finalParams.transfer && this.shouldBypassCaptchaForAttempt(finalParams)) { - const strategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy; - if (strategy) { - finalParams = { ...finalParams, strategy: strategy as SignUpCreateParams['strategy'] }; - } - } - return this._basePost({ path: this.pathRoot, body: normalizeUnsafeMetadata(finalParams), @@ -561,22 +554,24 @@ export class SignUp extends BaseResource implements SignUpResource { * We delegate bot detection to the following providers, instead of relying on turnstile exclusively */ protected shouldBypassCaptchaForAttempt(params: SignUpCreateParams) { - 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)) { + // For transfers, inspect the SignIn strategy to determine bypass logic + if (params.transfer && SignUp.clerk.client?.signIn?.firstFactorVerification?.status === 'transferable') { + const signInStrategy = SignUp.clerk.client.signIn.firstFactorVerification.strategy; + + // OAuth transfer: Check if strategy is in bypass list + if (signInStrategy?.startsWith('oauth_')) { + return captchaOauthBypass.some(strategy => strategy === signInStrategy); + } + + // Non-OAuth transfer (signUpIfMissing): Captcha already validated during SignIn 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) - ) { + // For direct SignUp (not transfer), check OAuth bypass + if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) { return true; } @@ -787,22 +782,24 @@ class SignUpFuture implements SignUpFutureResource { } 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)) { + // For transfers, inspect the SignIn strategy to determine bypass logic + if (params.transfer && SignUp.clerk.client?.signIn?.firstFactorVerification?.status === 'transferable') { + const signInStrategy = SignUp.clerk.client.signIn.firstFactorVerification.strategy; + + // OAuth transfer: Check if strategy is in bypass list + if (signInStrategy?.startsWith('oauth_')) { + return captchaOauthBypass.some(strategy => strategy === signInStrategy); + } + + // Non-OAuth transfer (signUpIfMissing): Captcha already validated during SignIn 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) - ) { + // For direct SignUp (not transfer), check OAuth bypass + if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) { return true; } 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 929d663389d..76d82b08de8 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -17,6 +17,16 @@ vi.mock('../../../utils/authenticateWithPopup', async () => { // Import the mocked function after mocking import { _futureAuthenticateWithPopup } from '../../../utils/authenticateWithPopup'; +// Mock the CaptchaChallenge module +vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({ + CaptchaChallenge: vi.fn().mockImplementation(() => ({ + managedOrInvisible: vi.fn().mockResolvedValue({ + captchaToken: 'mock_captcha_token', + captchaWidgetType: 'invisible', + }), + })), +})); + describe('SignIn', () => { it('can be serialized with JSON.stringify', () => { const signIn = new SignIn(); @@ -40,6 +50,17 @@ describe('SignIn', () => { BaseResource._fetch = mockFetch; const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + await signIn.create({ identifier: 'user@example.com' }); expect(mockFetch).toHaveBeenCalledWith( @@ -64,6 +85,17 @@ describe('SignIn', () => { BaseResource._fetch = mockFetch; const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + await signIn.create({ identifier: 'user@example.com' }); expect(mockFetch).toHaveBeenCalledWith( @@ -76,6 +108,98 @@ describe('SignIn', () => { }), ); }); + + it('includes captcha params when signUpIfMissing is true', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.create({ identifier: 'user@example.com', signUpIfMissing: true }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins', + body: expect.objectContaining({ + identifier: 'user@example.com', + signUpIfMissing: true, + captchaToken: 'mock_captcha_token', + captchaWidgetType: 'invisible', + }), + }), + ); + }); + + it('excludes captcha params when signUpIfMissing is false', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.create({ identifier: 'user@example.com', signUpIfMissing: false }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins', + body: expect.not.objectContaining({ + captchaToken: expect.anything(), + captchaWidgetType: expect.anything(), + }), + }), + ); + }); }); describe('SignInFuture', () => { @@ -246,6 +370,136 @@ describe('SignIn', () => { expect(result).toHaveProperty('error', mockError); }); + + it('includes captcha params when signUpIfMissing is true', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.__internal_future.create({ identifier: 'user@example.com', signUpIfMissing: true }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ins', + body: { + identifier: 'user@example.com', + signUpIfMissing: true, + captchaToken: 'mock_captcha_token', + captchaWidgetType: 'invisible', + }, + }); + }); + + it('excludes captcha params when signUpIfMissing is false', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.__internal_future.create({ identifier: 'user@example.com', signUpIfMissing: false }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ins', + body: { + identifier: 'user@example.com', + signUpIfMissing: false, + }, + }); + }); + + it('excludes captcha params when signUpIfMissing is not provided', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.__internal_future.create({ identifier: 'user@example.com' }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ins', + body: { + identifier: 'user@example.com', + }, + }); + }); }); describe('password', () => { @@ -1236,6 +1490,11 @@ describe('SignIn', () => { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_isWebAuthnAutofillSupported: mockIsWebAuthnAutofillSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1290,6 +1549,11 @@ describe('SignIn', () => { SignIn.clerk = { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1363,6 +1627,11 @@ describe('SignIn', () => { SignIn.clerk = { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn().mockResolvedValue({ @@ -1450,6 +1719,14 @@ describe('SignIn', () => { }); it('authenticates with metamask strategy', async () => { + SignIn.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + const mockFetch = vi .fn() .mockResolvedValueOnce({ @@ -1786,6 +2063,11 @@ describe('SignIn', () => { const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback'); SignIn.clerk = { buildUrlWithAuth: mockBuildUrlWithAuth, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1834,6 +2116,11 @@ describe('SignIn', () => { const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback'); SignIn.clerk = { buildUrlWithAuth: mockBuildUrlWithAuth, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn().mockResolvedValue({ @@ -1882,6 +2169,11 @@ describe('SignIn', () => { buildUrlWithAuth: mockBuildUrlWithAuth, buildUrl: vi.fn().mockImplementation(path => 'https://example.com' + path), frontendApi: 'clerk.example.com', + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn(); @@ -1957,6 +2249,14 @@ describe('SignIn', () => { }); vi.stubGlobal('URLSearchParams', vi.fn().mockReturnValue(mockSearchParams)); + SignIn.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + const mockFetch = vi.fn().mockResolvedValue({ client: null, response: { id: 'signin_123' }, 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..3f73fa7d266 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -44,7 +44,13 @@ describe('SignUp', () => { describe('create', () => { beforeEach(() => { - SignUp.clerk = {} as any; + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; }); afterEach(() => { @@ -152,6 +158,37 @@ describe('SignUp', () => { expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', undefined); expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined); }); + + it('skips captcha challenge for non-OAuth transfer (sign_up_if_missing)', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + SignUp.clerk = { + client: { + signIn: { + firstFactorVerification: { + status: 'transferable', + strategy: 'email_code', + }, + }, + }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: ['oauth_google', 'oauth_apple'], + }, + }, + } as any; + + const signUp = new SignUp(); + await signUp.__internal_future.create({ transfer: true }); + + 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', () => { @@ -160,6 +197,30 @@ describe('SignUp', () => { vi.unstubAllGlobals(); }); + it('calls prepare verification directly even with no existing signup', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp(); + await signUp.__internal_future.verifications.sendPhoneCode({ phoneNumber: '+15551234567' }); + + // Should call prepare verification directly + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ups', + body: expect.objectContaining({ + strategy: 'phone_code', + channel: 'sms', + }), + }), + ); + }); + it('uses existing signup when already created', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, @@ -549,7 +610,13 @@ describe('SignUp', () => { describe('web3', () => { beforeEach(() => { - SignUp.clerk = {} as any; + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; }); afterEach(() => { @@ -805,6 +872,16 @@ describe('SignUp', () => { }); describe('password', () => { + beforeEach(() => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + }); + afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); @@ -870,6 +947,16 @@ describe('SignUp', () => { }); describe('ticket', () => { + beforeEach(() => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + }); + afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); diff --git a/packages/clerk-js/src/utils/captcha/types.ts b/packages/clerk-js/src/utils/captcha/types.ts index 37abf1aaca8..b7c13fa7858 100644 --- a/packages/clerk-js/src/utils/captcha/types.ts +++ b/packages/clerk-js/src/utils/captcha/types.ts @@ -1,7 +1,7 @@ import type { CaptchaProvider, CaptchaWidgetType } from '@clerk/shared/types'; export type CaptchaOptions = { - action?: 'verify' | 'signup' | 'heartbeat'; + action?: 'verify' | 'signin' | 'signup' | 'heartbeat'; captchaProvider: CaptchaProvider; closeModal?: () => Promise; invisibleSiteKey: string; diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 52917cd1293..40e255b8cf1 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -162,8 +162,13 @@ export type SignInCreateParams = ( | { identifier: string; } - | { transfer?: boolean } -) & { transfer?: boolean }; + | { + transfer?: boolean; + } +) & { + transfer?: boolean; + signUpIfMissing?: boolean; +}; export type ResetPasswordParams = { password: string; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index a217a30843a..fcd83bdfb2e 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -37,6 +37,11 @@ export interface SignInFutureCreateParams { * generated from the Backend API. **Required** if `strategy` is set to `'ticket'`. */ ticket?: string; + /** + * When set to `true`, if a user does not exist, the sign-up will prepare a transfer to sign up a new + * account. If bot sign-up protection is enabled, captcha will also be required on sign in. + */ + signUpIfMissing?: boolean; } export type SignInFuturePasswordParams = { From 6df492ab1f341b0c2fea67f1e4e099eb069a14eb Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Mar 2026 21:21:20 -0500 Subject: [PATCH 2/4] fix: Refactor captcha bypass check on sign up --- .../clerk-js/src/core/resources/SignUp.ts | 64 +++++++--- .../core/resources/__tests__/SignUp.test.ts | 118 +++++++++++++++++- 2 files changed, 162 insertions(+), 20 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 048c0ffe6f8..1228aaf46bc 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -557,20 +557,26 @@ export class SignUp extends BaseResource implements SignUpResource { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const captchaOauthBypass = SignUp.clerk.__internal_environment!.displayConfig.captchaOauthBypass; - // For transfers, inspect the SignIn strategy to determine bypass logic - if (params.transfer && SignUp.clerk.client?.signIn?.firstFactorVerification?.status === 'transferable') { - const signInStrategy = SignUp.clerk.client.signIn.firstFactorVerification.strategy; + // Check for transfer captcha bypass. + if (params.transfer) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const signInVerificationStrategy = SignUp.clerk.client!.signIn.firstFactorVerification.strategy; - // OAuth transfer: Check if strategy is in bypass list - if (signInStrategy?.startsWith('oauth_')) { - return captchaOauthBypass.some(strategy => strategy === signInStrategy); + // OAuth transfers: If we delegate captcha detection to OAuth provider, + // do not show another captcha on sign up. + if (captchaOauthBypass.some(strategy => strategy === signInVerificationStrategy)) { + return true; } - // Non-OAuth transfer (signUpIfMissing): Captcha already validated during SignIn - return true; + // Sign up if missing transfers: We let sign in handle the captcha, + // do not show another captcha on sign up. + if (isCaptchaBypassableVerificationStrategy(signInVerificationStrategy)) { + return true; + } } - // For direct SignUp (not transfer), check OAuth bypass + // OAuth sign ups: If we delegate captcha detection to OAuth provider, + // do not show another captcha on sign up. if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) { return true; } @@ -590,6 +596,22 @@ export class SignUp extends BaseResource implements SignUpResource { }; } +/** + * Returns true if the given strategy is one where captcha is already handled + * by the sign-in attempt with sign up if missing, so a subsequent sign-up should + * not show another captcha. Matches email_link, email_code, phone_code, and any + * web3 wallet strategy. This should be kept in sync with `validateSignUpIfMissing` + * in the backend. + */ +export function isCaptchaBypassableVerificationStrategy(strategy: string | null): boolean { + if (!strategy) { + return false; + } + return ( + strategy === 'email_link' || strategy === 'email_code' || strategy === 'phone_code' || strategy.startsWith('web3_') + ); +} + type SignUpFutureVerificationsMethods = Pick< SignUpFutureVerifications, | 'sendEmailCode' @@ -785,20 +807,26 @@ class SignUpFuture implements SignUpFutureResource { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const captchaOauthBypass = SignUp.clerk.__internal_environment!.displayConfig.captchaOauthBypass; - // For transfers, inspect the SignIn strategy to determine bypass logic - if (params.transfer && SignUp.clerk.client?.signIn?.firstFactorVerification?.status === 'transferable') { - const signInStrategy = SignUp.clerk.client.signIn.firstFactorVerification.strategy; + // Check for transfer captcha bypass. + if (params.transfer) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const signInVerificationStrategy = SignUp.clerk.client!.signIn.firstFactorVerification.strategy; - // OAuth transfer: Check if strategy is in bypass list - if (signInStrategy?.startsWith('oauth_')) { - return captchaOauthBypass.some(strategy => strategy === signInStrategy); + // OAuth transfers: If we delegate captcha detection to OAuth provider, + // do not show another captcha on sign up. + if (captchaOauthBypass.some(strategy => strategy === signInVerificationStrategy)) { + return true; } - // Non-OAuth transfer (signUpIfMissing): Captcha already validated during SignIn - return true; + // Sign up if missing transfers: We let sign in handle the captcha, + // do not show another captcha on sign up. + if (isCaptchaBypassableVerificationStrategy(signInVerificationStrategy)) { + return true; + } } - // For direct SignUp (not transfer), check OAuth bypass + // OAuth sign ups: If we delegate captcha detection to OAuth provider, + // do not show another captcha on sign up. if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) { return true; } 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 3f73fa7d266..82e2817bb83 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -35,6 +35,86 @@ describe('SignUp', () => { expect(snapshot).toBeDefined(); }); + describe('create', () => { + beforeEach(() => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + SignUp.clerk = {} as any; + }); + + it.each([ + { strategy: 'email_code', label: 'email_code' }, + { strategy: 'email_link', label: 'email_link' }, + { strategy: 'phone_code', label: 'phone_code' }, + { strategy: 'web3_metamask_signature', label: 'web3_metamask_signature' }, + { strategy: 'web3_coinbase_wallet_signature', label: 'web3_coinbase_wallet_signature' }, + ])('skips captcha challenge for $label transfer (sign_up_if_missing)', async ({ strategy }) => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + SignUp.clerk = { + client: { + signIn: { + firstFactorVerification: { + status: 'transferable', + strategy, + }, + }, + }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: ['oauth_google', 'oauth_apple'], + }, + }, + } as any; + + const signUp = new SignUp(); + await signUp.create({ transfer: true }); + + expect(CaptchaChallenge).not.toHaveBeenCalled(); + }); + + it('does not skip captcha challenge for enterprise_sso transfer', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + SignUp.clerk = { + client: { + signIn: { + firstFactorVerification: { + status: 'transferable', + strategy: 'enterprise_sso', + }, + }, + }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + + const signUp = new SignUp(); + await signUp.create({ transfer: true }); + + expect(CaptchaChallenge).toHaveBeenCalledWith(SignUp.clerk); + }); + }); + describe('SignUpFuture', () => { it('can be serialized with JSON.stringify', () => { const signUp = new SignUp(); @@ -159,7 +239,13 @@ describe('SignUp', () => { expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined); }); - it('skips captcha challenge for non-OAuth transfer (sign_up_if_missing)', async () => { + it.each([ + { strategy: 'email_code', label: 'email_code' }, + { strategy: 'email_link', label: 'email_link' }, + { strategy: 'phone_code', label: 'phone_code' }, + { strategy: 'web3_metamask_signature', label: 'web3_metamask_signature' }, + { strategy: 'web3_coinbase_wallet_signature', label: 'web3_coinbase_wallet_signature' }, + ])('skips captcha challenge for $label transfer (sign_up_if_missing)', async ({ strategy }) => { const mockFetch = vi.fn().mockResolvedValue({ client: null, response: { id: 'signup_123', status: 'missing_requirements' }, @@ -170,7 +256,7 @@ describe('SignUp', () => { signIn: { firstFactorVerification: { status: 'transferable', - strategy: 'email_code', + strategy, }, }, }, @@ -189,6 +275,34 @@ describe('SignUp', () => { expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', undefined); expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined); }); + + it('does not skip captcha challenge for enterprise_sso transfer', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + SignUp.clerk = { + client: { + signIn: { + firstFactorVerification: { + status: 'transferable', + strategy: 'enterprise_sso', + }, + }, + }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + + const signUp = new SignUp(); + await signUp.__internal_future.create({ transfer: true }); + + expect(CaptchaChallenge).toHaveBeenCalledWith(SignUp.clerk); + }); }); describe('sendPhoneCode', () => { From 0cb052dcc16be0ae7189ac5582dc2993fecf7259 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 10 Mar 2026 13:01:11 -0400 Subject: [PATCH 3/4] refactor: Add sign up if missing to name --- packages/clerk-js/src/core/resources/SignUp.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 1228aaf46bc..e2881dfde6f 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -570,7 +570,7 @@ export class SignUp extends BaseResource implements SignUpResource { // Sign up if missing transfers: We let sign in handle the captcha, // do not show another captcha on sign up. - if (isCaptchaBypassableVerificationStrategy(signInVerificationStrategy)) { + if (isSignUpIfMissingCaptchaBypassStrategy(signInVerificationStrategy)) { return true; } } @@ -603,13 +603,13 @@ export class SignUp extends BaseResource implements SignUpResource { * web3 wallet strategy. This should be kept in sync with `validateSignUpIfMissing` * in the backend. */ -export function isCaptchaBypassableVerificationStrategy(strategy: string | null): boolean { +const SIGN_UP_IF_MISSING_CAPTCHA_BYPASS_STRATEGIES = new Set(['email_link', 'email_code', 'phone_code']); + +export function isSignUpIfMissingCaptchaBypassStrategy(strategy: string | null): boolean { if (!strategy) { return false; } - return ( - strategy === 'email_link' || strategy === 'email_code' || strategy === 'phone_code' || strategy.startsWith('web3_') - ); + return SIGN_UP_IF_MISSING_CAPTCHA_BYPASS_STRATEGIES.has(strategy) || strategy.startsWith('web3_'); } type SignUpFutureVerificationsMethods = Pick< @@ -820,7 +820,7 @@ class SignUpFuture implements SignUpFutureResource { // Sign up if missing transfers: We let sign in handle the captcha, // do not show another captcha on sign up. - if (isCaptchaBypassableVerificationStrategy(signInVerificationStrategy)) { + if (isSignUpIfMissingCaptchaBypassStrategy(signInVerificationStrategy)) { return true; } } From f84547cd49848f985c1e54b9e7e2f29f47729e01 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 10 Mar 2026 13:01:32 -0400 Subject: [PATCH 4/4] test: Add explicit transfer oauth captcha bypass test --- .../core/resources/__tests__/SignUp.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) 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 82e2817bb83..96257b65b73 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -86,6 +86,34 @@ describe('SignUp', () => { expect(CaptchaChallenge).not.toHaveBeenCalled(); }); + it('skips captcha challenge for oauth transfer when strategy is in captchaOauthBypass', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + SignUp.clerk = { + client: { + signIn: { + firstFactorVerification: { + status: 'transferable', + strategy: 'oauth_google', + }, + }, + }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: ['oauth_google', 'oauth_apple'], + }, + }, + } as any; + + const signUp = new SignUp(); + await signUp.create({ transfer: true }); + + expect(CaptchaChallenge).not.toHaveBeenCalled(); + }); + it('does not skip captcha challenge for enterprise_sso transfer', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, @@ -276,6 +304,37 @@ describe('SignUp', () => { expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined); }); + it('skips captcha challenge for oauth transfer when strategy is in captchaOauthBypass', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + SignUp.clerk = { + client: { + signIn: { + firstFactorVerification: { + status: 'transferable', + strategy: 'oauth_google', + }, + }, + }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: ['oauth_google', 'oauth_apple'], + }, + }, + } as any; + + const signUp = new SignUp(); + await signUp.__internal_future.create({ transfer: true }); + + 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); + }); + it('does not skip captcha challenge for enterprise_sso transfer', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null,