Skip to content

Commit 273d16c

Browse files
authoredNov 20, 2024··
Fraud protection improvements (#4614)
1 parent ea9e6b4 commit 273d16c

File tree

9 files changed

+81
-159
lines changed

9 files changed

+81
-159
lines changed
 

‎.changeset/red-chicken-visit.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+
Inject captcha token into every X heartbeats

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

+62
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import { getCaptchaToken, retrieveCaptchaInfo } from '../utils/captcha';
2+
import type { Clerk } from './resources/internal';
3+
14
/**
25
* TODO: @nikos Move captcha and fraud detection logic to this class
36
*/
47
class FraudProtectionService {
58
private inflightRequest: Promise<unknown> | null = null;
9+
private ticks = 0;
10+
private readonly interval = 6;
611

712
public async execute<T extends () => Promise<any>>(cb: T): Promise<Awaited<ReturnType<T>>> {
813
if (this.inflightRequest) {
@@ -20,6 +25,63 @@ class FraudProtectionService {
2025
public blockUntilReady() {
2126
return this.inflightRequest ? this.inflightRequest.then(() => null) : Promise.resolve();
2227
}
28+
29+
public async challengeHeartbeat(clerk: Clerk) {
30+
if (!clerk.__unstable__environment?.displayConfig.captchaHeartbeat || this.ticks++ % (this.interval - 1)) {
31+
return undefined;
32+
}
33+
return this.invisibleChallenge(clerk);
34+
}
35+
36+
/**
37+
* Triggers an invisible challenge.
38+
* This will always use the non-interactive variant of the CAPTCHA challenge and will
39+
* always use the fallback key.
40+
*/
41+
public async invisibleChallenge(clerk: Clerk) {
42+
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaPublicKeyInvisible } = retrieveCaptchaInfo(clerk);
43+
44+
if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
45+
return getCaptchaToken({
46+
siteKey: captchaPublicKeyInvisible,
47+
invisibleSiteKey: captchaPublicKeyInvisible,
48+
widgetType: 'invisible',
49+
scriptUrl: captchaURL,
50+
captchaProvider: 'turnstile',
51+
});
52+
}
53+
54+
return undefined;
55+
}
56+
57+
/**
58+
* Triggers a smart challenge if the user is required to solve a CAPTCHA.
59+
* Depending on the environment settings, this will either trigger an
60+
* invisible or smart (managed) CAPTCHA challenge.
61+
* Managed challenged start as non-interactive and escalate to interactive if necessary.
62+
* Important: For this to work at the moment, the instance needs to be using SMART protection
63+
* as we need both keys (visible and invisible) to be present.
64+
*/
65+
public async managedChallenge(clerk: Clerk) {
66+
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
67+
retrieveCaptchaInfo(clerk);
68+
69+
if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
70+
return getCaptchaToken({
71+
siteKey: captchaSiteKey,
72+
widgetType: captchaWidgetType,
73+
invisibleSiteKey: captchaPublicKeyInvisible,
74+
scriptUrl: captchaURL,
75+
captchaProvider,
76+
modalWrapperQuerySelector: '#cl-modal-captcha-wrapper',
77+
modalContainerQuerySelector: '#cl-modal-captcha-container',
78+
openModal: () => clerk.__internal_openBlankCaptchaModal(),
79+
closeModal: () => clerk.__internal_closeBlankCaptchaModal(),
80+
});
81+
}
82+
83+
return {};
84+
}
2385
}
2486

2587
export const fraudProtection = new FraudProtectionService();

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

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
2626
captchaProvider: CaptchaProvider = 'turnstile';
2727
captchaPublicKeyInvisible: string | null = null;
2828
captchaOauthBypass: OAuthStrategy[] = [];
29+
captchaHeartbeat: boolean = false;
2930
homeUrl!: string;
3031
instanceEnvironmentType!: string;
3132
faviconImageUrl!: string;
@@ -83,6 +84,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
8384
// These are the OAuth strategies we used to bypass the captcha for by default
8485
// before the introduction of the captcha_oauth_bypass field
8586
this.captchaOauthBypass = data.captcha_oauth_bypass || ['oauth_google', 'oauth_microsoft', 'oauth_apple'];
87+
this.captchaHeartbeat = data.captcha_heartbeat || false;
8688
this.supportEmail = data.support_email || '';
8789
this.clerkJSVersion = data.clerk_js_version;
8890
this.organizationProfileUrl = data.organization_profile_url;

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

+4-24
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import type {
2222
UserResource,
2323
} from '@clerk/types';
2424

25-
import { getCaptchaToken, retrieveCaptchaInfo } from '../../utils/captcha';
2625
import { unixEpochToDate } from '../../utils/date';
2726
import { clerkInvalidStrategy } from '../errors';
2827
import { eventBus, events } from '../events';
@@ -273,13 +272,15 @@ export class Session extends BaseResource implements SessionResource {
273272
// TODO: update template endpoint to accept organizationId
274273
const params: Record<string, string | null> = template ? {} : { organizationId };
275274

275+
// this handles all getToken invocations with skipCache: true
276276
await fraudProtection.blockUntilReady();
277277

278278
const createTokenWithCaptchaProtection = async () => {
279-
return Token.create(path, params).catch(e => {
279+
const heartbeatParams = skipCache ? undefined : await fraudProtection.challengeHeartbeat(Session.clerk);
280+
return Token.create(path, { ...params, ...heartbeatParams }).catch(e => {
280281
if (isClerkAPIResponseError(e) && e.errors[0].code === 'requires_captcha') {
281282
return fraudProtection.execute(async () => {
282-
const captchaParams = await this.#triggerCaptchaChallenge();
283+
const captchaParams = await fraudProtection.managedChallenge(Session.clerk);
283284
return Token.create(path, { ...params, ...captchaParams });
284285
});
285286
}
@@ -298,25 +299,4 @@ export class Session extends BaseResource implements SessionResource {
298299
return token.getRawString() || null;
299300
});
300301
}
301-
302-
async #triggerCaptchaChallenge() {
303-
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
304-
retrieveCaptchaInfo(Session.clerk);
305-
306-
if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
307-
return getCaptchaToken({
308-
siteKey: captchaSiteKey,
309-
widgetType: captchaWidgetType,
310-
invisibleSiteKey: captchaPublicKeyInvisible,
311-
scriptUrl: captchaURL,
312-
captchaProvider,
313-
modalWrapperQuerySelector: '#cl-modal-captcha-wrapper',
314-
modalContainerQuerySelector: '#cl-modal-captcha-container',
315-
openModal: () => Session.clerk.__internal_openBlankCaptchaModal(),
316-
closeModal: () => Session.clerk.__internal_closeBlankCaptchaModal(),
317-
});
318-
}
319-
320-
return {};
321-
}
322302
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface TokenCache {
2626

2727
const KEY_PREFIX = 'clerk';
2828
const DELIMITER = '::';
29-
const LEEWAY = 10;
29+
const LEEWAY = 11;
3030
// This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller
3131
const SYNC_LEEWAY = 5;
3232

Original file line numberDiff line numberDiff line change
@@ -1,14 +1,4 @@
1-
import { getHCaptchaToken } from './hcaptcha';
21
import { getTurnstileToken } from './turnstile';
3-
import type { CaptchaOptions, GetCaptchaTokenReturn } from './types';
2+
import type { CaptchaOptions } from './types';
43

5-
/*
6-
* This is a temporary solution to test different captcha providers, until we decide on a single one.
7-
*/
8-
export const getCaptchaToken = (opts: CaptchaOptions): Promise<GetCaptchaTokenReturn> => {
9-
if (opts.captchaProvider === 'hcaptcha') {
10-
return getHCaptchaToken(opts);
11-
} else {
12-
return getTurnstileToken(opts);
13-
}
14-
};
4+
export const getCaptchaToken = (opts: CaptchaOptions) => getTurnstileToken(opts);

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

-120
This file was deleted.

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => {
1818
: null,
1919
captchaURL: fapiClient
2020
.buildUrl({
21-
path: captchaProvider == 'hcaptcha' ? 'hcaptcha/1/api.js' : 'cloudflare/turnstile/v0/api.js',
21+
path: 'cloudflare/turnstile/v0/api.js',
2222
pathPrefix: '',
2323
search: '?render=explicit',
2424
})

‎packages/types/src/displayConfig.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { OAuthStrategy } from './strategies';
44

55
export type PreferredSignInStrategy = 'password' | 'otp';
66
export type CaptchaWidgetType = 'smart' | 'invisible' | null;
7-
export type CaptchaProvider = 'hcaptcha' | 'turnstile';
7+
export type CaptchaProvider = 'turnstile';
88

99
export interface DisplayConfigJSON {
1010
object: 'display_config';
@@ -21,6 +21,7 @@ export interface DisplayConfigJSON {
2121
captcha_public_key_invisible: string | null;
2222
captcha_provider: CaptchaProvider;
2323
captcha_oauth_bypass: OAuthStrategy[] | null;
24+
captcha_heartbeat?: boolean;
2425
home_url: string;
2526
instance_environment_type: string;
2627
logo_image_url: string;
@@ -64,6 +65,7 @@ export interface DisplayConfigResource extends ClerkResource {
6465
* This can also be used to bypass the captcha for a specific OAuth provider on a per-instance basis.
6566
*/
6667
captchaOauthBypass: OAuthStrategy[];
68+
captchaHeartbeat: boolean;
6769
homeUrl: string;
6870
instanceEnvironmentType: string;
6971
logoImageUrl: string;

0 commit comments

Comments
 (0)
Please sign in to comment.