Skip to content

Commit eb67419

Browse files
authored
fix(clerk-js): Stop retrying on /verify if the client cannot solve the challenge (#5526)
1 parent c4836f9 commit eb67419

File tree

4 files changed

+48
-11
lines changed

4 files changed

+48
-11
lines changed

.changeset/fresh-apples-add.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Stop retrying on `/verify` if the client cannot solve the challenge

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

+23-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { CaptchaChallenge } from '../utils/captcha/CaptchaChallenge';
22
import type { Clerk } from './resources/internal';
3-
import { Client, isClerkAPIResponseError } from './resources/internal';
3+
import { ClerkRuntimeError, Client, isClerkAPIResponseError } from './resources/internal';
44

55
export class FraudProtection {
66
private static instance: FraudProtection;
77

88
private inflightException: Promise<unknown> | null = null;
99

10+
private captchaRetryCount = 0;
11+
private readonly MAX_RETRY_ATTEMPTS = 3;
12+
1013
public static getInstance(): FraudProtection {
1114
if (!FraudProtection.instance) {
1215
FraudProtection.instance = new FraudProtection(Client, CaptchaChallenge);
@@ -20,6 +23,13 @@ export class FraudProtection {
2023
) {}
2124

2225
public async execute<T extends () => Promise<any>, R = Awaited<ReturnType<T>>>(clerk: Clerk, cb: T): Promise<R> {
26+
if (this.captchaAttemptsExceeded()) {
27+
throw new ClerkRuntimeError(
28+
'Security verification failed. Please try again by refreshing the page, clearing your browser cookies, or using a different web browser.',
29+
{ code: 'captcha_client_attempts_exceeded' },
30+
);
31+
}
32+
2333
try {
2434
if (this.inflightException) {
2535
await this.inflightException;
@@ -47,10 +57,15 @@ export class FraudProtection {
4757
let resolve: any;
4858
this.inflightException = new Promise<unknown>(r => (resolve = r));
4959

50-
const captchaParams = await this.managedChallenge(clerk);
51-
5260
try {
53-
await this.client.getOrCreateInstance().sendCaptchaToken(captchaParams);
61+
const captchaParams: any = await this.managedChallenge(clerk);
62+
if (captchaParams?.captchaError !== 'modal_component_not_ready') {
63+
await this.client.getOrCreateInstance().sendCaptchaToken(captchaParams);
64+
this.captchaRetryCount = 0; // Reset the retry count on success
65+
}
66+
} catch (err) {
67+
this.captchaRetryCount++;
68+
throw err;
5469
} finally {
5570
// Resolve the exception placeholder promise so that other exceptions can be handled
5671
resolve();
@@ -64,4 +79,8 @@ export class FraudProtection {
6479
public managedChallenge(clerk: Clerk) {
6580
return new this.CaptchaChallengeImpl(clerk).managedInModal({ action: 'verify' });
6681
}
82+
83+
private captchaAttemptsExceeded = () => {
84+
return this.captchaRetryCount >= this.MAX_RETRY_ATTEMPTS;
85+
};
6786
}

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

+11-6
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class CaptchaChallenge {
1515
const { captchaSiteKey, canUseCaptcha, captchaPublicKeyInvisible } = retrieveCaptchaInfo(this.clerk);
1616

1717
if (canUseCaptcha && captchaSiteKey && captchaPublicKeyInvisible) {
18-
return getCaptchaToken({
18+
const captchaResult = await getCaptchaToken({
1919
siteKey: captchaPublicKeyInvisible,
2020
invisibleSiteKey: captchaPublicKeyInvisible,
2121
widgetType: 'invisible',
@@ -25,11 +25,12 @@ export class CaptchaChallenge {
2525
if (e.captchaError) {
2626
return { captchaError: e.captchaError };
2727
}
28-
return { captchaError: e?.message || e };
28+
return { captchaError: e?.message || e || 'unexpected_captcha_error' };
2929
});
30+
return { ...captchaResult, captchaAction: opts?.action };
3031
}
3132

32-
return { captchaError: 'captcha_unavailable' };
33+
return { captchaError: 'captcha_unavailable', captchaAction: opts?.action };
3334
}
3435

3536
/**
@@ -45,7 +46,7 @@ export class CaptchaChallenge {
4546
retrieveCaptchaInfo(this.clerk);
4647

4748
if (canUseCaptcha && captchaSiteKey && captchaPublicKeyInvisible) {
48-
return getCaptchaToken({
49+
const captchaResult = await getCaptchaToken({
4950
siteKey: captchaSiteKey,
5051
widgetType: captchaWidgetType,
5152
invisibleSiteKey: captchaPublicKeyInvisible,
@@ -55,11 +56,15 @@ export class CaptchaChallenge {
5556
if (e.captchaError) {
5657
return { captchaError: e.captchaError };
5758
}
58-
return opts?.action === 'verify' ? { captchaError: e?.message || e } : undefined;
59+
// if captcha action is signup, we return undefined, because we don't want to make the call to FAPI
60+
return opts?.action === 'verify' ? { captchaError: e?.message || e || 'unexpected_captcha_error' } : undefined;
5961
});
62+
return opts?.action === 'verify' ? { ...captchaResult, captchaAction: 'verify' } : captchaResult;
6063
}
6164

62-
return opts?.action === 'verify' ? { captchaError: 'captcha_unavailable' } : {};
65+
// if captcha action is signup, we return an empty object, because it means that the bot protection is disabled
66+
// and the user should be able to sign up without solving a captcha
67+
return opts?.action === 'verify' ? { captchaError: 'captcha_unavailable', captchaAction: opts?.action } : {};
6368
}
6469

6570
/**

packages/clerk-js/src/utils/captcha/turnstile.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,15 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
176176
// but we won't show the modal as it will never escalate to interactive mode
177177
captchaWidgetType = widgetType;
178178
widgetContainerQuerySelector = modalContainerQuerySelector;
179-
await openModal?.();
179+
try {
180+
await openModal?.();
181+
} catch {
182+
// When a client is captcha_block the first attempt to open the modal will fail with 'ClerkJS components are not ready yet.'
183+
// This happens consistently in the first attempt, because in clerk.#loadInStandardBrowser we first await for the `/client` response
184+
// and then we run initComponents to initialize the components.
185+
// eslint-disable-next-line @typescript-eslint/only-throw-error
186+
throw { captchaError: 'modal_component_not_ready' };
187+
}
180188
const modalContainderEl = await waitForElement(modalContainerQuerySelector);
181189
if (modalContainderEl) {
182190
const { theme, language, size } = getCaptchaAttibutesFromElemenet(modalContainderEl);

0 commit comments

Comments
 (0)