Skip to content

Commit

Permalink
Fraud protection improvements (#4614)
Browse files Browse the repository at this point in the history
  • Loading branch information
nikosdouvlis authored Nov 20, 2024
1 parent ea9e6b4 commit 273d16c
Show file tree
Hide file tree
Showing 9 changed files with 81 additions and 159 deletions.
6 changes: 6 additions & 0 deletions .changeset/red-chicken-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/clerk-js": patch
"@clerk/types": patch
---

Inject captcha token into every X heartbeats
62 changes: 62 additions & 0 deletions packages/clerk-js/src/core/fraudProtection.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { getCaptchaToken, retrieveCaptchaInfo } from '../utils/captcha';
import type { Clerk } from './resources/internal';

/**
* TODO: @nikos Move captcha and fraud detection logic to this class
*/
class FraudProtectionService {
private inflightRequest: Promise<unknown> | null = null;
private ticks = 0;
private readonly interval = 6;

public async execute<T extends () => Promise<any>>(cb: T): Promise<Awaited<ReturnType<T>>> {
if (this.inflightRequest) {
Expand All @@ -20,6 +25,63 @@ class FraudProtectionService {
public blockUntilReady() {
return this.inflightRequest ? this.inflightRequest.then(() => null) : Promise.resolve();
}

public async challengeHeartbeat(clerk: Clerk) {
if (!clerk.__unstable__environment?.displayConfig.captchaHeartbeat || this.ticks++ % (this.interval - 1)) {
return undefined;
}
return this.invisibleChallenge(clerk);
}

/**
* Triggers an invisible challenge.
* This will always use the non-interactive variant of the CAPTCHA challenge and will
* always use the fallback key.
*/
public async invisibleChallenge(clerk: Clerk) {
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaPublicKeyInvisible } = retrieveCaptchaInfo(clerk);

if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
return getCaptchaToken({
siteKey: captchaPublicKeyInvisible,
invisibleSiteKey: captchaPublicKeyInvisible,
widgetType: 'invisible',
scriptUrl: captchaURL,
captchaProvider: 'turnstile',
});
}

return undefined;
}

/**
* 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.
* 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 managedChallenge(clerk: Clerk) {
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
retrieveCaptchaInfo(clerk);

if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
return getCaptchaToken({
siteKey: captchaSiteKey,
widgetType: captchaWidgetType,
invisibleSiteKey: captchaPublicKeyInvisible,
scriptUrl: captchaURL,
captchaProvider,
modalWrapperQuerySelector: '#cl-modal-captcha-wrapper',
modalContainerQuerySelector: '#cl-modal-captcha-container',
openModal: () => clerk.__internal_openBlankCaptchaModal(),
closeModal: () => clerk.__internal_closeBlankCaptchaModal(),
});
}

return {};
}
}

export const fraudProtection = new FraudProtectionService();
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/DisplayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
captchaProvider: CaptchaProvider = 'turnstile';
captchaPublicKeyInvisible: string | null = null;
captchaOauthBypass: OAuthStrategy[] = [];
captchaHeartbeat: boolean = false;
homeUrl!: string;
instanceEnvironmentType!: string;
faviconImageUrl!: string;
Expand Down Expand Up @@ -83,6 +84,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
// These are the OAuth strategies we used to bypass the captcha for by default
// before the introduction of the captcha_oauth_bypass field
this.captchaOauthBypass = data.captcha_oauth_bypass || ['oauth_google', 'oauth_microsoft', 'oauth_apple'];
this.captchaHeartbeat = data.captcha_heartbeat || false;
this.supportEmail = data.support_email || '';
this.clerkJSVersion = data.clerk_js_version;
this.organizationProfileUrl = data.organization_profile_url;
Expand Down
28 changes: 4 additions & 24 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import type {
UserResource,
} from '@clerk/types';

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

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

const createTokenWithCaptchaProtection = async () => {
return Token.create(path, params).catch(e => {
const heartbeatParams = skipCache ? undefined : await fraudProtection.challengeHeartbeat(Session.clerk);
return Token.create(path, { ...params, ...heartbeatParams }).catch(e => {
if (isClerkAPIResponseError(e) && e.errors[0].code === 'requires_captcha') {
return fraudProtection.execute(async () => {
const captchaParams = await this.#triggerCaptchaChallenge();
const captchaParams = await fraudProtection.managedChallenge(Session.clerk);
return Token.create(path, { ...params, ...captchaParams });
});
}
Expand All @@ -298,25 +299,4 @@ export class Session extends BaseResource implements SessionResource {
return token.getRawString() || null;
});
}

async #triggerCaptchaChallenge() {
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
retrieveCaptchaInfo(Session.clerk);

if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
return getCaptchaToken({
siteKey: captchaSiteKey,
widgetType: captchaWidgetType,
invisibleSiteKey: captchaPublicKeyInvisible,
scriptUrl: captchaURL,
captchaProvider,
modalWrapperQuerySelector: '#cl-modal-captcha-wrapper',
modalContainerQuerySelector: '#cl-modal-captcha-container',
openModal: () => Session.clerk.__internal_openBlankCaptchaModal(),
closeModal: () => Session.clerk.__internal_closeBlankCaptchaModal(),
});
}

return {};
}
}
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/tokenCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface TokenCache {

const KEY_PREFIX = 'clerk';
const DELIMITER = '::';
const LEEWAY = 10;
const LEEWAY = 11;
// This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller
const SYNC_LEEWAY = 5;

Expand Down
14 changes: 2 additions & 12 deletions packages/clerk-js/src/utils/captcha/getCaptchaToken.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,4 @@
import { getHCaptchaToken } from './hcaptcha';
import { getTurnstileToken } from './turnstile';
import type { CaptchaOptions, GetCaptchaTokenReturn } from './types';
import type { CaptchaOptions } from './types';

/*
* This is a temporary solution to test different captcha providers, until we decide on a single one.
*/
export const getCaptchaToken = (opts: CaptchaOptions): Promise<GetCaptchaTokenReturn> => {
if (opts.captchaProvider === 'hcaptcha') {
return getHCaptchaToken(opts);
} else {
return getTurnstileToken(opts);
}
};
export const getCaptchaToken = (opts: CaptchaOptions) => getTurnstileToken(opts);
120 changes: 0 additions & 120 deletions packages/clerk-js/src/utils/captcha/hcaptcha.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => {
: null,
captchaURL: fapiClient
.buildUrl({
path: captchaProvider == 'hcaptcha' ? 'hcaptcha/1/api.js' : 'cloudflare/turnstile/v0/api.js',
path: 'cloudflare/turnstile/v0/api.js',
pathPrefix: '',
search: '?render=explicit',
})
Expand Down
4 changes: 3 additions & 1 deletion packages/types/src/displayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { OAuthStrategy } from './strategies';

export type PreferredSignInStrategy = 'password' | 'otp';
export type CaptchaWidgetType = 'smart' | 'invisible' | null;
export type CaptchaProvider = 'hcaptcha' | 'turnstile';
export type CaptchaProvider = 'turnstile';

export interface DisplayConfigJSON {
object: 'display_config';
Expand All @@ -21,6 +21,7 @@ export interface DisplayConfigJSON {
captcha_public_key_invisible: string | null;
captcha_provider: CaptchaProvider;
captcha_oauth_bypass: OAuthStrategy[] | null;
captcha_heartbeat?: boolean;
home_url: string;
instance_environment_type: string;
logo_image_url: string;
Expand Down Expand Up @@ -64,6 +65,7 @@ export interface DisplayConfigResource extends ClerkResource {
* This can also be used to bypass the captcha for a specific OAuth provider on a per-instance basis.
*/
captchaOauthBypass: OAuthStrategy[];
captchaHeartbeat: boolean;
homeUrl: string;
instanceEnvironmentType: string;
logoImageUrl: string;
Expand Down

0 comments on commit 273d16c

Please sign in to comment.