Skip to content

Commit aa48b1f

Browse files
authoredJan 16, 2025··
fix(clerk-js,types): Skip fraud protection if client has bypass enabled (#4907)
1 parent 7c88d6b commit aa48b1f

File tree

10 files changed

+268
-265
lines changed

10 files changed

+268
-265
lines changed
 

‎.changeset/healthy-bees-turn.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/types': patch
4+
---
5+
6+
Skip fraud protection if client has bypass enabled

‎packages/clerk-js/src/core/auth/CaptchaHeartbeat.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class CaptchaHeartbeat {
2222
}
2323

2424
private async challengeAndSend() {
25-
if (!this.clerk.client) {
25+
if (!this.clerk.client || this.clientBypass()) {
2626
return;
2727
}
2828

@@ -38,6 +38,10 @@ export class CaptchaHeartbeat {
3838
return !!this.clerk.__unstable__environment?.displayConfig.captchaHeartbeat;
3939
}
4040

41+
private clientBypass() {
42+
return this.clerk.client?.captchaBypass;
43+
}
44+
4145
private intervalInMs() {
4246
return this.clerk.__unstable__environment?.displayConfig.captchaHeartbeatIntervalMs ?? 10 * 60 * 1000;
4347
}

‎packages/clerk-js/src/core/fraudProtection.test.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ describe('FraudProtectionService', () => {
77
let mockClerk: Clerk;
88
let mockClient: typeof Client;
99
let solveCaptcha: any;
10-
let mockManaged: jest.Mock;
10+
let mockManagedInModal: jest.Mock;
1111

1212
function MockCaptchaChallenge() {
1313
// @ts-ignore - we don't need to implement the entire class
14-
this.managed = mockManaged;
14+
this.managedInModal = mockManagedInModal;
1515
}
1616

1717
const createCaptchaError = () => {
@@ -22,7 +22,7 @@ describe('FraudProtectionService', () => {
2222
};
2323

2424
beforeEach(() => {
25-
mockManaged = jest.fn().mockResolvedValue(
25+
mockManagedInModal = jest.fn().mockResolvedValue(
2626
new Promise(r => {
2727
solveCaptcha = r;
2828
}),
@@ -54,7 +54,7 @@ describe('FraudProtectionService', () => {
5454
await fn1res;
5555

5656
// only one will need to call the captcha as the other will be blocked
57-
expect(mockManaged).toHaveBeenCalledTimes(0);
57+
expect(mockManagedInModal).toHaveBeenCalledTimes(0);
5858
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
5959
expect(fn1).toHaveBeenCalledTimes(1);
6060
});
@@ -67,7 +67,7 @@ describe('FraudProtectionService', () => {
6767
const fn1 = jest.fn().mockRejectedValueOnce(unrelatedError);
6868
const fn1res = sut.execute(mockClerk, fn1);
6969
expect(fn1res).rejects.toEqual(unrelatedError);
70-
expect(mockManaged).toHaveBeenCalledTimes(0);
70+
expect(mockManagedInModal).toHaveBeenCalledTimes(0);
7171
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
7272
expect(fn1).toHaveBeenCalledTimes(1);
7373
});
@@ -87,7 +87,7 @@ describe('FraudProtectionService', () => {
8787
await Promise.all([fn1res, fn2res]);
8888

8989
// only one will need to call the captcha as the other will be blocked
90-
expect(mockManaged).toHaveBeenCalledTimes(1);
90+
expect(mockManagedInModal).toHaveBeenCalledTimes(1);
9191
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
9292
expect(fn1).toHaveBeenCalledTimes(2);
9393
});
@@ -107,7 +107,7 @@ describe('FraudProtectionService', () => {
107107
await Promise.all([fn1res, fn2res]);
108108

109109
// captcha will only be called once
110-
expect(mockManaged).toHaveBeenCalledTimes(1);
110+
expect(mockManagedInModal).toHaveBeenCalledTimes(1);
111111
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
112112
// but all failed requests will be retried
113113
expect(fn1).toHaveBeenCalledTimes(2);
@@ -134,7 +134,7 @@ describe('FraudProtectionService', () => {
134134
solveCaptcha();
135135
await Promise.all([fn1res, fn2res]);
136136

137-
expect(mockManaged).toHaveBeenCalledTimes(1);
137+
expect(mockManagedInModal).toHaveBeenCalledTimes(1);
138138
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
139139
expect(fn1).toHaveBeenCalledTimes(2);
140140
expect(fn2).toHaveBeenCalledTimes(1);
@@ -167,7 +167,7 @@ describe('FraudProtectionService', () => {
167167
// but the other requests will be unblocked and retried
168168
await Promise.all([fn2res, fn3res]);
169169

170-
expect(mockManaged).toHaveBeenCalledTimes(1);
170+
expect(mockManagedInModal).toHaveBeenCalledTimes(1);
171171
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
172172

173173
expect(fn1).toHaveBeenCalledTimes(2);

‎packages/clerk-js/src/core/fraudProtection.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,6 @@ export class FraudProtection {
6262
}
6363

6464
public managedChallenge(clerk: Clerk) {
65-
return new this.CaptchaChallengeImpl(clerk).managed();
65+
return new this.CaptchaChallengeImpl(clerk).managedInModal();
6666
}
6767
}

‎packages/clerk-js/src/core/resources/Client.ts

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export class Client extends BaseResource implements ClientResource {
2020
signUp: SignUpResource = new SignUp();
2121
signIn: SignInResource = new SignIn();
2222
lastActiveSessionId: string | null = null;
23+
captchaBypass = false;
2324
cookieExpiresAt: Date | null = null;
2425
createdAt: Date | null = null;
2526
updatedAt: Date | null = null;
@@ -116,6 +117,7 @@ export class Client extends BaseResource implements ClientResource {
116117
this.signUp = new SignUp(data.sign_up);
117118
this.signIn = new SignIn(data.sign_in);
118119
this.lastActiveSessionId = data.last_active_session_id;
120+
this.captchaBypass = data.captcha_bypass || false;
119121
this.cookieExpiresAt = data.cookie_expires_at ? unixEpochToDate(data.cookie_expires_at) : null;
120122
this.createdAt = unixEpochToDate(data.created_at || undefined);
121123
this.updatedAt = unixEpochToDate(data.updated_at || undefined);
@@ -133,6 +135,7 @@ export class Client extends BaseResource implements ClientResource {
133135
sign_up: this.signUp.__internal_toSnapshot(),
134136
sign_in: this.signIn.__internal_toSnapshot(),
135137
last_active_session_id: this.lastActiveSessionId,
138+
captcha_bypass: this.captchaBypass,
136139
cookie_expires_at: this.cookieExpiresAt ? this.cookieExpiresAt.getTime() : null,
137140
created_at: this.createdAt?.getTime() ?? null,
138141
updated_at: this.updatedAt?.getTime() ?? null,

‎packages/clerk-js/src/core/resources/SignUp.ts

+16-41
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
getOKXWalletIdentifier,
3535
windowNavigate,
3636
} from '../../utils';
37-
import { getCaptchaToken, retrieveCaptchaInfo } from '../../utils/captcha';
37+
import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge';
3838
import { createValidatePassword } from '../../utils/passwords/password';
3939
import { normalizeUnsafeMetadata } from '../../utils/resourceParams';
4040
import {
@@ -81,54 +81,25 @@ export class SignUp extends BaseResource implements SignUpResource {
8181
this.fromJSON(data);
8282
}
8383

84-
create = async (params: SignUpCreateParams): Promise<SignUpResource> => {
85-
const paramsWithCaptcha: Record<string, unknown> = params;
86-
87-
if (!__BUILD_DISABLE_RHC__) {
88-
const {
89-
captchaSiteKey,
90-
canUseCaptcha,
91-
captchaURL,
92-
captchaWidgetType,
93-
captchaProvider,
94-
captchaPublicKeyInvisible,
95-
} = retrieveCaptchaInfo(SignUp.clerk);
96-
97-
if (
98-
!this.shouldBypassCaptchaForAttempt(params) &&
99-
canUseCaptcha &&
100-
captchaSiteKey &&
101-
captchaURL &&
102-
captchaPublicKeyInvisible
103-
) {
104-
try {
105-
const captchaParams = await getCaptchaToken({
106-
siteKey: captchaSiteKey,
107-
widgetType: captchaWidgetType,
108-
invisibleSiteKey: captchaPublicKeyInvisible,
109-
scriptUrl: captchaURL,
110-
captchaProvider,
111-
});
112-
113-
paramsWithCaptcha.captchaToken = captchaParams.captchaToken;
114-
paramsWithCaptcha.captchaWidgetType = captchaParams.captchaWidgetType;
115-
} catch (e) {
116-
if (e.captchaError) {
117-
paramsWithCaptcha.captchaError = e.captchaError;
118-
} else {
119-
throw new ClerkRuntimeError(e.message, { code: 'captcha_unavailable' });
120-
}
121-
}
84+
create = async (_params: SignUpCreateParams): Promise<SignUpResource> => {
85+
let params: Record<string, unknown> = _params;
86+
87+
if (!__BUILD_DISABLE_RHC__ && !this.clientBypass() && !this.shouldBypassCaptchaForAttempt(params)) {
88+
const captchaChallenge = new CaptchaChallenge(SignUp.clerk);
89+
const captchaParams = await captchaChallenge.managedOrInvisible();
90+
if (!captchaParams) {
91+
throw new ClerkRuntimeError('', { code: 'captcha_unavailable' });
12292
}
93+
params = { ...params, ...captchaParams };
12394
}
12495

12596
if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) {
126-
paramsWithCaptcha.strategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy;
97+
params.strategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy;
12798
}
12899

129100
return this._basePost({
130101
path: this.pathRoot,
131-
body: normalizeUnsafeMetadata(paramsWithCaptcha),
102+
body: normalizeUnsafeMetadata(params),
132103
});
133104
};
134105

@@ -429,6 +400,10 @@ export class SignUp extends BaseResource implements SignUpResource {
429400
};
430401
}
431402

403+
private clientBypass() {
404+
return SignUp.clerk.client?.captchaBypass;
405+
}
406+
432407
/**
433408
* We delegate bot detection to the following providers, instead of relying on turnstile exclusively
434409
*/

‎packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap

+206-204
Large diffs are not rendered by default.

‎packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts

+20-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Clerk } from '../../core/resources/internal';
22
import { getCaptchaToken } from './getCaptchaToken';
33
import { retrieveCaptchaInfo } from './retrieveCaptchaInfo';
4+
import type { CaptchaOptions } from './types';
45

56
export class CaptchaChallenge {
67
public constructor(private clerk: Clerk) {}
@@ -33,13 +34,13 @@ export class CaptchaChallenge {
3334

3435
/**
3536
* Triggers a smart challenge if the user is required to solve a CAPTCHA.
36-
* Depending on the environment settings, this will either trigger an
37-
* invisible or smart (managed) CAPTCHA challenge.
37+
* The type of the challenge depends on the dashboard configuration.
38+
* By default, smart (managed) captcha is preferred. If the customer has selected invisible, this method
39+
* will fall back to using the invisible captcha instead.
40+
*
3841
* Managed challenged start as non-interactive and escalate to interactive if necessary.
39-
* Important: For this to work at the moment, the instance needs to be using SMART protection
40-
* as we need both keys (visible and invisible) to be present.
4142
*/
42-
public async managed() {
43+
public async managedOrInvisible(opts?: Partial<CaptchaOptions>) {
4344
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
4445
retrieveCaptchaInfo(this.clerk);
4546

@@ -50,10 +51,7 @@ export class CaptchaChallenge {
5051
invisibleSiteKey: captchaPublicKeyInvisible,
5152
scriptUrl: captchaURL,
5253
captchaProvider,
53-
modalWrapperQuerySelector: '#cl-modal-captcha-wrapper',
54-
modalContainerQuerySelector: '#cl-modal-captcha-container',
55-
openModal: () => this.clerk.__internal_openBlankCaptchaModal(),
56-
closeModal: () => this.clerk.__internal_closeBlankCaptchaModal(),
54+
...opts,
5755
}).catch(e => {
5856
if (e.captchaError) {
5957
return { captchaError: e.captchaError };
@@ -64,4 +62,17 @@ export class CaptchaChallenge {
6462

6563
return {};
6664
}
65+
66+
/**
67+
* Similar to managed() but will render the CAPTCHA challenge in a modal
68+
* managed by clerk-js itself.
69+
*/
70+
public async managedInModal() {
71+
return this.managedOrInvisible({
72+
modalWrapperQuerySelector: '#cl-modal-captcha-wrapper',
73+
modalContainerQuerySelector: '#cl-modal-captcha-container',
74+
openModal: () => this.clerk.__internal_openBlankCaptchaModal(),
75+
closeModal: () => this.clerk.__internal_closeBlankCaptchaModal(),
76+
});
77+
}
6778
}

‎packages/types/src/client.ts

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface ClientResource extends ClerkResource {
1818
isEligibleForTouch: () => boolean;
1919
buildTouchUrl: (params: { redirectUrl: URL }) => string;
2020
lastActiveSessionId: string | null;
21+
captchaBypass: boolean;
2122
cookieExpiresAt: Date | null;
2223
createdAt: Date | null;
2324
updatedAt: Date | null;

‎packages/types/src/json.ts

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export interface ClientJSON extends ClerkResourceJSON {
7070
sessions: SessionJSON[];
7171
sign_up: SignUpJSON | null;
7272
sign_in: SignInJSON | null;
73+
captcha_bypass?: boolean;
7374
last_active_session_id: string | null;
7475
cookie_expires_at: number | null;
7576
created_at: number;

0 commit comments

Comments
 (0)
Please sign in to comment.