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 c694488eb56..18b7da9307e 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -3,7 +3,7 @@ { "path": "./dist/clerk.js", "maxSize": "539KB" }, { "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.legacy.browser.js", "maxSize": "107KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" }, { "path": "./dist/clerk.native.js", "maxSize": "65KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 8535b013cca..2e40da3c189 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,85 @@ 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 body: Record = { ...params }; + const locale = getBrowserLocale(); + if (locale) { + body.locale = locale; + } + + const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params); + + if (captchaToken !== undefined) { + body.captchaToken = captchaToken; + body.captchaWidgetType = captchaWidgetType; + body.captchaError = captchaError; + } + 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 25b50a55683..e1b0e60f50e 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 9723f8b751e..917bb1549df 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', () => { @@ -161,6 +198,14 @@ describe('SignUp', () => { }); it('creates signup with phoneNumber when no existing signup', async () => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + const mockFetch = vi .fn() .mockResolvedValueOnce({ @@ -590,7 +635,13 @@ describe('SignUp', () => { describe('web3', () => { beforeEach(() => { - SignUp.clerk = {} as any; + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; }); afterEach(() => { @@ -846,6 +897,16 @@ describe('SignUp', () => { }); describe('password', () => { + beforeEach(() => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + }); + afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); @@ -911,6 +972,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 cff096bc3f1..4b5bdd1a4a7 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 = {