From 4e8571c61dfcd86c590a59de35af1ef14e6f6b36 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Thu, 4 Apr 2024 17:18:27 +0300 Subject: [PATCH 1/4] feat(clerk-js): Bot Protection Upgrade Phase 1 PoC --- .../src/ui/components/SignUp/SignUpForm.tsx | 17 +++++++---- packages/clerk-js/src/utils/captcha.ts | 29 ++++++++++++++----- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx index dc3979f65b6..fa492406d3c 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Col, localizationKeys, useAppearance } from '../../customizables'; +import { CAPTCHA_ELEMENT_ID } from '../../../utils'; +import { Box, Col, localizationKeys, useAppearance } from '../../customizables'; import { Form } from '../../elements'; import { mqu } from '../../styledSystem'; import type { FormControlState } from '../../utils'; @@ -101,10 +102,16 @@ export const SignUpForm = (props: SignUpFormProps) => { )} - + + ({ display: 'none', marginBottom: t.space.$6 })} + /> + + ); }; diff --git a/packages/clerk-js/src/utils/captcha.ts b/packages/clerk-js/src/utils/captcha.ts index 1c10f1eb695..49b413da59c 100644 --- a/packages/clerk-js/src/utils/captcha.ts +++ b/packages/clerk-js/src/utils/captcha.ts @@ -35,6 +35,14 @@ interface RenderOptions { * @param errorCode string */ 'error-callback'?: (errorCode: string) => void; + /** + * Appearance controls when the widget is visible. + * It can be always (default), execute, or interaction-only. + * Refer to Appearance Modes for more information: + * https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#appearance-modes + * @default 'always' + */ + appearance?: 'always' | 'execute' | 'interaction-only'; } interface Turnstile { @@ -50,7 +58,7 @@ declare global { } } -const WIDGET_CLASSNAME = 'clerk-captcha'; +export const CAPTCHA_ELEMENT_ID = 'clerk-captcha'; export const shouldRetryTurnstileErrorCode = (errorCode: string) => { const codesWithRetries = ['crashed', 'undefined_error', '102', '103', '104', '106', '110600', '300', '600']; @@ -75,9 +83,14 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU let captchaToken = '', id = ''; - const div = document.createElement('div'); - div.classList.add(WIDGET_CLASSNAME); - document.body.appendChild(div); + const widgetDiv = document.getElementById(CAPTCHA_ELEMENT_ID); + if (widgetDiv) { + widgetDiv.style.display = 'block'; + } else { + throw { + captchaError: 'Element to render the captcha not found', + }; + } const captcha = await loadCaptcha(scriptUrl); let retries = 0; @@ -86,8 +99,9 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU const handleCaptchaTokenGeneration = (): Promise<[string, string]> => { return new Promise((resolve, reject) => { try { - const id = captcha.render(`.${WIDGET_CLASSNAME}`, { + const id = captcha.render(`#${CAPTCHA_ELEMENT_ID}`, { sitekey, + appearance: 'interaction-only', retry: 'never', 'refresh-expired': 'auto', callback: function (token: string) { @@ -133,8 +147,9 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU captchaError: e, }; } finally { - // After challenge has run remove node element attached - document.body.removeChild(div); + if (widgetDiv) { + widgetDiv.style.display = 'none'; + } } return captchaToken; From a36f64652c885b8e5d390c41a95acbc4534a87db Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Tue, 9 Apr 2024 16:15:13 +0300 Subject: [PATCH 2/4] feat(clerk-js): Add support for different CAPTCHA widget types --- .changeset/eighty-pens-mix.md | 6 +++ .../src/core/resources/DisplayConfig.ts | 2 + .../clerk-js/src/core/resources/SignUp.ts | 3 +- packages/clerk-js/src/utils/captcha.ts | 38 ++++++++++++++----- .../clerk-js/src/utils/retrieveCaptchaInfo.ts | 1 + packages/types/src/displayConfig.ts | 2 + 6 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 .changeset/eighty-pens-mix.md diff --git a/.changeset/eighty-pens-mix.md b/.changeset/eighty-pens-mix.md new file mode 100644 index 00000000000..4130381b42e --- /dev/null +++ b/.changeset/eighty-pens-mix.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Add support for different CAPTCHA widget types diff --git a/packages/clerk-js/src/core/resources/DisplayConfig.ts b/packages/clerk-js/src/core/resources/DisplayConfig.ts index f172acab4a8..19b0320d46f 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -14,6 +14,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource backendHost!: string; branded!: boolean; captchaPublicKey: string | null = null; + captchaWidgetType: string | null = null; homeUrl!: string; instanceEnvironmentType!: string; faviconImageUrl!: string; @@ -59,6 +60,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource this.afterSwitchSessionUrl = data.after_switch_session_url; this.branded = data.branded; this.captchaPublicKey = data.captcha_public_key; + this.captchaWidgetType = data.captcha_widget_type; 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/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index d40040daf5d..7d1def63bb8 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -68,12 +68,13 @@ export class SignUp extends BaseResource implements SignUpResource { create = async (params: SignUpCreateParams): Promise => { const paramsWithCaptcha: Record = params; - const { captchaSiteKey, canUseCaptcha, captchaURL } = retrieveCaptchaInfo(SignUp.clerk); + const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType } = retrieveCaptchaInfo(SignUp.clerk); if (canUseCaptcha && captchaSiteKey && captchaURL) { try { paramsWithCaptcha.captchaToken = await getCaptchaToken({ siteKey: captchaSiteKey, + widgetType: captchaWidgetType, scriptUrl: captchaURL, }); } catch (e) { diff --git a/packages/clerk-js/src/utils/captcha.ts b/packages/clerk-js/src/utils/captcha.ts index 49b413da59c..0b40a5bb493 100644 --- a/packages/clerk-js/src/utils/captcha.ts +++ b/packages/clerk-js/src/utils/captcha.ts @@ -59,6 +59,7 @@ 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']; @@ -78,18 +79,33 @@ export async function loadCaptcha(url: string) { return window.turnstile; } -export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptUrl: string }) => { - const { siteKey: sitekey, scriptUrl } = captchaOptions; +export const getCaptchaToken = async (captchaOptions: { + siteKey: string; + scriptUrl: string; + widgetType: string | null; +}) => { + const { siteKey: sitekey, scriptUrl, widgetType } = captchaOptions; let captchaToken = '', id = ''; + const invisibleWidget = !widgetType || widgetType === 'invisible'; - const widgetDiv = document.getElementById(CAPTCHA_ELEMENT_ID); - if (widgetDiv) { - widgetDiv.style.display = 'block'; + let widgetDiv: HTMLElement | null = null; + + if (invisibleWidget) { + const div = document.createElement('div'); + div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); + document.body.appendChild(div); + widgetDiv = div; } else { - throw { - captchaError: 'Element to render the captcha not found', - }; + const visibleDiv = document.getElementById(CAPTCHA_ELEMENT_ID); + if (visibleDiv) { + visibleDiv.style.display = 'block'; + widgetDiv = visibleDiv; + } else { + throw { + captchaError: 'Element to render the captcha not found', + }; + } } const captcha = await loadCaptcha(scriptUrl); @@ -99,7 +115,7 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU const handleCaptchaTokenGeneration = (): Promise<[string, string]> => { return new Promise((resolve, reject) => { try { - const id = captcha.render(`#${CAPTCHA_ELEMENT_ID}`, { + const id = captcha.render(invisibleWidget ? `.${CAPTCHA_INVISIBLE_CLASSNAME}` : `#${CAPTCHA_ELEMENT_ID}`, { sitekey, appearance: 'interaction-only', retry: 'never', @@ -147,7 +163,9 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU captchaError: e, }; } finally { - if (widgetDiv) { + if (invisibleWidget) { + document.body.removeChild(widgetDiv); + } else { widgetDiv.style.display = 'none'; } } diff --git a/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts b/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts index 0948d483a58..d83f4a4d126 100644 --- a/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts +++ b/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts @@ -6,6 +6,7 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => { const fapiClient = createFapiClient(clerk); return { captchaSiteKey: _environment ? _environment.displayConfig.captchaPublicKey : null, + captchaWidgetType: _environment ? _environment.displayConfig.captchaWidgetType : null, canUseCaptcha: _environment ? _environment.userSettings.signUp.captcha_enabled && clerk.isStandardBrowser && diff --git a/packages/types/src/displayConfig.ts b/packages/types/src/displayConfig.ts index 36228251d0f..7fce8be15d0 100644 --- a/packages/types/src/displayConfig.ts +++ b/packages/types/src/displayConfig.ts @@ -14,6 +14,7 @@ export interface DisplayConfigJSON { application_name: string; branded: boolean; captcha_public_key: string | null; + captcha_widget_type: string | null; home_url: string; instance_environment_type: string; logo_image_url: string; @@ -42,6 +43,7 @@ export interface DisplayConfigResource extends ClerkResource { backendHost: string; branded: boolean; captchaPublicKey: string | null; + captchaWidgetType: string | null; homeUrl: string; instanceEnvironmentType: string; logoImageUrl: string; From d187452b64c968f6c9e7e53acc8c6dcb7d32ee91 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Tue, 9 Apr 2024 17:23:14 +0300 Subject: [PATCH 3/4] fix(clerk-js): Show the CAPTCHA even if the form is not shown --- .../clerk-js/src/ui/components/SignUp/SignUpForm.tsx | 9 +++------ .../clerk-js/src/ui/components/SignUp/SignUpStart.tsx | 2 ++ packages/clerk-js/src/ui/elements/CaptchaElement.tsx | 9 +++++++++ 3 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 packages/clerk-js/src/ui/elements/CaptchaElement.tsx diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx index fa492406d3c..bdc685a847c 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { CAPTCHA_ELEMENT_ID } from '../../../utils'; -import { Box, Col, localizationKeys, useAppearance } from '../../customizables'; +import { Col, localizationKeys, useAppearance } from '../../customizables'; import { Form } from '../../elements'; +import { CaptchaElement } from '../../elements/CaptchaElement'; import { mqu } from '../../styledSystem'; import type { FormControlState } from '../../utils'; import type { ActiveIdentifier, Fields } from './signUpFormHelpers'; @@ -103,10 +103,7 @@ export const SignUpForm = (props: SignUpFormProps) => { )} - ({ display: 'none', marginBottom: t.space.$6 })} - /> + )} + {!shouldShowForm && } diff --git a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx new file mode 100644 index 00000000000..ee8772cf7c6 --- /dev/null +++ b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx @@ -0,0 +1,9 @@ +import { CAPTCHA_ELEMENT_ID } from '../../utils'; +import { Box } from '../customizables'; + +export const CaptchaElement = () => ( + ({ display: 'none', marginBottom: t.space.$6 })} + /> +); From b87c910cd865685058c4685988351f4a7c077852 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Wed, 10 Apr 2024 17:33:26 +0300 Subject: [PATCH 4/4] fix(clerk-js): Add `appearance: always` property for `always_visible` widget type --- packages/clerk-js/src/core/resources/DisplayConfig.ts | 10 ++++++++-- packages/clerk-js/src/utils/captcha.ts | 5 +++-- packages/types/src/displayConfig.ts | 5 +++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/resources/DisplayConfig.ts b/packages/clerk-js/src/core/resources/DisplayConfig.ts index 19b0320d46f..222aad45fc6 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -1,4 +1,10 @@ -import type { DisplayConfigJSON, DisplayConfigResource, DisplayThemeJSON, PreferredSignInStrategy } from '@clerk/types'; +import type { + CaptchaWidgetType, + DisplayConfigJSON, + DisplayConfigResource, + DisplayThemeJSON, + PreferredSignInStrategy, +} from '@clerk/types'; import { BaseResource } from './internal'; @@ -14,7 +20,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource backendHost!: string; branded!: boolean; captchaPublicKey: string | null = null; - captchaWidgetType: string | null = null; + captchaWidgetType: CaptchaWidgetType = null; homeUrl!: string; instanceEnvironmentType!: string; faviconImageUrl!: string; diff --git a/packages/clerk-js/src/utils/captcha.ts b/packages/clerk-js/src/utils/captcha.ts index 0b40a5bb493..60b9ebe116c 100644 --- a/packages/clerk-js/src/utils/captcha.ts +++ b/packages/clerk-js/src/utils/captcha.ts @@ -1,4 +1,5 @@ import { loadScript } from '@clerk/shared/loadScript'; +import type { CaptchaWidgetType } from '@clerk/types'; import { clerkFailedToLoadThirdPartyScript } from '../core/errors'; @@ -82,7 +83,7 @@ export async function loadCaptcha(url: string) { export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptUrl: string; - widgetType: string | null; + widgetType: CaptchaWidgetType; }) => { const { siteKey: sitekey, scriptUrl, widgetType } = captchaOptions; let captchaToken = '', @@ -117,7 +118,7 @@ export const getCaptchaToken = async (captchaOptions: { try { const id = captcha.render(invisibleWidget ? `.${CAPTCHA_INVISIBLE_CLASSNAME}` : `#${CAPTCHA_ELEMENT_ID}`, { sitekey, - appearance: 'interaction-only', + appearance: widgetType === 'always_visible' ? 'always' : 'interaction-only', retry: 'never', 'refresh-expired': 'auto', callback: function (token: string) { diff --git a/packages/types/src/displayConfig.ts b/packages/types/src/displayConfig.ts index 7fce8be15d0..c7039c82ef1 100644 --- a/packages/types/src/displayConfig.ts +++ b/packages/types/src/displayConfig.ts @@ -2,6 +2,7 @@ import type { DisplayThemeJSON } from './json'; import type { ClerkResource } from './resource'; export type PreferredSignInStrategy = 'password' | 'otp'; +export type CaptchaWidgetType = 'smart' | 'always_visible' | 'invisible' | null; export interface DisplayConfigJSON { object: 'display_config'; @@ -14,7 +15,7 @@ export interface DisplayConfigJSON { application_name: string; branded: boolean; captcha_public_key: string | null; - captcha_widget_type: string | null; + captcha_widget_type: CaptchaWidgetType; home_url: string; instance_environment_type: string; logo_image_url: string; @@ -43,7 +44,7 @@ export interface DisplayConfigResource extends ClerkResource { backendHost: string; branded: boolean; captchaPublicKey: string | null; - captchaWidgetType: string | null; + captchaWidgetType: CaptchaWidgetType; homeUrl: string; instanceEnvironmentType: string; logoImageUrl: string;