From 4beb00672da64bafd67fbc98181c4c2649a9062c Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Mon, 27 May 2024 14:32:18 +0300 Subject: [PATCH] feat(clerk-js): Add experimental support for hCaptcha (#3422) Co-authored-by: Nikos Douvlis --- .changeset/thick-days-thank.md | 6 + package-lock.json | 7 + package.json | 1 + .../src/core/resources/DisplayConfig.ts | 3 + .../clerk-js/src/core/resources/SignUp.ts | 7 +- .../src/ui/elements/CaptchaElement.tsx | 2 +- .../src/utils/__tests__/captcha.test.ts | 2 +- .../clerk-js/src/utils/captcha/constants.ts | 2 + .../src/utils/captcha/getCaptchaToken.ts | 24 ++++ .../clerk-js/src/utils/captcha/hcaptcha.ts | 122 ++++++++++++++++++ packages/clerk-js/src/utils/captcha/index.ts | 3 + .../{ => captcha}/retrieveCaptchaInfo.ts | 8 +- .../{captcha.ts => captcha/turnstile.ts} | 9 +- packages/clerk-js/src/utils/index.ts | 1 - packages/types/src/displayConfig.ts | 3 + 15 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 .changeset/thick-days-thank.md create mode 100644 packages/clerk-js/src/utils/captcha/constants.ts create mode 100644 packages/clerk-js/src/utils/captcha/getCaptchaToken.ts create mode 100644 packages/clerk-js/src/utils/captcha/hcaptcha.ts create mode 100644 packages/clerk-js/src/utils/captcha/index.ts rename packages/clerk-js/src/utils/{ => captcha}/retrieveCaptchaInfo.ts (70%) rename packages/clerk-js/src/utils/{captcha.ts => captcha/turnstile.ts} (96%) diff --git a/.changeset/thick-days-thank.md b/.changeset/thick-days-thank.md new file mode 100644 index 0000000000..ba96daee3d --- /dev/null +++ b/.changeset/thick-days-thank.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Add experimental support for hCaptcha captcha provider diff --git a/package-lock.json b/package-lock.json index 27dd1efeb6..39e187eb90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@commitlint/config-conventional": "^19.2.2", "@emotion/jest": "^11.11.0", "@faker-js/faker": "^8.1.0", + "@hcaptcha/types": "^1.0.3", "@octokit/rest": "^20.0.2", "@playwright/test": "^1.44.0", "@testing-library/dom": "^8.19.0", @@ -6588,6 +6589,12 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hcaptcha/types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@hcaptcha/types/-/types-1.0.3.tgz", + "integrity": "sha512-1mbU6eSGawRrqeahRrOzZo/SVLI6oZ5/azuBpSyVrRRR96CnS3fOVDWfzxpngfxKD0/I9Rwu6c/3ITqD8rXeTQ==", + "dev": true + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.11", "license": "Apache-2.0", diff --git a/package.json b/package.json index a58dafb7d3..3feb6f8332 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@commitlint/config-conventional": "^19.2.2", "@emotion/jest": "^11.11.0", "@faker-js/faker": "^8.1.0", + "@hcaptcha/types": "^1.0.3", "@octokit/rest": "^20.0.2", "@playwright/test": "^1.44.0", "@testing-library/dom": "^8.19.0", diff --git a/packages/clerk-js/src/core/resources/DisplayConfig.ts b/packages/clerk-js/src/core/resources/DisplayConfig.ts index 8d776b1122..64b5e92de1 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -1,4 +1,5 @@ import type { + CaptchaProvider, CaptchaWidgetType, DisplayConfigJSON, DisplayConfigResource, @@ -21,6 +22,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource branded!: boolean; captchaPublicKey: string | null = null; captchaWidgetType: CaptchaWidgetType = null; + captchaProvider: CaptchaProvider = 'turnstile'; captchaPublicKeyInvisible: string | null = null; homeUrl!: string; instanceEnvironmentType!: string; @@ -69,6 +71,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource this.branded = data.branded; this.captchaPublicKey = data.captcha_public_key; this.captchaWidgetType = data.captcha_widget_type; + this.captchaProvider = data.captcha_provider; this.captchaPublicKeyInvisible = data.captcha_public_key_invisible; this.supportEmail = data.support_email || ''; this.clerkJSVersion = data.clerk_js_version; diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 885699fc9c..d4348b4c30 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -21,10 +21,10 @@ import type { StartEmailLinkFlowParams, } from '@clerk/types'; -import { generateSignatureWithMetamask, getCaptchaToken, getMetamaskIdentifier, windowNavigate } from '../../utils'; +import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate } from '../../utils'; +import { getCaptchaToken, retrieveCaptchaInfo } from '../../utils/captcha'; import { createValidatePassword } from '../../utils/passwords/password'; import { normalizeUnsafeMetadata } from '../../utils/resourceParams'; -import { retrieveCaptchaInfo } from '../../utils/retrieveCaptchaInfo'; import { clerkInvalidFAPIResponse, clerkVerifyEmailAddressCalledBeforeCreate, @@ -68,7 +68,7 @@ export class SignUp extends BaseResource implements SignUpResource { create = async (params: SignUpCreateParams): Promise => { const paramsWithCaptcha: Record = params; - const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaPublicKeyInvisible } = + const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } = retrieveCaptchaInfo(SignUp.clerk); if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) { @@ -78,6 +78,7 @@ export class SignUp extends BaseResource implements SignUpResource { widgetType: captchaWidgetType, invisibleSiteKey: captchaPublicKeyInvisible, scriptUrl: captchaURL, + captchaProvider, }); paramsWithCaptcha.captchaToken = captchaToken; paramsWithCaptcha.captchaWidgetType = captchaWidgetTypeUsed; diff --git a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx index 22ccd9d40f..76c9a7e11a 100644 --- a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx +++ b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx @@ -1,4 +1,4 @@ -import { CAPTCHA_ELEMENT_ID } from '../../utils'; +import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha'; import { Box } from '../customizables'; export const CaptchaElement = () => ( diff --git a/packages/clerk-js/src/utils/__tests__/captcha.test.ts b/packages/clerk-js/src/utils/__tests__/captcha.test.ts index 06201bc26a..0b64aaee20 100644 --- a/packages/clerk-js/src/utils/__tests__/captcha.test.ts +++ b/packages/clerk-js/src/utils/__tests__/captcha.test.ts @@ -1,4 +1,4 @@ -import { shouldRetryTurnstileErrorCode } from '../captcha'; +import { shouldRetryTurnstileErrorCode } from '../captcha/turnstile'; describe('shouldRetryTurnstileErrorCode', () => { it.each([ diff --git a/packages/clerk-js/src/utils/captcha/constants.ts b/packages/clerk-js/src/utils/captcha/constants.ts new file mode 100644 index 0000000000..7ad0a0e05c --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/constants.ts @@ -0,0 +1,2 @@ +export const CAPTCHA_ELEMENT_ID = 'clerk-captcha'; +export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha'; diff --git a/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts b/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts new file mode 100644 index 0000000000..ca7b1aef5a --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts @@ -0,0 +1,24 @@ +import type { CaptchaProvider, CaptchaWidgetType } from '@clerk/types'; + +import { getHCaptchaToken } from './hcaptcha'; +import { getTunstileToken } from './turnstile'; + +type CaptchaOptions = { + siteKey: string; + scriptUrl: string; + widgetType: CaptchaWidgetType; + invisibleSiteKey: string; + captchaProvider: CaptchaProvider; +}; + +/* + * This is a temporary solution to test different captcha providers, until we decide on a single one. + */ +export const getCaptchaToken = (captchaOptions: CaptchaOptions) => { + const { captchaProvider, ...captchaProviderOptions } = captchaOptions; + if (captchaProvider === 'hcaptcha') { + return getHCaptchaToken(captchaProviderOptions); + } else { + return getTunstileToken(captchaProviderOptions); + } +}; diff --git a/packages/clerk-js/src/utils/captcha/hcaptcha.ts b/packages/clerk-js/src/utils/captcha/hcaptcha.ts new file mode 100644 index 0000000000..29498035ae --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/hcaptcha.ts @@ -0,0 +1,122 @@ +/// + +import { loadScript } from '@clerk/shared/loadScript'; +import type { CaptchaWidgetType } from '@clerk/types'; + +import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from './constants'; + +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: { + siteKey: string; + scriptUrl: string; + widgetType: CaptchaWidgetType; + invisibleSiteKey: string; +}) => { + 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('Captcha DOM element not found. Using invisible captcha widget.'); + 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, captchaWidgetTypeUsed: isInvisibleWidget ? 'invisible' : 'smart' }; +}; diff --git a/packages/clerk-js/src/utils/captcha/index.ts b/packages/clerk-js/src/utils/captcha/index.ts new file mode 100644 index 0000000000..4b8e35c308 --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/index.ts @@ -0,0 +1,3 @@ +export * from './retrieveCaptchaInfo'; +export * from './constants'; +export * from './getCaptchaToken'; diff --git a/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts b/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts similarity index 70% rename from packages/clerk-js/src/utils/retrieveCaptchaInfo.ts rename to packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts index ca4d4eb5d8..0270163202 100644 --- a/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts +++ b/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts @@ -1,12 +1,14 @@ -import type { Clerk } from '../core/clerk'; -import { createFapiClient } from '../core/fapiClient'; +import type { Clerk } from '../../core/clerk'; +import { createFapiClient } from '../../core/fapiClient'; export const retrieveCaptchaInfo = (clerk: Clerk) => { const _environment = clerk.__unstable__environment; const fapiClient = createFapiClient(clerk); + const captchaProvider = _environment ? _environment.displayConfig.captchaProvider : 'turnstile'; return { captchaSiteKey: _environment ? _environment.displayConfig.captchaPublicKey : null, captchaWidgetType: _environment ? _environment.displayConfig.captchaWidgetType : null, + captchaProvider, captchaPublicKeyInvisible: _environment ? _environment.displayConfig.captchaPublicKeyInvisible : null, canUseCaptcha: _environment ? _environment.userSettings.signUp.captcha_enabled && @@ -15,7 +17,7 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => { : null, captchaURL: fapiClient .buildUrl({ - path: 'cloudflare/turnstile/v0/api.js', + path: captchaProvider == 'hcaptcha' ? 'hcaptcha/1/api.js' : 'cloudflare/turnstile/v0/api.js', pathPrefix: '', search: '?render=explicit', }) diff --git a/packages/clerk-js/src/utils/captcha.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts similarity index 96% rename from packages/clerk-js/src/utils/captcha.ts rename to packages/clerk-js/src/utils/captcha/turnstile.ts index 252baf0c25..ce63d283a4 100644 --- a/packages/clerk-js/src/utils/captcha.ts +++ b/packages/clerk-js/src/utils/captcha/turnstile.ts @@ -1,6 +1,8 @@ import { loadScript } from '@clerk/shared/loadScript'; import type { CaptchaWidgetType } from '@clerk/types'; +import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from './constants'; + interface RenderOptions { /** * Every widget has a sitekey. This sitekey is associated with the corresponding widget configuration and is created upon the widget creation. @@ -61,16 +63,13 @@ declare global { } } -export const CAPTCHA_ELEMENT_ID = 'clerk-captcha'; -export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha'; - export const shouldRetryTurnstileErrorCode = (errorCode: string) => { const codesWithRetries = ['crashed', 'undefined_error', '102', '103', '104', '106', '110600', '300', '600']; return !!codesWithRetries.find(w => errorCode.startsWith(w)); }; -export async function loadCaptcha(url: string) { +async function loadCaptcha(url: string) { if (!window.turnstile) { try { await loadScript(url, { defer: true }); @@ -92,7 +91,7 @@ export async function loadCaptcha(url: string) { * - If the widgetType is 'smart', the captcha widget is rendered in a div with the id 'clerk-captcha'. If the div does * not exist, the invisibleSiteKey is used as a fallback and the widget is rendered in a hidden div at the bottom of the body. */ -export const getCaptchaToken = async (captchaOptions: { +export const getTunstileToken = async (captchaOptions: { siteKey: string; scriptUrl: string; widgetType: CaptchaWidgetType; diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index 6799f50b67..2defd829ff 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -24,5 +24,4 @@ export * from './componentGuards'; export * from './queryStateParams'; export * from './normalizeRoutingOptions'; export * from './image'; -export * from './captcha'; export * from './completeSignUpFlow'; diff --git a/packages/types/src/displayConfig.ts b/packages/types/src/displayConfig.ts index ab923e76cc..ca75c6cfc9 100644 --- a/packages/types/src/displayConfig.ts +++ b/packages/types/src/displayConfig.ts @@ -3,6 +3,7 @@ import type { ClerkResource } from './resource'; export type PreferredSignInStrategy = 'password' | 'otp'; export type CaptchaWidgetType = 'smart' | 'invisible' | null; +export type CaptchaProvider = 'hcaptcha' | 'turnstile'; export interface DisplayConfigJSON { object: 'display_config'; @@ -17,6 +18,7 @@ export interface DisplayConfigJSON { captcha_public_key: string | null; captcha_widget_type: CaptchaWidgetType; captcha_public_key_invisible: string | null; + captcha_provider: CaptchaProvider; home_url: string; instance_environment_type: string; logo_image_url: string; @@ -47,6 +49,7 @@ export interface DisplayConfigResource extends ClerkResource { branded: boolean; captchaPublicKey: string | null; captchaWidgetType: CaptchaWidgetType; + captchaProvider: CaptchaProvider; captchaPublicKeyInvisible: string | null; homeUrl: string; instanceEnvironmentType: string;