Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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