diff --git a/.changeset/red-chicken-visit.md b/.changeset/red-chicken-visit.md new file mode 100644 index 00000000000..4e87d960f19 --- /dev/null +++ b/.changeset/red-chicken-visit.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": patch +"@clerk/types": patch +--- + +Inject captcha token into every X heartbeats diff --git a/packages/clerk-js/src/core/fraudProtection.ts b/packages/clerk-js/src/core/fraudProtection.ts index 173b72f02ca..5ad24208c38 100644 --- a/packages/clerk-js/src/core/fraudProtection.ts +++ b/packages/clerk-js/src/core/fraudProtection.ts @@ -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 | null = null; + private ticks = 0; + private readonly interval = 6; public async execute Promise>(cb: T): Promise>> { if (this.inflightRequest) { @@ -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(); diff --git a/packages/clerk-js/src/core/resources/DisplayConfig.ts b/packages/clerk-js/src/core/resources/DisplayConfig.ts index f6b33901744..62fd568edcd 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -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; @@ -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; diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index f003334ea6c..c4a30e76008 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -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'; @@ -273,13 +272,15 @@ export class Session extends BaseResource implements SessionResource { // TODO: update template endpoint to accept organizationId const params: Record = 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 }); }); } @@ -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 {}; - } } diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 102798425ed..c5e74be3da1 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -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; diff --git a/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts b/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts index 9003d1ea5a8..68058d407ce 100644 --- a/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts +++ b/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts @@ -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 => { - if (opts.captchaProvider === 'hcaptcha') { - return getHCaptchaToken(opts); - } else { - return getTurnstileToken(opts); - } -}; +export const getCaptchaToken = (opts: CaptchaOptions) => getTurnstileToken(opts); diff --git a/packages/clerk-js/src/utils/captcha/hcaptcha.ts b/packages/clerk-js/src/utils/captcha/hcaptcha.ts deleted file mode 100644 index 8f74f2d66c1..00000000000 --- a/packages/clerk-js/src/utils/captcha/hcaptcha.ts +++ /dev/null @@ -1,120 +0,0 @@ -/// - -import { loadScript } from '@clerk/shared/loadScript'; -import type { CaptchaWidgetType } from '@clerk/types'; - -import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from './constants'; -import type { CaptchaOptions } from './types'; - -async function loadCaptcha(url: string) { - if (!window.hcaptcha) { - try { - await loadScript(url, { defer: true }); - } catch { - // Rethrow with specific message - console.error('Clerk: Failed to load the CAPTCHA script from the URL: ', url); - throw { - captchaError: 'captcha_script_failed_to_load', - }; - } - } - return window.hcaptcha; -} - -export const getHCaptchaToken = async (captchaOptions: CaptchaOptions) => { - const { siteKey, scriptUrl, widgetType, invisibleSiteKey } = captchaOptions; - let captchaToken = '', - id = ''; - let isInvisibleWidget = !widgetType || widgetType === 'invisible'; - let hCaptchaSiteKey = siteKey; - - let widgetDiv: HTMLElement | null = null; - - const createInvisibleDOMElement = () => { - const div = document.createElement('div'); - div.id = CAPTCHA_INVISIBLE_CLASSNAME; - document.body.appendChild(div); - return div; - }; - - const captcha: HCaptcha = await loadCaptcha(scriptUrl); - let retries = 0; - const errorCodes: (string | number)[] = []; - - const handleCaptchaTokenGeneration = (): Promise<[string, string]> => { - return new Promise((resolve, reject) => { - try { - if (isInvisibleWidget) { - widgetDiv = createInvisibleDOMElement(); - } else { - const visibleDiv = document.getElementById(CAPTCHA_ELEMENT_ID); - if (visibleDiv) { - visibleDiv.style.display = 'block'; - widgetDiv = visibleDiv; - } else { - console.error( - 'Cannot initialize Smart CAPTCHA widget because the `clerk-captcha` DOM element was not found; falling back to Invisible CAPTCHA widget. If you are using a custom flow, visit https://clerk.com/docs/custom-flows/bot-sign-up-protection for instructions', - ); - widgetDiv = createInvisibleDOMElement(); - isInvisibleWidget = true; - hCaptchaSiteKey = invisibleSiteKey; - } - } - - const id = captcha.render(isInvisibleWidget ? CAPTCHA_INVISIBLE_CLASSNAME : CAPTCHA_ELEMENT_ID, { - sitekey: hCaptchaSiteKey, - size: isInvisibleWidget ? 'invisible' : 'normal', - callback: function (token: string) { - resolve([token, id]); - }, - 'error-callback': function (errorCode) { - errorCodes.push(errorCode); - if (retries < 2) { - setTimeout(() => { - captcha.reset(id); - retries++; - }, 250); - return; - } - reject([errorCodes.join(','), id]); - }, - }); - - if (isInvisibleWidget) { - captcha.execute(id); - } - } catch (e) { - /** - * There is a case the captcha may fail before the challenge has started. - * In such case the 'error-callback' does not fire. - * We should mark the promise as rejected. - */ - reject([e, undefined]); - } - }); - }; - - try { - [captchaToken, id] = await handleCaptchaTokenGeneration(); - // After a successful challenge remove it - captcha.remove(id); - } catch ([e, id]) { - if (id) { - // After a failed challenge remove it - captcha.remove(id); - } - throw { - captchaError: e, - }; - } finally { - if (widgetDiv) { - if (isInvisibleWidget) { - document.body.removeChild(widgetDiv as HTMLElement); - } else { - (widgetDiv as HTMLElement).style.display = 'none'; - } - } - } - - return { captchaToken, captchaWidgetType: (isInvisibleWidget ? 'invisible' : 'smart') as CaptchaWidgetType }; -}; diff --git a/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts b/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts index 2d15bdea6b1..2cf78c84db0 100644 --- a/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts +++ b/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts @@ -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', }) diff --git a/packages/types/src/displayConfig.ts b/packages/types/src/displayConfig.ts index f10a8dbb9dd..2eab780801a 100644 --- a/packages/types/src/displayConfig.ts +++ b/packages/types/src/displayConfig.ts @@ -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'; @@ -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; @@ -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;