diff --git a/.changeset/healthy-bees-turn.md b/.changeset/healthy-bees-turn.md new file mode 100644 index 00000000000..33602e38698 --- /dev/null +++ b/.changeset/healthy-bees-turn.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Skip fraud protection if client has bypass enabled diff --git a/packages/clerk-js/src/core/auth/CaptchaHeartbeat.ts b/packages/clerk-js/src/core/auth/CaptchaHeartbeat.ts index 173019607e6..5c87be87cc5 100644 --- a/packages/clerk-js/src/core/auth/CaptchaHeartbeat.ts +++ b/packages/clerk-js/src/core/auth/CaptchaHeartbeat.ts @@ -22,7 +22,7 @@ export class CaptchaHeartbeat { } private async challengeAndSend() { - if (!this.clerk.client) { + if (!this.clerk.client || this.clientBypass()) { return; } @@ -38,6 +38,10 @@ export class CaptchaHeartbeat { return !!this.clerk.__unstable__environment?.displayConfig.captchaHeartbeat; } + private clientBypass() { + return this.clerk.client?.captchaBypass; + } + private intervalInMs() { return this.clerk.__unstable__environment?.displayConfig.captchaHeartbeatIntervalMs ?? 10 * 60 * 1000; } diff --git a/packages/clerk-js/src/core/fraudProtection.test.ts b/packages/clerk-js/src/core/fraudProtection.test.ts index 465c01b1f57..61edb176548 100644 --- a/packages/clerk-js/src/core/fraudProtection.test.ts +++ b/packages/clerk-js/src/core/fraudProtection.test.ts @@ -7,11 +7,11 @@ describe('FraudProtectionService', () => { let mockClerk: Clerk; let mockClient: typeof Client; let solveCaptcha: any; - let mockManaged: jest.Mock; + let mockManagedInModal: jest.Mock; function MockCaptchaChallenge() { // @ts-ignore - we don't need to implement the entire class - this.managed = mockManaged; + this.managedInModal = mockManagedInModal; } const createCaptchaError = () => { @@ -22,7 +22,7 @@ describe('FraudProtectionService', () => { }; beforeEach(() => { - mockManaged = jest.fn().mockResolvedValue( + mockManagedInModal = jest.fn().mockResolvedValue( new Promise(r => { solveCaptcha = r; }), @@ -54,7 +54,7 @@ describe('FraudProtectionService', () => { await fn1res; // only one will need to call the captcha as the other will be blocked - expect(mockManaged).toHaveBeenCalledTimes(0); + expect(mockManagedInModal).toHaveBeenCalledTimes(0); expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(0); expect(fn1).toHaveBeenCalledTimes(1); }); @@ -67,7 +67,7 @@ describe('FraudProtectionService', () => { const fn1 = jest.fn().mockRejectedValueOnce(unrelatedError); const fn1res = sut.execute(mockClerk, fn1); expect(fn1res).rejects.toEqual(unrelatedError); - expect(mockManaged).toHaveBeenCalledTimes(0); + expect(mockManagedInModal).toHaveBeenCalledTimes(0); expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(0); expect(fn1).toHaveBeenCalledTimes(1); }); @@ -87,7 +87,7 @@ describe('FraudProtectionService', () => { await Promise.all([fn1res, fn2res]); // only one will need to call the captcha as the other will be blocked - expect(mockManaged).toHaveBeenCalledTimes(1); + expect(mockManagedInModal).toHaveBeenCalledTimes(1); expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1); expect(fn1).toHaveBeenCalledTimes(2); }); @@ -107,7 +107,7 @@ describe('FraudProtectionService', () => { await Promise.all([fn1res, fn2res]); // captcha will only be called once - expect(mockManaged).toHaveBeenCalledTimes(1); + expect(mockManagedInModal).toHaveBeenCalledTimes(1); expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1); // but all failed requests will be retried expect(fn1).toHaveBeenCalledTimes(2); @@ -134,7 +134,7 @@ describe('FraudProtectionService', () => { solveCaptcha(); await Promise.all([fn1res, fn2res]); - expect(mockManaged).toHaveBeenCalledTimes(1); + expect(mockManagedInModal).toHaveBeenCalledTimes(1); expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1); expect(fn1).toHaveBeenCalledTimes(2); expect(fn2).toHaveBeenCalledTimes(1); @@ -167,7 +167,7 @@ describe('FraudProtectionService', () => { // but the other requests will be unblocked and retried await Promise.all([fn2res, fn3res]); - expect(mockManaged).toHaveBeenCalledTimes(1); + expect(mockManagedInModal).toHaveBeenCalledTimes(1); expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1); expect(fn1).toHaveBeenCalledTimes(2); diff --git a/packages/clerk-js/src/core/fraudProtection.ts b/packages/clerk-js/src/core/fraudProtection.ts index 5a15c0b3981..81055e29cc7 100644 --- a/packages/clerk-js/src/core/fraudProtection.ts +++ b/packages/clerk-js/src/core/fraudProtection.ts @@ -62,6 +62,6 @@ export class FraudProtection { } public managedChallenge(clerk: Clerk) { - return new this.CaptchaChallengeImpl(clerk).managed(); + return new this.CaptchaChallengeImpl(clerk).managedInModal(); } } diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 9fbcfd83835..b7558c49a07 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -20,6 +20,7 @@ export class Client extends BaseResource implements ClientResource { signUp: SignUpResource = new SignUp(); signIn: SignInResource = new SignIn(); lastActiveSessionId: string | null = null; + captchaBypass = false; cookieExpiresAt: Date | null = null; createdAt: Date | null = null; updatedAt: Date | null = null; @@ -116,6 +117,7 @@ export class Client extends BaseResource implements ClientResource { this.signUp = new SignUp(data.sign_up); this.signIn = new SignIn(data.sign_in); this.lastActiveSessionId = data.last_active_session_id; + this.captchaBypass = data.captcha_bypass || false; this.cookieExpiresAt = data.cookie_expires_at ? unixEpochToDate(data.cookie_expires_at) : null; this.createdAt = unixEpochToDate(data.created_at || undefined); this.updatedAt = unixEpochToDate(data.updated_at || undefined); @@ -133,6 +135,7 @@ export class Client extends BaseResource implements ClientResource { sign_up: this.signUp.__internal_toSnapshot(), sign_in: this.signIn.__internal_toSnapshot(), last_active_session_id: this.lastActiveSessionId, + captcha_bypass: this.captchaBypass, cookie_expires_at: this.cookieExpiresAt ? this.cookieExpiresAt.getTime() : null, created_at: this.createdAt?.getTime() ?? null, updated_at: this.updatedAt?.getTime() ?? null, diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 4a6785a3b09..37ee628a4f4 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -34,7 +34,7 @@ import { getOKXWalletIdentifier, windowNavigate, } from '../../utils'; -import { getCaptchaToken, retrieveCaptchaInfo } from '../../utils/captcha'; +import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { createValidatePassword } from '../../utils/passwords/password'; import { normalizeUnsafeMetadata } from '../../utils/resourceParams'; import { @@ -81,54 +81,25 @@ export class SignUp extends BaseResource implements SignUpResource { this.fromJSON(data); } - create = async (params: SignUpCreateParams): Promise => { - const paramsWithCaptcha: Record = params; - - if (!__BUILD_DISABLE_RHC__) { - const { - captchaSiteKey, - canUseCaptcha, - captchaURL, - captchaWidgetType, - captchaProvider, - captchaPublicKeyInvisible, - } = retrieveCaptchaInfo(SignUp.clerk); - - if ( - !this.shouldBypassCaptchaForAttempt(params) && - canUseCaptcha && - captchaSiteKey && - captchaURL && - captchaPublicKeyInvisible - ) { - try { - const captchaParams = await getCaptchaToken({ - siteKey: captchaSiteKey, - widgetType: captchaWidgetType, - invisibleSiteKey: captchaPublicKeyInvisible, - scriptUrl: captchaURL, - captchaProvider, - }); - - paramsWithCaptcha.captchaToken = captchaParams.captchaToken; - paramsWithCaptcha.captchaWidgetType = captchaParams.captchaWidgetType; - } catch (e) { - if (e.captchaError) { - paramsWithCaptcha.captchaError = e.captchaError; - } else { - throw new ClerkRuntimeError(e.message, { code: 'captcha_unavailable' }); - } - } + create = async (_params: SignUpCreateParams): Promise => { + let params: Record = _params; + + if (!__BUILD_DISABLE_RHC__ && !this.clientBypass() && !this.shouldBypassCaptchaForAttempt(params)) { + const captchaChallenge = new CaptchaChallenge(SignUp.clerk); + const captchaParams = await captchaChallenge.managedOrInvisible(); + if (!captchaParams) { + throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); } + params = { ...params, ...captchaParams }; } if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) { - paramsWithCaptcha.strategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy; + params.strategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy; } return this._basePost({ path: this.pathRoot, - body: normalizeUnsafeMetadata(paramsWithCaptcha), + body: normalizeUnsafeMetadata(params), }); }; @@ -429,6 +400,10 @@ export class SignUp extends BaseResource implements SignUpResource { }; } + private clientBypass() { + return SignUp.clerk.client?.captchaBypass; + } + /** * We delegate bot detection to the following providers, instead of relying on turnstile exclusively */ diff --git a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap index 09e6c4b8d38..93027d7f486 100644 --- a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap @@ -1,7 +1,213 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Client Singleton __internal_toSnapshot() 1`] = ` +{ + "captcha_bypass": false, + "cookie_expires_at": null, + "created_at": 1733924546843, + "id": "client_DUMMY_ID", + "last_active_session_id": null, + "object": "client", + "sessions": [], + "sign_in": { + "created_session_id": null, + "first_factor_verification": { + "attempts": null, + "error": { + "code": "", + "long_message": undefined, + "message": "", + "meta": { + "email_addresses": undefined, + "identifiers": undefined, + "param_name": undefined, + "session_id": undefined, + "zxcvbn": undefined, + }, + }, + "expire_at": 1733924546849, + "external_verification_redirect_url": null, + "id": "", + "message": null, + "nonce": null, + "object": "verification", + "status": null, + "strategy": null, + "verified_at_client": null, + }, + "id": "", + "identifier": null, + "object": "sign_in", + "second_factor_verification": { + "attempts": null, + "error": { + "code": "", + "long_message": undefined, + "message": "", + "meta": { + "email_addresses": undefined, + "identifiers": undefined, + "param_name": undefined, + "session_id": undefined, + "zxcvbn": undefined, + }, + }, + "expire_at": 1733924546849, + "external_verification_redirect_url": null, + "id": "", + "message": null, + "nonce": null, + "object": "verification", + "status": null, + "strategy": null, + "verified_at_client": null, + }, + "status": null, + "supported_first_factors": [], + "supported_identifiers": [], + "supported_second_factors": null, + "user_data": { + "first_name": undefined, + "has_image": null, + "image_url": null, + "last_name": undefined, + }, + }, + "sign_up": { + "abandon_at": null, + "created_session_id": null, + "created_user_id": null, + "email_address": null, + "external_account": undefined, + "external_account_strategy": undefined, + "first_name": null, + "has_password": false, + "id": "", + "last_name": null, + "legal_accepted_at": null, + "missing_fields": [], + "object": "sign_up", + "optional_fields": [], + "phone_number": null, + "required_fields": [], + "status": null, + "unsafe_metadata": {}, + "unverified_fields": [], + "username": null, + "verifications": { + "email_address": { + "attempts": null, + "error": { + "code": "", + "long_message": undefined, + "message": "", + "meta": { + "email_addresses": undefined, + "identifiers": undefined, + "param_name": undefined, + "session_id": undefined, + "zxcvbn": undefined, + }, + }, + "expire_at": 1733924546849, + "external_verification_redirect_url": null, + "id": "", + "message": null, + "next_action": "", + "nonce": null, + "object": "verification", + "status": null, + "strategy": null, + "supported_strategies": [], + "verified_at_client": null, + }, + "external_account": { + "attempts": null, + "error": { + "code": "", + "long_message": undefined, + "message": "", + "meta": { + "email_addresses": undefined, + "identifiers": undefined, + "param_name": undefined, + "session_id": undefined, + "zxcvbn": undefined, + }, + }, + "expire_at": 1733924546849, + "external_verification_redirect_url": null, + "id": "", + "message": null, + "nonce": null, + "object": "verification", + "status": null, + "strategy": null, + "verified_at_client": null, + }, + "phone_number": { + "attempts": null, + "error": { + "code": "", + "long_message": undefined, + "message": "", + "meta": { + "email_addresses": undefined, + "identifiers": undefined, + "param_name": undefined, + "session_id": undefined, + "zxcvbn": undefined, + }, + }, + "expire_at": 1733924546849, + "external_verification_redirect_url": null, + "id": "", + "message": null, + "next_action": "", + "nonce": null, + "object": "verification", + "status": null, + "strategy": null, + "supported_strategies": [], + "verified_at_client": null, + }, + "web3_wallet": { + "attempts": null, + "error": { + "code": "", + "long_message": undefined, + "message": "", + "meta": { + "email_addresses": undefined, + "identifiers": undefined, + "param_name": undefined, + "session_id": undefined, + "zxcvbn": undefined, + }, + }, + "expire_at": 1733924546849, + "external_verification_redirect_url": null, + "id": "", + "message": null, + "next_action": "", + "nonce": null, + "object": "verification", + "status": null, + "strategy": null, + "supported_strategies": [], + "verified_at_client": null, + }, + }, + "web3_wallet": null, + }, + "status": null, + "updated_at": 1733924546843, +} +`; + exports[`Client Singleton has the same initial properties 1`] = ` Client { + "captchaBypass": false, "cookieExpiresAt": null, "createdAt": 2024-12-11T13:42:26.843Z, "id": "client_DUMMY_ID", @@ -231,207 +437,3 @@ Client { "updatedAt": 2024-12-11T13:42:26.843Z, } `; - -exports[`Client Singleton __internal_toSnapshot() 1`] = ` -{ - "cookie_expires_at": null, - "created_at": 1733924546843, - "id": "client_DUMMY_ID", - "last_active_session_id": null, - "object": "client", - "sessions": [], - "sign_in": { - "created_session_id": null, - "first_factor_verification": { - "attempts": null, - "error": { - "code": "", - "long_message": undefined, - "message": "", - "meta": { - "email_addresses": undefined, - "identifiers": undefined, - "param_name": undefined, - "session_id": undefined, - "zxcvbn": undefined, - }, - }, - "expire_at": 1733924546849, - "external_verification_redirect_url": null, - "id": "", - "message": null, - "nonce": null, - "object": "verification", - "status": null, - "strategy": null, - "verified_at_client": null, - }, - "id": "", - "identifier": null, - "object": "sign_in", - "second_factor_verification": { - "attempts": null, - "error": { - "code": "", - "long_message": undefined, - "message": "", - "meta": { - "email_addresses": undefined, - "identifiers": undefined, - "param_name": undefined, - "session_id": undefined, - "zxcvbn": undefined, - }, - }, - "expire_at": 1733924546849, - "external_verification_redirect_url": null, - "id": "", - "message": null, - "nonce": null, - "object": "verification", - "status": null, - "strategy": null, - "verified_at_client": null, - }, - "status": null, - "supported_first_factors": [], - "supported_identifiers": [], - "supported_second_factors": null, - "user_data": { - "first_name": undefined, - "has_image": null, - "image_url": null, - "last_name": undefined, - }, - }, - "sign_up": { - "abandon_at": null, - "created_session_id": null, - "created_user_id": null, - "email_address": null, - "external_account": undefined, - "external_account_strategy": undefined, - "first_name": null, - "has_password": false, - "id": "", - "last_name": null, - "legal_accepted_at": null, - "missing_fields": [], - "object": "sign_up", - "optional_fields": [], - "phone_number": null, - "required_fields": [], - "status": null, - "unsafe_metadata": {}, - "unverified_fields": [], - "username": null, - "verifications": { - "email_address": { - "attempts": null, - "error": { - "code": "", - "long_message": undefined, - "message": "", - "meta": { - "email_addresses": undefined, - "identifiers": undefined, - "param_name": undefined, - "session_id": undefined, - "zxcvbn": undefined, - }, - }, - "expire_at": 1733924546849, - "external_verification_redirect_url": null, - "id": "", - "message": null, - "next_action": "", - "nonce": null, - "object": "verification", - "status": null, - "strategy": null, - "supported_strategies": [], - "verified_at_client": null, - }, - "external_account": { - "attempts": null, - "error": { - "code": "", - "long_message": undefined, - "message": "", - "meta": { - "email_addresses": undefined, - "identifiers": undefined, - "param_name": undefined, - "session_id": undefined, - "zxcvbn": undefined, - }, - }, - "expire_at": 1733924546849, - "external_verification_redirect_url": null, - "id": "", - "message": null, - "nonce": null, - "object": "verification", - "status": null, - "strategy": null, - "verified_at_client": null, - }, - "phone_number": { - "attempts": null, - "error": { - "code": "", - "long_message": undefined, - "message": "", - "meta": { - "email_addresses": undefined, - "identifiers": undefined, - "param_name": undefined, - "session_id": undefined, - "zxcvbn": undefined, - }, - }, - "expire_at": 1733924546849, - "external_verification_redirect_url": null, - "id": "", - "message": null, - "next_action": "", - "nonce": null, - "object": "verification", - "status": null, - "strategy": null, - "supported_strategies": [], - "verified_at_client": null, - }, - "web3_wallet": { - "attempts": null, - "error": { - "code": "", - "long_message": undefined, - "message": "", - "meta": { - "email_addresses": undefined, - "identifiers": undefined, - "param_name": undefined, - "session_id": undefined, - "zxcvbn": undefined, - }, - }, - "expire_at": 1733924546849, - "external_verification_redirect_url": null, - "id": "", - "message": null, - "next_action": "", - "nonce": null, - "object": "verification", - "status": null, - "strategy": null, - "supported_strategies": [], - "verified_at_client": null, - }, - }, - "web3_wallet": null, - }, - "status": null, - "updated_at": 1733924546843, -} -`; diff --git a/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts b/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts index 7e3d4dbf267..918a3bd4b8b 100644 --- a/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts +++ b/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts @@ -1,6 +1,7 @@ import type { Clerk } from '../../core/resources/internal'; import { getCaptchaToken } from './getCaptchaToken'; import { retrieveCaptchaInfo } from './retrieveCaptchaInfo'; +import type { CaptchaOptions } from './types'; export class CaptchaChallenge { public constructor(private clerk: Clerk) {} @@ -33,13 +34,13 @@ export class CaptchaChallenge { /** * Triggers a smart challenge if the user is required to solve a CAPTCHA. - * Depending on the environment settings, this will either trigger an - * invisible or smart (managed) CAPTCHA challenge. + * The type of the challenge depends on the dashboard configuration. + * By default, smart (managed) captcha is preferred. If the customer has selected invisible, this method + * will fall back to using the invisible captcha instead. + * * Managed challenged start as non-interactive and escalate to interactive if necessary. - * Important: For this to work at the moment, the instance needs to be using SMART protection - * as we need both keys (visible and invisible) to be present. */ - public async managed() { + public async managedOrInvisible(opts?: Partial) { const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } = retrieveCaptchaInfo(this.clerk); @@ -50,10 +51,7 @@ export class CaptchaChallenge { invisibleSiteKey: captchaPublicKeyInvisible, scriptUrl: captchaURL, captchaProvider, - modalWrapperQuerySelector: '#cl-modal-captcha-wrapper', - modalContainerQuerySelector: '#cl-modal-captcha-container', - openModal: () => this.clerk.__internal_openBlankCaptchaModal(), - closeModal: () => this.clerk.__internal_closeBlankCaptchaModal(), + ...opts, }).catch(e => { if (e.captchaError) { return { captchaError: e.captchaError }; @@ -64,4 +62,17 @@ export class CaptchaChallenge { return {}; } + + /** + * Similar to managed() but will render the CAPTCHA challenge in a modal + * managed by clerk-js itself. + */ + public async managedInModal() { + return this.managedOrInvisible({ + modalWrapperQuerySelector: '#cl-modal-captcha-wrapper', + modalContainerQuerySelector: '#cl-modal-captcha-container', + openModal: () => this.clerk.__internal_openBlankCaptchaModal(), + closeModal: () => this.clerk.__internal_closeBlankCaptchaModal(), + }); + } } diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 2169e312c9e..d9b235bade1 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -18,6 +18,7 @@ export interface ClientResource extends ClerkResource { isEligibleForTouch: () => boolean; buildTouchUrl: (params: { redirectUrl: URL }) => string; lastActiveSessionId: string | null; + captchaBypass: boolean; cookieExpiresAt: Date | null; createdAt: Date | null; updatedAt: Date | null; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 91a4aefb871..145906af9a4 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -70,6 +70,7 @@ export interface ClientJSON extends ClerkResourceJSON { sessions: SessionJSON[]; sign_up: SignUpJSON | null; sign_in: SignInJSON | null; + captcha_bypass?: boolean; last_active_session_id: string | null; cookie_expires_at: number | null; created_at: number;