From d5f77917918fdbcd821b8a82dde4741c0f7915ad Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Feb 2026 15:23:34 -0500 Subject: [PATCH 1/4] feat(clerk-js): Add sign_up_if_missing for SignIn.create We are building a new sign-in-or-sign-up flow compatible with strict enumeration protection. It is meant to complement our existing sign-in-or-sign-up flow. Our current sign-in-or-sign-up flow is managed by the SDKs: We start with a sign in attempt, and on a 422 (when an account does not exist), we redirect to a sign up. The flow is thus: Sign In (422) -> Sign Up (200) -> Attempt Verifications for VerifiedAtSignUp identifiers (200). This is vulnerable to user enumeration attacks because the attacker sees the sign in to sign up redirect before they prove their identity by completing a verification. When `sign_up_if_missing` is passed as a param when POSTing a sign in, instead we do the following: Sign In (200) -> Attempt Verification for Identifier (200) -> Create User and Session. (In future work this third step will be modified to support adding additional information to the user, either via AccountTransfer or Session Tasks). This is enumeration safe, because you only see if an account already existed or was created after you verify your identity. This PR is the first step in SDK support for this new flow. We add support for the optional `sign_up_if_missing` param on `SignIn`. We also add captcha support for `SignIn`. This is all optional and currently in testing with custom components. Support in Clerk components will be in future PRs. --- .changeset/strict-needles-taste.md | 6 + .../clerk-js/src/core/resources/SignIn.ts | 125 +++++++++++++- .../core/resources/__tests__/SignIn.test.ts | 160 ++++++++++++++++++ packages/clerk-js/src/utils/captcha/types.ts | 2 +- packages/shared/src/types/signInCommon.ts | 14 +- packages/shared/src/types/signInFuture.ts | 23 +++ 6 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 .changeset/strict-needles-taste.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/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 998fb276c03..f7d40dcd484 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, @@ -80,6 +81,7 @@ import { _futureAuthenticateWithPopup, wrapWithPopupRoutes, } from '../../utils/authenticateWithPopup'; +import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; import { loadZxcvbn } from '../../utils/zxcvbn'; import { @@ -162,12 +164,43 @@ 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 finalParams = { ...params }; + + // Inject browser locale if not already provided + if (!finalParams.locale) { + const browserLocale = getBrowserLocale(); + if (browserLocale) { + finalParams.locale = browserLocale; + } + } + + // Determine captcha requirement based on params + const requiresCaptcha = this.shouldRequireCaptcha(params); + + if (requiresCaptcha) { + const captchaChallenge = new CaptchaChallenge(SignIn.clerk); + const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' }); + if (!captchaParams) { + throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); + } + finalParams = { ...finalParams, ...captchaParams }; + } + + if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) { + const strategy = SignIn.clerk.client?.signUp.verifications.externalAccount.strategy; + if (strategy) { + // When transfer is true, we're in the OAuth/Enterprise SSO transfer case + type TransferParams = Extract; + (finalParams as TransferParams).strategy = strategy as TransferParams['strategy']; + } + } + return this._basePost({ path: this.pathRoot, - body: locale ? { locale, ...params } : params, + body: finalParams, }); }; @@ -574,6 +607,59 @@ export class SignIn extends BaseResource implements SignInResource { return this; } + /** + * We delegate bot detection to the following providers, instead of relying on turnstile exclusively + * + * This is almost identical to SignUp.shouldBypassCaptchaForAttempt, but they differ because on transfer + * sign up needs to check the sign in, and sign in needs to check the sign up. + */ + protected shouldBypassCaptchaForAttempt(params: SignInCreateParams) { + if (!('strategy' in params) || !params.strategy) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const captchaOauthBypass = SignIn.clerk.__internal_environment!.displayConfig.captchaOauthBypass; + + if (captchaOauthBypass.some(strategy => strategy === params.strategy)) { + return true; + } + + if ( + params.transfer && + captchaOauthBypass.some( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + strategy => strategy === SignIn.clerk.client!.signUp.verifications.externalAccount.strategy, + ) + ) { + return true; + } + + return false; + } + + /** + * Determines whether captcha is required based on the provided params. + */ + private shouldRequireCaptcha(params: SignInCreateParams): boolean { + // Always bypass for these conditions + if (__BUILD_DISABLE_RHC__) { + return false; + } + + if (SignIn.clerk.client?.captchaBypass) { + return false; + } + + // Strategy-based bypass (OAuth, etc.) + if (this.shouldBypassCaptchaForAttempt(params)) { + return false; + } + + // Require captcha if sign_up_if_missing is present + return !!params.sign_up_if_missing; + } + public __internal_toSnapshot(): SignInJSONSnapshot { return { object: 'sign_in', @@ -757,11 +843,42 @@ class SignInFuture implements SignInFutureResource { }); } + private async getCaptchaToken(): Promise<{ + captcha_token?: string; + captcha_widget_type?: CaptchaWidgetType; + captcha_error?: unknown; + }> { + 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 { captcha_token: captchaToken, captcha_widget_type: captchaWidgetType, captcha_error: captchaError }; + } + private async _create(params: SignInFutureCreateParams): Promise { const locale = getBrowserLocale(); + let body: Record = { ...params }; + if (locale) { + body.locale = locale; + } + + // Determine captcha requirement based on params + const requiresCaptcha = this.#resource['shouldRequireCaptcha']( + body, + 'strategy' in params ? params.strategy : undefined, + ); + + if (requiresCaptcha) { + const captchaParams = await this.getCaptchaToken(); + body = { ...body, ...captchaParams }; + } + await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, - body: locale ? { locale, ...params } : params, + body, }); } 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 2fcdd4b0d10..f1caf53cdef 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,12 @@ describe('SignIn', () => { BaseResource._fetch = mockFetch; const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + } as any; + await signIn.create({ identifier: 'user@example.com' }); expect(mockFetch).toHaveBeenCalledWith( @@ -64,6 +80,12 @@ describe('SignIn', () => { BaseResource._fetch = mockFetch; const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + } as any; + await signIn.create({ identifier: 'user@example.com' }); expect(mockFetch).toHaveBeenCalledWith( @@ -76,6 +98,98 @@ describe('SignIn', () => { }), ); }); + + it('includes captcha params when sign_up_if_missing 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', sign_up_if_missing: true }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins', + body: expect.objectContaining({ + identifier: 'user@example.com', + sign_up_if_missing: true, + captchaToken: 'mock_captcha_token', + captchaWidgetType: 'invisible', + }), + }), + ); + }); + + it('excludes captcha params when sign_up_if_missing 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', sign_up_if_missing: false }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins', + body: expect.not.objectContaining({ + captchaToken: expect.anything(), + captchaWidgetType: expect.anything(), + }), + }), + ); + }); }); describe('SignInFuture', () => { @@ -1109,6 +1223,11 @@ describe('SignIn', () => { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_isWebAuthnAutofillSupported: mockIsWebAuthnAutofillSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1163,6 +1282,11 @@ describe('SignIn', () => { SignIn.clerk = { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1236,6 +1360,11 @@ describe('SignIn', () => { SignIn.clerk = { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn().mockResolvedValue({ @@ -1323,6 +1452,14 @@ describe('SignIn', () => { }); it('authenticates with metamask strategy', async () => { + SignIn.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + const mockFetch = vi .fn() .mockResolvedValueOnce({ @@ -1659,6 +1796,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 @@ -1707,6 +1849,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({ @@ -1755,6 +1902,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(); @@ -1830,6 +1982,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/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..e86b5db2bfd 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -162,8 +162,18 @@ export type SignInCreateParams = ( | { identifier: string; } - | { transfer?: boolean } -) & { transfer?: boolean }; + | { + transfer: true; + strategy?: OAuthStrategy | EnterpriseSSOStrategy; + } +) & { + transfer?: boolean; + locale?: string; + sign_up_if_missing?: boolean; + captcha_token?: string; + captcha_error?: unknown; + captcha_widget_type?: string | null; +}; export type ResetPasswordParams = { password: string; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 12e21236970..0253bfcf4f3 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -37,6 +37,29 @@ 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-in will create a new account automatically. + * Captcha will be required when this is enabled. + */ + sign_up_if_missing?: boolean; + /** + * The captcha token returned from the captcha challenge. + * + * @internal + */ + captcha_token?: string; + /** + * The captcha error if the captcha challenge failed. + * + * @internal + */ + captcha_error?: unknown; + /** + * The type of captcha widget used ('smart', 'invisible', or null). + * + * @internal + */ + captcha_widget_type?: string | null; } export type SignInFuturePasswordParams = { From 5c17f4b8ec6b6e1af802957372918a9aacd3cb83 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Feb 2026 16:08:57 -0500 Subject: [PATCH 2/4] fix: Synchronize SignIn and SignInFuture, add tests --- .../clerk-js/src/core/resources/SignIn.ts | 62 ++++----- .../core/resources/__tests__/SignIn.test.ts | 130 ++++++++++++++++++ packages/shared/src/types/signInFuture.ts | 5 + 3 files changed, 163 insertions(+), 34 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index f7d40dcd484..f1ccc93bd7f 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -169,23 +169,17 @@ export class SignIn extends BaseResource implements SignInResource { let finalParams = { ...params }; - // Inject browser locale if not already provided - if (!finalParams.locale) { - const browserLocale = getBrowserLocale(); - if (browserLocale) { - finalParams.locale = browserLocale; - } + // Inject browser locale + const browserLocale = getBrowserLocale(); + if (browserLocale) { + finalParams.locale = browserLocale; } // Determine captcha requirement based on params const requiresCaptcha = this.shouldRequireCaptcha(params); if (requiresCaptcha) { - const captchaChallenge = new CaptchaChallenge(SignIn.clerk); - const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' }); - if (!captchaParams) { - throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); - } + const captchaParams = await this.getCaptchaToken(); finalParams = { ...finalParams, ...captchaParams }; } @@ -613,7 +607,7 @@ export class SignIn extends BaseResource implements SignInResource { * This is almost identical to SignUp.shouldBypassCaptchaForAttempt, but they differ because on transfer * sign up needs to check the sign in, and sign in needs to check the sign up. */ - protected shouldBypassCaptchaForAttempt(params: SignInCreateParams) { + protected shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) { if (!('strategy' in params) || !params.strategy) { return false; } @@ -641,7 +635,11 @@ export class SignIn extends BaseResource implements SignInResource { /** * Determines whether captcha is required based on the provided params. */ - private shouldRequireCaptcha(params: SignInCreateParams): boolean { + private shouldRequireCaptcha(params: { + strategy?: string; + transfer?: boolean; + sign_up_if_missing?: boolean; + }): boolean { // Always bypass for these conditions if (__BUILD_DISABLE_RHC__) { return false; @@ -660,6 +658,19 @@ export class SignIn extends BaseResource implements SignInResource { return !!params.sign_up_if_missing; } + /** + * Gets captcha token and widget type from the captcha challenge. + * Throws if captcha is unavailable. + */ + private async getCaptchaToken() { + const captchaChallenge = new CaptchaChallenge(SignIn.clerk); + const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' }); + if (!captchaParams) { + throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); + } + return captchaParams; + } + public __internal_toSnapshot(): SignInJSONSnapshot { return { object: 'sign_in', @@ -843,36 +854,19 @@ class SignInFuture implements SignInFutureResource { }); } - private async getCaptchaToken(): Promise<{ - captcha_token?: string; - captcha_widget_type?: CaptchaWidgetType; - captcha_error?: unknown; - }> { - 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 { captcha_token: captchaToken, captcha_widget_type: captchaWidgetType, captcha_error: captchaError }; - } - private async _create(params: SignInFutureCreateParams): Promise { + let body = { ...params }; + const locale = getBrowserLocale(); - let body: Record = { ...params }; if (locale) { body.locale = locale; } // Determine captcha requirement based on params - const requiresCaptcha = this.#resource['shouldRequireCaptcha']( - body, - 'strategy' in params ? params.strategy : undefined, - ); + const requiresCaptcha = this.#resource['shouldRequireCaptcha'](body); if (requiresCaptcha) { - const captchaParams = await this.getCaptchaToken(); + const captchaParams = await this.#resource['getCaptchaToken'](); body = { ...body, ...captchaParams }; } 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 f1caf53cdef..b6edf6b9f1f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -360,6 +360,136 @@ describe('SignIn', () => { expect(result).toHaveProperty('error', mockError); }); + + it('includes captcha params when sign_up_if_missing 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', sign_up_if_missing: true }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ins', + body: { + identifier: 'user@example.com', + sign_up_if_missing: true, + captchaToken: 'mock_captcha_token', + captchaWidgetType: 'invisible', + }, + }); + }); + + it('excludes captcha params when sign_up_if_missing 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', sign_up_if_missing: false }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ins', + body: { + identifier: 'user@example.com', + sign_up_if_missing: false, + }, + }); + }); + + it('excludes captcha params when sign_up_if_missing 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', () => { diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 0253bfcf4f3..f3cc8264e38 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; + /** + * The locale to assign to the user in [BCP 47](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) + * format (e.g., "en-US", "fr-FR"). Set from the browser's locale. + */ + locale?: string; /** * When set to `true`, if a user does not exist, the sign-in will create a new account automatically. * Captcha will be required when this is enabled. From 4998b6a11bf2b8798e89bc73b5e0f5ceb119a095 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Feb 2026 16:12:22 -0500 Subject: [PATCH 3/4] chore: Remove unused import --- packages/clerk-js/src/core/resources/SignIn.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index f1ccc93bd7f..e59d88d9a6d 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -16,7 +16,6 @@ import type { AuthenticateWithPopupParams, AuthenticateWithRedirectParams, AuthenticateWithWeb3Params, - CaptchaWidgetType, ClientTrustState, CreateEmailLinkFlowReturn, EmailCodeConfig, From 968af9e189b35fdb8786ea3b11f6a68e024db534 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Feb 2026 16:24:43 -0500 Subject: [PATCH 4/4] chore: Bump bundlewatch size --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 3bd20ec6fee..d716dd2b5f3 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "306KB" }, { "path": "./dist/clerk.native.js", "maxSize": "65KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" },