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/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" }, diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 998fb276c03..e59d88d9a6d 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -80,6 +80,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 +163,37 @@ 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 + const browserLocale = getBrowserLocale(); + if (browserLocale) { + finalParams.locale = browserLocale; + } + + // Determine captcha requirement based on params + const requiresCaptcha = this.shouldRequireCaptcha(params); + + if (requiresCaptcha) { + const captchaParams = await this.getCaptchaToken(); + 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 +600,76 @@ 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: { strategy?: string; transfer?: boolean }) { + 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: { + strategy?: string; + transfer?: boolean; + sign_up_if_missing?: boolean; + }): 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; + } + + /** + * 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', @@ -758,10 +854,24 @@ class SignInFuture implements SignInFutureResource { } private async _create(params: SignInFutureCreateParams): Promise { + let body = { ...params }; + const locale = getBrowserLocale(); + if (locale) { + body.locale = locale; + } + + // Determine captcha requirement based on params + const requiresCaptcha = this.#resource['shouldRequireCaptcha'](body); + + if (requiresCaptcha) { + const captchaParams = await this.#resource['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..b6edf6b9f1f 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', () => { @@ -246,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', () => { @@ -1109,6 +1353,11 @@ describe('SignIn', () => { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_isWebAuthnAutofillSupported: mockIsWebAuthnAutofillSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1163,6 +1412,11 @@ describe('SignIn', () => { SignIn.clerk = { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1236,6 +1490,11 @@ describe('SignIn', () => { SignIn.clerk = { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn().mockResolvedValue({ @@ -1323,6 +1582,14 @@ describe('SignIn', () => { }); it('authenticates with metamask strategy', async () => { + SignIn.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + const mockFetch = vi .fn() .mockResolvedValueOnce({ @@ -1659,6 +1926,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 +1979,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 +2032,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 +2112,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..f3cc8264e38 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -37,6 +37,34 @@ 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. + */ + 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 = {