From a965ed8078128c03985831f0b9b44962c16a2678 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Mon, 22 Jun 2026 16:31:39 +0100 Subject: [PATCH 1/4] feat(js,shared): add Prosopo Procaptcha as a CAPTCHA provider Adds 'prosopo' alongside 'turnstile' in CaptchaProvider and a new getProcaptchaToken that mirrors the invisible + smart flows from turnstile.ts. When displayConfig.captchaProvider is 'prosopo', clerk-js loads the Procaptcha bundle from js.prosopo.io and renders into the existing invisible/smart containers. Turnstile remains the default. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/prosopo-captcha-provider.md | 6 + .../src/utils/captcha/CaptchaChallenge.ts | 6 +- .../utils/captcha/__tests__/prosopo.test.ts | 61 +++++ .../src/utils/captcha/getCaptchaToken.ts | 5 + .../clerk-js/src/utils/captcha/prosopo.ts | 217 ++++++++++++++++++ packages/shared/src/types/displayConfig.ts | 2 +- 6 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 .changeset/prosopo-captcha-provider.md create mode 100644 packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts create mode 100644 packages/clerk-js/src/utils/captcha/prosopo.ts diff --git a/.changeset/prosopo-captcha-provider.md b/.changeset/prosopo-captcha-provider.md new file mode 100644 index 00000000000..53525b52097 --- /dev/null +++ b/.changeset/prosopo-captcha-provider.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add Prosopo Procaptcha as an alternative CAPTCHA provider alongside Cloudflare Turnstile. When `displayConfig.captchaProvider` is `'prosopo'`, clerk-js now loads the Procaptcha bundle from `js.prosopo.io` and renders an invisible or smart widget into the same containers Turnstile uses today (the auto-generated invisible div and the user-supplied `#clerk-captcha` element). Turnstile remains the default and the existing flow is unchanged for instances that have not opted into Prosopo. The public `CaptchaProvider` type now accepts `'turnstile' | 'prosopo'`. diff --git a/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts b/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts index 085edc7babc..1e0d50e4fd4 100644 --- a/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts +++ b/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts @@ -15,12 +15,14 @@ export class CaptchaChallenge { * always use the fallback key. */ public async invisible(opts?: Partial) { - const { captchaSiteKey, canUseCaptcha, captchaPublicKeyInvisible, nonce } = retrieveCaptchaInfo(this.clerk); + const { captchaSiteKey, canUseCaptcha, captchaProvider, captchaPublicKeyInvisible, nonce } = retrieveCaptchaInfo( + this.clerk, + ); if (canUseCaptcha && captchaSiteKey && captchaPublicKeyInvisible) { const captchaResult = await getCaptchaToken({ action: opts?.action, - captchaProvider: 'turnstile', + captchaProvider, invisibleSiteKey: captchaPublicKeyInvisible, nonce: opts?.nonce || nonce || undefined, siteKey: captchaPublicKeyInvisible, diff --git a/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts b/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts new file mode 100644 index 00000000000..4483f0f3dd8 --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts @@ -0,0 +1,61 @@ +import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getProcaptchaToken } from '../prosopo'; + +/** + * Mirrors the Turnstile regression guard: the inline `#clerk-captcha` "spotlight" + * keys off `style.maxHeight !== '0'`, so the common invisible flow must never + * mount Procaptcha into `#clerk-captcha`. + */ +describe('getProcaptchaToken — invisible flow', () => { + let renderArgs: { element: Element; options: Record } | undefined; + + beforeEach(() => { + renderArgs = undefined; + (window as any).procaptcha = { + render: vi.fn((element: Element, options: Record) => { + renderArgs = { element, options }; + setTimeout(() => options.callback('mock_token'), 0); + return Promise.resolve(); + }), + reset: vi.fn(), + execute: vi.fn(), + ready: vi.fn(), + }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).procaptcha; + vi.restoreAllMocks(); + }); + + it('renders into its own throwaway container, never #clerk-captcha', async () => { + const inline = document.createElement('div'); + inline.id = CAPTCHA_ELEMENT_ID; + inline.style.maxHeight = '0'; + document.body.appendChild(inline); + + const result = await getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + }); + + expect(result).toEqual({ captchaToken: 'mock_token', captchaWidgetType: 'invisible' }); + + // The invisible flow renders into the dedicated `.clerk-invisible-captcha` div with the invisible key. + expect((window as any).procaptcha.render).toHaveBeenCalledTimes(1); + expect((renderArgs?.element as HTMLElement).classList.contains(CAPTCHA_INVISIBLE_CLASSNAME)).toBe(true); + expect(renderArgs?.options.siteKey).toBe('invisible-key'); + expect(renderArgs?.options.size).toBe('invisible'); + + // The spotlight signal on #clerk-captcha is untouched throughout → no spotlight. + expect(inline.style.maxHeight || '0').toBe('0'); + + // The temporary invisible container is created then cleaned up in `finally`. + expect(document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toBeNull(); + }); +}); diff --git a/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts b/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts index cce0ad35331..f8777d4e50a 100644 --- a/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts +++ b/packages/clerk-js/src/utils/captcha/getCaptchaToken.ts @@ -1,3 +1,4 @@ +import { getProcaptchaToken } from './prosopo'; import { getTurnstileToken } from './turnstile'; import type { CaptchaOptions } from './types'; @@ -6,5 +7,9 @@ export const getCaptchaToken = (opts: CaptchaOptions) => { return Promise.reject(new Error('Captcha not supported in this environment')); } + if (opts.captchaProvider === 'prosopo') { + return getProcaptchaToken(opts); + } + return getTurnstileToken(opts); }; diff --git a/packages/clerk-js/src/utils/captcha/prosopo.ts b/packages/clerk-js/src/utils/captcha/prosopo.ts new file mode 100644 index 00000000000..e8418163ba5 --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/prosopo.ts @@ -0,0 +1,217 @@ +import { waitForElement } from '@clerk/shared/dom'; +import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants'; +import { loadScript } from '@clerk/shared/loadScript'; +import type { CaptchaWidgetType } from '@clerk/shared/types'; + +import type { CaptchaOptions } from './types'; + +// We use the explicit render mode so we control when the widget mounts. +// Prosopo docs: https://docs.prosopo.io/en/js/api-reference/javascript-api-reference.html +const PROSOPO_BUNDLE_URL = 'https://js.prosopo.io/js/procaptcha.bundle.js?render=explicit'; + +type ProcaptchaTheme = 'light' | 'dark'; + +type ProcaptchaRenderOptions = { + siteKey: string; + theme?: ProcaptchaTheme; + callback?: (token: string) => void; + 'error-callback'?: () => void; + 'expired-callback'?: () => void; + 'close-callback'?: () => void; + size?: 'invisible'; + language?: string; +}; + +type ProcaptchaApi = { + render: (element: Element, options: ProcaptchaRenderOptions) => Promise | void; + reset: () => void; + execute: () => void; + ready: (fn: () => void) => void; +}; + +declare global { + export interface Window { + procaptcha: ProcaptchaApi; + } +} + +async function loadProcaptcha(nonce?: string) { + if (!window.procaptcha) { + await loadProcaptchaFromUrl(nonce).catch(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw { captchaError: 'captcha_script_failed_to_load' }; + }); + } + return window.procaptcha; +} + +async function loadProcaptchaFromUrl(nonce?: string) { + try { + if (__BUILD_DISABLE_RHC__) { + return Promise.reject(new Error('Captcha not supported in this environment')); + } + + return await loadScript(PROSOPO_BUNDLE_URL, { defer: true, nonce }); + } catch (err) { + console.warn( + 'Clerk: Failed to load the CAPTCHA script from Prosopo. If you see a CSP error in your browser, please add the necessary CSP rules to your app. Visit https://clerk.com/docs/security/clerk-csp for more information.', + ); + throw err; + } +} + +function getCaptchaAttibutesFromElemenet(element: HTMLElement) { + try { + const rawTheme = element.getAttribute('data-cl-theme') || undefined; + const theme: ProcaptchaTheme | undefined = rawTheme === 'dark' || rawTheme === 'light' ? rawTheme : undefined; + const language = element.getAttribute('data-cl-language') || undefined; + return { theme, language }; + } catch { + return { theme: undefined, language: undefined }; + } +} + +/* + * Mirrors getTurnstileToken: renders an invisible Procaptcha widget by default, + * or a smart widget inside the user-provided #clerk-captcha container when present. + */ +export const getProcaptchaToken = async (opts: CaptchaOptions) => { + const { siteKey, widgetType, invisibleSiteKey, nonce } = opts; + const { modalContainerQuerySelector, modalWrapperQuerySelector, closeModal, openModal } = opts; + const captcha = await loadProcaptcha(nonce); + + let captchaToken = ''; + let prosopoSiteKey = siteKey; + let captchaTheme: ProcaptchaTheme | undefined; + let captchaLanguage: string | undefined; + let widgetContainerQuerySelector: string | undefined; + // The backend uses this to determine which site-key was used in order to verify the token + let captchaWidgetType: CaptchaWidgetType = null; + let captchaTypeUsed: 'invisible' | 'modal' | 'smart' = 'invisible'; + + // modal + if (modalContainerQuerySelector && modalWrapperQuerySelector) { + captchaWidgetType = widgetType; + widgetContainerQuerySelector = modalContainerQuerySelector; + captchaTypeUsed = 'modal'; + try { + await openModal?.(); + } catch { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw { captchaError: 'modal_component_not_ready' }; + } + const modalContainderEl = await waitForElement(modalContainerQuerySelector); + if (modalContainderEl) { + const { theme, language } = getCaptchaAttibutesFromElemenet(modalContainderEl); + captchaTheme = theme; + captchaLanguage = language; + } + } + + // smart widget with container provided by user + if (!widgetContainerQuerySelector && widgetType === 'smart') { + const visibleDiv = document.getElementById(CAPTCHA_ELEMENT_ID); + if (visibleDiv) { + captchaTypeUsed = 'smart'; + captchaWidgetType = 'smart'; + widgetContainerQuerySelector = `#${CAPTCHA_ELEMENT_ID}`; + // Procaptcha's smart widget is visible from the start, unlike Turnstile's interaction-only mode. + // Reserve a reasonable min-height to reduce layout shift while the widget renders. + visibleDiv.style.minHeight = '78px'; + visibleDiv.style.marginBottom = '1.5rem'; + const { theme, language } = getCaptchaAttibutesFromElemenet(visibleDiv); + captchaTheme = theme; + captchaLanguage = language; + } 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/guides/development/custom-flows/authentication/bot-sign-up-protection for instructions', + ); + } + } + + // invisible widget for which we create the container automatically + if (!widgetContainerQuerySelector) { + captchaTypeUsed = 'invisible'; + prosopoSiteKey = invisibleSiteKey; + captchaWidgetType = 'invisible'; + widgetContainerQuerySelector = `.${CAPTCHA_INVISIBLE_CLASSNAME}`; + const div = document.createElement('div'); + div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); + div.style.display = 'none'; + document.body.appendChild(div); + } + + const handleCaptchaTokenGeneration = async (): Promise => { + const containerEl = widgetContainerQuerySelector ? document.querySelector(widgetContainerQuerySelector) : null; + if (!containerEl) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'captcha_container_missing'; + } + + return new Promise((resolve, reject) => { + const renderOptions: ProcaptchaRenderOptions = { + siteKey: prosopoSiteKey, + theme: captchaTheme, + language: captchaLanguage, + callback: (token: string) => { + closeModal?.(); + resolve(token); + }, + 'error-callback': () => { + reject('procaptcha_error'); + }, + 'expired-callback': () => { + reject('procaptcha_expired'); + }, + }; + + if (captchaTypeUsed === 'invisible') { + renderOptions.size = 'invisible'; + } + + Promise.resolve(captcha.render(containerEl, renderOptions)) + .then(() => { + // Invisible widgets do not auto-challenge — execute() dispatches the event + // the bundle's invisible component is listening for once it has mounted. + if (captchaTypeUsed === 'invisible') { + captcha.execute(); + } + }) + .catch(e => reject(e)); + }); + }; + + try { + captchaToken = await handleCaptchaTokenGeneration(); + } catch (e) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw { + captchaError: typeof e === 'string' ? e : (e as Error)?.message || 'unexpected_captcha_error', + }; + } finally { + // cleanup + try { + captcha.reset(); + } catch { + // best-effort cleanup + } + if (captchaTypeUsed === 'modal') { + closeModal?.(); + } + if (captchaTypeUsed === 'invisible') { + const invisibleWidget = document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`); + if (invisibleWidget) { + document.body.removeChild(invisibleWidget); + } + } + if (captchaTypeUsed === 'smart') { + const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID); + if (visibleWidget) { + visibleWidget.style.minHeight = 'unset'; + visibleWidget.style.marginBottom = 'unset'; + } + } + } + + return { captchaToken, captchaWidgetType }; +}; diff --git a/packages/shared/src/types/displayConfig.ts b/packages/shared/src/types/displayConfig.ts index b5fce950ebc..0124f024bc6 100644 --- a/packages/shared/src/types/displayConfig.ts +++ b/packages/shared/src/types/displayConfig.ts @@ -5,7 +5,7 @@ import type { OAuthStrategy } from './strategies'; export type PreferredSignInStrategy = 'password' | 'otp'; export type CaptchaWidgetType = 'smart' | 'invisible' | null; -export type CaptchaProvider = 'turnstile'; +export type CaptchaProvider = 'turnstile' | 'prosopo'; export interface DisplayConfigJSON { object: 'display_config'; From 547f04f567a1e252af82e4b0eeda8619c1a4fbcd Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Mon, 22 Jun 2026 16:58:15 +0100 Subject: [PATCH 2/4] refactor(js): extract shared captcha container resolver + expand tests Pull the modal/smart/invisible container decision out of turnstile.ts and prosopo.ts into a single resolveCaptchaContainer helper, plus a matching cleanupCaptchaContainer for the side effects (modal close, invisible div removal). Each provider keeps its own visual styling (Turnstile's maxHeight/min-height dance, Prosopo's reserved height) and its own render() call. Also: - Point the Procaptcha load-failure warning at js.prosopo.io + Prosopo CSP docs rather than the Cloudflare-flavored guidance. - Add router test for getCaptchaToken (turnstile vs prosopo dispatch). - Add CaptchaChallenge test that .invisible() and .managedOrInvisible() both honour displayConfig.captchaProvider. - Expand prosopo.test.ts with smart, modal, modal-not-ready, error- callback, expired-callback, and script-load-failure cases. Validates the warning now points at Prosopo's CSP docs. 15 captcha tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/CaptchaChallenge.test.ts | 60 ++++ .../captcha/__tests__/getCaptchaToken.test.ts | 45 +++ .../utils/captcha/__tests__/prosopo.test.ts | 295 +++++++++++++++--- .../src/utils/captcha/containerResolver.ts | 123 ++++++++ .../clerk-js/src/utils/captcha/prosopo.ts | 123 +++----- .../clerk-js/src/utils/captcha/turnstile.ts | 118 ++----- 6 files changed, 536 insertions(+), 228 deletions(-) create mode 100644 packages/clerk-js/src/utils/captcha/__tests__/CaptchaChallenge.test.ts create mode 100644 packages/clerk-js/src/utils/captcha/__tests__/getCaptchaToken.test.ts create mode 100644 packages/clerk-js/src/utils/captcha/containerResolver.ts diff --git a/packages/clerk-js/src/utils/captcha/__tests__/CaptchaChallenge.test.ts b/packages/clerk-js/src/utils/captcha/__tests__/CaptchaChallenge.test.ts new file mode 100644 index 00000000000..c261505239e --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/__tests__/CaptchaChallenge.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../getCaptchaToken', () => ({ + getCaptchaToken: vi.fn(async () => ({ captchaToken: 'mock_token', captchaWidgetType: 'invisible' })), +})); + +import { CaptchaChallenge } from '../CaptchaChallenge'; +import { getCaptchaToken } from '../getCaptchaToken'; + +const makeClerk = (provider: 'turnstile' | 'prosopo') => + ({ + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaProvider: provider, + captchaPublicKey: 'visible-key', + captchaPublicKeyInvisible: 'invisible-key', + captchaWidgetType: 'smart' as const, + }, + userSettings: { signUp: { captcha_enabled: true } }, + }, + __internal_getOption: () => undefined, + }) as any; + +describe('CaptchaChallenge propagates displayConfig.captchaProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('invisible() forwards "prosopo" when configured', async () => { + const challenge = new CaptchaChallenge(makeClerk('prosopo')); + + await challenge.invisible(); + + expect(getCaptchaToken).toHaveBeenCalledTimes(1); + expect((getCaptchaToken as any).mock.calls[0][0]).toMatchObject({ captchaProvider: 'prosopo' }); + }); + + it('invisible() forwards "turnstile" when configured (default)', async () => { + const challenge = new CaptchaChallenge(makeClerk('turnstile')); + + await challenge.invisible(); + + expect(getCaptchaToken).toHaveBeenCalledTimes(1); + expect((getCaptchaToken as any).mock.calls[0][0]).toMatchObject({ captchaProvider: 'turnstile' }); + }); + + it('managedOrInvisible() forwards the configured provider', async () => { + const challenge = new CaptchaChallenge(makeClerk('prosopo')); + + await challenge.managedOrInvisible({ action: 'verify' }); + + expect(getCaptchaToken).toHaveBeenCalledTimes(1); + expect((getCaptchaToken as any).mock.calls[0][0]).toMatchObject({ captchaProvider: 'prosopo' }); + }); +}); diff --git a/packages/clerk-js/src/utils/captcha/__tests__/getCaptchaToken.test.ts b/packages/clerk-js/src/utils/captcha/__tests__/getCaptchaToken.test.ts new file mode 100644 index 00000000000..ef6f4208227 --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/__tests__/getCaptchaToken.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getCaptchaToken } from '../getCaptchaToken'; + +vi.mock('../turnstile', () => ({ + getTurnstileToken: vi.fn(async () => ({ captchaToken: 'turnstile_token', captchaWidgetType: 'invisible' })), +})); +vi.mock('../prosopo', () => ({ + getProcaptchaToken: vi.fn(async () => ({ captchaToken: 'procaptcha_token', captchaWidgetType: 'invisible' })), +})); + +import { getProcaptchaToken } from '../prosopo'; +import { getTurnstileToken } from '../turnstile'; + +describe('getCaptchaToken provider routing', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('routes to Procaptcha when captchaProvider is "prosopo"', async () => { + const result = await getCaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible', + invisibleSiteKey: 'invisible', + widgetType: 'invisible', + }); + + expect(getProcaptchaToken).toHaveBeenCalledTimes(1); + expect(getTurnstileToken).not.toHaveBeenCalled(); + expect(result).toEqual({ captchaToken: 'procaptcha_token', captchaWidgetType: 'invisible' }); + }); + + it('routes to Turnstile when captchaProvider is "turnstile"', async () => { + const result = await getCaptchaToken({ + captchaProvider: 'turnstile', + siteKey: 'visible', + invisibleSiteKey: 'invisible', + widgetType: 'invisible', + }); + + expect(getTurnstileToken).toHaveBeenCalledTimes(1); + expect(getProcaptchaToken).not.toHaveBeenCalled(); + expect(result).toEqual({ captchaToken: 'turnstile_token', captchaWidgetType: 'invisible' }); + }); +}); diff --git a/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts b/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts index 4483f0f3dd8..cf5a0b9fc9f 100644 --- a/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts +++ b/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts @@ -1,61 +1,276 @@ import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('@clerk/shared/loadScript', () => ({ + loadScript: vi.fn(async () => ({}) as any), +})); + +import { loadScript } from '@clerk/shared/loadScript'; + import { getProcaptchaToken } from '../prosopo'; +type RenderCall = { element: Element; options: Record }; + +const installProcaptchaMock = (renderImpl?: (call: RenderCall) => void) => { + const calls: RenderCall[] = []; + (window as any).procaptcha = { + render: vi.fn((element: Element, options: Record) => { + const call: RenderCall = { element, options }; + calls.push(call); + renderImpl?.(call); + return Promise.resolve(); + }), + reset: vi.fn(), + execute: vi.fn(), + ready: vi.fn(), + }; + return calls; +}; + /** - * Mirrors the Turnstile regression guard: the inline `#clerk-captcha` "spotlight" - * keys off `style.maxHeight !== '0'`, so the common invisible flow must never - * mount Procaptcha into `#clerk-captcha`. + * Regression guard for the inline "spotlight": the spotlight keys off `#clerk-captcha`'s + * `style.maxHeight !== '0'`. The common invisible flow (~99% of challenges) must never create or + * mutate `#clerk-captcha`, so its `maxHeight` stays `'0'` and no spotlight is ever triggered. */ -describe('getProcaptchaToken — invisible flow', () => { - let renderArgs: { element: Element; options: Record } | undefined; - - beforeEach(() => { - renderArgs = undefined; - (window as any).procaptcha = { - render: vi.fn((element: Element, options: Record) => { - renderArgs = { element, options }; - setTimeout(() => options.callback('mock_token'), 0); - return Promise.resolve(); - }), - reset: vi.fn(), - execute: vi.fn(), - ready: vi.fn(), - }; - }); - +describe('getProcaptchaToken', () => { afterEach(() => { document.body.innerHTML = ''; delete (window as any).procaptcha; vi.restoreAllMocks(); }); - it('renders into its own throwaway container, never #clerk-captcha', async () => { - const inline = document.createElement('div'); - inline.id = CAPTCHA_ELEMENT_ID; - inline.style.maxHeight = '0'; - document.body.appendChild(inline); + describe('invisible flow', () => { + beforeEach(() => { + installProcaptchaMock(call => { + setTimeout(() => call.options.callback('mock_token'), 0); + }); + }); + + it('renders into its own throwaway container, never #clerk-captcha', async () => { + const inline = document.createElement('div'); + inline.id = CAPTCHA_ELEMENT_ID; + inline.style.maxHeight = '0'; + document.body.appendChild(inline); + + const result = await getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + }); + + expect(result).toEqual({ captchaToken: 'mock_token', captchaWidgetType: 'invisible' }); + + const renderMock = (window as any).procaptcha.render as ReturnType; + expect(renderMock).toHaveBeenCalledTimes(1); + const [containerArg, optionsArg] = renderMock.mock.calls[0]; + expect((containerArg as HTMLElement).classList.contains(CAPTCHA_INVISIBLE_CLASSNAME)).toBe(true); + expect(optionsArg.siteKey).toBe('invisible-key'); + expect(optionsArg.size).toBe('invisible'); + + // Spotlight signal on #clerk-captcha is untouched throughout. + expect(inline.style.maxHeight || '0').toBe('0'); + + // Temporary container is cleaned up. + expect(document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toBeNull(); + + // execute() is required to start the challenge for invisible widgets. + expect((window as any).procaptcha.execute).toHaveBeenCalledTimes(1); + }); + + it('falls back to invisible when widgetType is "smart" but #clerk-captcha is absent', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'smart', + }); + + expect(result.captchaWidgetType).toBe('invisible'); + const optionsArg = ((window as any).procaptcha.render as any).mock.calls[0][1]; + expect(optionsArg.siteKey).toBe('invisible-key'); + expect(optionsArg.size).toBe('invisible'); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('smart flow', () => { + it('renders into #clerk-captcha with the visible site key, no invisible container', async () => { + installProcaptchaMock(call => { + setTimeout(() => call.options.callback('smart_token'), 0); + }); - const result = await getProcaptchaToken({ - captchaProvider: 'prosopo', - siteKey: 'visible-key', - invisibleSiteKey: 'invisible-key', - widgetType: 'invisible', + const inline = document.createElement('div'); + inline.id = CAPTCHA_ELEMENT_ID; + inline.setAttribute('data-cl-theme', 'dark'); + document.body.appendChild(inline); + + const result = await getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'smart', + }); + + expect(result).toEqual({ captchaToken: 'smart_token', captchaWidgetType: 'smart' }); + + const renderMock = (window as any).procaptcha.render as ReturnType; + const [containerArg, optionsArg] = renderMock.mock.calls[0]; + expect((containerArg as HTMLElement).id).toBe(CAPTCHA_ELEMENT_ID); + expect(optionsArg.siteKey).toBe('visible-key'); + expect(optionsArg.theme).toBe('dark'); + expect(optionsArg.size).toBeUndefined(); + + // Smart widgets are not triggered through execute(). + expect((window as any).procaptcha.execute).not.toHaveBeenCalled(); + + // No invisible container leaked. + expect(document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toBeNull(); + + // Smart-flow inline styles are reverted on cleanup. + expect(inline.style.minHeight).toBe('unset'); + expect(inline.style.marginBottom).toBe('unset'); }); - expect(result).toEqual({ captchaToken: 'mock_token', captchaWidgetType: 'invisible' }); + it('drops invalid theme attributes silently', async () => { + installProcaptchaMock(call => setTimeout(() => call.options.callback('t'), 0)); - // The invisible flow renders into the dedicated `.clerk-invisible-captcha` div with the invisible key. - expect((window as any).procaptcha.render).toHaveBeenCalledTimes(1); - expect((renderArgs?.element as HTMLElement).classList.contains(CAPTCHA_INVISIBLE_CLASSNAME)).toBe(true); - expect(renderArgs?.options.siteKey).toBe('invisible-key'); - expect(renderArgs?.options.size).toBe('invisible'); + const inline = document.createElement('div'); + inline.id = CAPTCHA_ELEMENT_ID; + inline.setAttribute('data-cl-theme', 'rainbow'); + document.body.appendChild(inline); - // The spotlight signal on #clerk-captcha is untouched throughout → no spotlight. - expect(inline.style.maxHeight || '0').toBe('0'); + await getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'smart', + }); - // The temporary invisible container is created then cleaned up in `finally`. - expect(document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toBeNull(); + const optionsArg = ((window as any).procaptcha.render as any).mock.calls[0][1]; + expect(optionsArg.theme).toBeUndefined(); + }); + }); + + describe('modal flow', () => { + it('opens the modal, renders into the modal container, and closes the modal on success', async () => { + installProcaptchaMock(call => setTimeout(() => call.options.callback('modal_token'), 0)); + + const wrapper = document.createElement('div'); + wrapper.id = 'cl-modal-captcha-wrapper'; + const container = document.createElement('div'); + container.id = 'cl-modal-captcha-container'; + document.body.append(wrapper, container); + + const openModal = vi.fn(async () => undefined); + const closeModal = vi.fn(async () => undefined); + + const result = await getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + modalContainerQuerySelector: '#cl-modal-captcha-container', + modalWrapperQuerySelector: '#cl-modal-captcha-wrapper', + openModal, + closeModal, + }); + + expect(openModal).toHaveBeenCalledTimes(1); + // Once from the callback, once from the finally-cleanup. + expect(closeModal).toHaveBeenCalled(); + expect(result.captchaWidgetType).toBe('invisible'); + + const [containerArg, optionsArg] = ((window as any).procaptcha.render as any).mock.calls[0]; + expect((containerArg as HTMLElement).id).toBe('cl-modal-captcha-container'); + // Modal flow uses the (visible) siteKey, not the invisible fallback. + expect(optionsArg.siteKey).toBe('visible-key'); + }); + + it('throws modal_component_not_ready when openModal rejects', async () => { + installProcaptchaMock(); + + const wrapper = document.createElement('div'); + wrapper.id = 'cl-modal-captcha-wrapper'; + const container = document.createElement('div'); + container.id = 'cl-modal-captcha-container'; + document.body.append(wrapper, container); + + const openModal = vi.fn(async () => { + throw new Error('not ready'); + }); + + await expect( + getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + modalContainerQuerySelector: '#cl-modal-captcha-container', + modalWrapperQuerySelector: '#cl-modal-captcha-wrapper', + openModal, + }), + ).rejects.toMatchObject({ captchaError: 'modal_component_not_ready' }); + + expect((window as any).procaptcha.render).not.toHaveBeenCalled(); + }); + }); + + describe('rejection paths', () => { + it('rejects with procaptcha_error when error-callback fires', async () => { + installProcaptchaMock(call => { + setTimeout(() => call.options['error-callback'](), 0); + }); + + await expect( + getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + }), + ).rejects.toMatchObject({ captchaError: 'procaptcha_error' }); + + // Invisible container still cleaned up after error. + expect(document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toBeNull(); + }); + + it('rejects with procaptcha_expired when expired-callback fires', async () => { + installProcaptchaMock(call => { + setTimeout(() => call.options['expired-callback'](), 0); + }); + + await expect( + getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + }), + ).rejects.toMatchObject({ captchaError: 'procaptcha_expired' }); + }); + + it('rejects with captcha_script_failed_to_load when the bundle fails to load', async () => { + // Ensure procaptcha is not on window so the load path is exercised. + delete (window as any).procaptcha; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + (loadScript as any).mockRejectedValueOnce(new Error('network')); + + await expect( + getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + }), + ).rejects.toMatchObject({ captchaError: 'captcha_script_failed_to_load' }); + + // Surface the Prosopo CSP doc URL, not Cloudflare's. + expect(warnSpy.mock.calls[0]?.[0]).toMatch(/js\.prosopo\.io/); + + warnSpy.mockRestore(); + }); }); }); diff --git a/packages/clerk-js/src/utils/captcha/containerResolver.ts b/packages/clerk-js/src/utils/captcha/containerResolver.ts new file mode 100644 index 00000000000..27ce07f177e --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/containerResolver.ts @@ -0,0 +1,123 @@ +import { waitForElement } from '@clerk/shared/dom'; +import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants'; +import type { CaptchaWidgetType } from '@clerk/shared/types'; + +import type { CaptchaOptions } from './types'; + +export type CaptchaContainerType = 'invisible' | 'modal' | 'smart'; + +export type CaptchaContainerAttributes = { + theme?: string; + language?: string; + size?: string; +}; + +export type ResolvedCaptchaContainer = { + containerSelector: string; + containerType: CaptchaContainerType; + /** The site key the provider should render with (smart/modal use siteKey, invisible falls back to invisibleSiteKey). */ + effectiveSiteKey: string; + /** What the backend should be told the token was minted from. */ + captchaWidgetType: CaptchaWidgetType; + attributes: CaptchaContainerAttributes; +}; + +function readContainerAttributes(element: Element): CaptchaContainerAttributes { + try { + const el = element as HTMLElement; + return { + theme: el.getAttribute('data-cl-theme') || undefined, + language: el.getAttribute('data-cl-language') || undefined, + size: el.getAttribute('data-cl-size') || undefined, + }; + } catch { + return { theme: undefined, language: undefined, size: undefined }; + } +} + +/** + * Decides which DOM container to render the CAPTCHA into. The decision tree is shared between + * every CAPTCHA provider so the surface a provider must implement stays a thin render(element, options) + * call and any provider-specific styling. + * + * Order of precedence: modal > smart > invisible. Smart falls back to invisible when the consumer + * has not mounted a `#clerk-captcha` element. + * + * Side effects: opens the modal (when modal selectors are passed) and appends a hidden + * `.clerk-invisible-captcha` div to the body for the invisible flow. Both are reverted by + * {@link cleanupCaptchaContainer}. + */ +export const resolveCaptchaContainer = async (opts: CaptchaOptions): Promise => { + const { modalContainerQuerySelector, modalWrapperQuerySelector, openModal } = opts; + + // modal: an invisible widget rendered inside Clerk's modal — it will only escalate to interactive if needed. + if (modalContainerQuerySelector && modalWrapperQuerySelector) { + try { + await openModal?.(); + } catch { + // When a client is captcha_block the first attempt to open the modal will fail with + // 'ClerkJS components are not ready yet.' (initComponents races the first /client response). + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw { captchaError: 'modal_component_not_ready' }; + } + const el = await waitForElement(modalContainerQuerySelector); + return { + containerSelector: modalContainerQuerySelector, + containerType: 'modal', + effectiveSiteKey: opts.siteKey, + captchaWidgetType: opts.widgetType, + attributes: el ? readContainerAttributes(el) : {}, + }; + } + + // smart: render into the consumer-provided #clerk-captcha element when present. + if (opts.widgetType === 'smart') { + const visibleDiv = document.getElementById(CAPTCHA_ELEMENT_ID); + if (visibleDiv) { + return { + containerSelector: `#${CAPTCHA_ELEMENT_ID}`, + containerType: 'smart', + effectiveSiteKey: opts.siteKey, + captchaWidgetType: 'smart', + attributes: readContainerAttributes(visibleDiv), + }; + } + 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/guides/development/custom-flows/authentication/bot-sign-up-protection for instructions', + ); + } + + // invisible (default + smart fallback): create a hidden throwaway container. + const div = document.createElement('div'); + div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); + div.style.display = 'none'; + document.body.appendChild(div); + return { + containerSelector: `.${CAPTCHA_INVISIBLE_CLASSNAME}`, + containerType: 'invisible', + effectiveSiteKey: opts.invisibleSiteKey, + captchaWidgetType: 'invisible', + attributes: {}, + }; +}; + +/** + * Reverts the DOM side effects of {@link resolveCaptchaContainer}. The smart container is owned by + * the consumer (we leave it in place); provider-specific style mutations should be cleaned up by + * the caller before invoking this. + */ +export const cleanupCaptchaContainer = ( + containerType: CaptchaContainerType, + opts: Pick, +) => { + if (containerType === 'modal') { + opts.closeModal?.(); + return; + } + if (containerType === 'invisible') { + const invisibleWidget = document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`); + if (invisibleWidget && invisibleWidget.parentNode === document.body) { + document.body.removeChild(invisibleWidget); + } + } +}; diff --git a/packages/clerk-js/src/utils/captcha/prosopo.ts b/packages/clerk-js/src/utils/captcha/prosopo.ts index e8418163ba5..f8f5b14616c 100644 --- a/packages/clerk-js/src/utils/captcha/prosopo.ts +++ b/packages/clerk-js/src/utils/captcha/prosopo.ts @@ -1,14 +1,16 @@ -import { waitForElement } from '@clerk/shared/dom'; -import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants'; +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { loadScript } from '@clerk/shared/loadScript'; -import type { CaptchaWidgetType } from '@clerk/shared/types'; +import { cleanupCaptchaContainer, resolveCaptchaContainer } from './containerResolver'; import type { CaptchaOptions } from './types'; // We use the explicit render mode so we control when the widget mounts. // Prosopo docs: https://docs.prosopo.io/en/js/api-reference/javascript-api-reference.html const PROSOPO_BUNDLE_URL = 'https://js.prosopo.io/js/procaptcha.bundle.js?render=explicit'; +// Reserve space for the rendered smart widget to avoid layout shift; matches Procaptcha's frictionless widget height. +const PROSOPO_SMART_MIN_HEIGHT = '78px'; + type ProcaptchaTheme = 'light' | 'dark'; type ProcaptchaRenderOptions = { @@ -54,95 +56,45 @@ async function loadProcaptchaFromUrl(nonce?: string) { return await loadScript(PROSOPO_BUNDLE_URL, { defer: true, nonce }); } catch (err) { console.warn( - 'Clerk: Failed to load the CAPTCHA script from Prosopo. If you see a CSP error in your browser, please add the necessary CSP rules to your app. Visit https://clerk.com/docs/security/clerk-csp for more information.', + 'Clerk: Failed to load the CAPTCHA script from Prosopo (js.prosopo.io). If you see a CSP error in your browser, ensure your `script-src`, `connect-src`, and `frame-src` directives include `https://*.prosopo.io`. See https://docs.prosopo.io/en/integrations/csp.html for the full list of Prosopo CSP directives, and https://clerk.com/docs/security/clerk-csp for Clerk-side guidance.', ); throw err; } } -function getCaptchaAttibutesFromElemenet(element: HTMLElement) { - try { - const rawTheme = element.getAttribute('data-cl-theme') || undefined; - const theme: ProcaptchaTheme | undefined = rawTheme === 'dark' || rawTheme === 'light' ? rawTheme : undefined; - const language = element.getAttribute('data-cl-language') || undefined; - return { theme, language }; - } catch { - return { theme: undefined, language: undefined }; - } +function narrowTheme(theme: string | undefined): ProcaptchaTheme | undefined { + return theme === 'dark' || theme === 'light' ? theme : undefined; } /* - * Mirrors getTurnstileToken: renders an invisible Procaptcha widget by default, - * or a smart widget inside the user-provided #clerk-captcha container when present. + * Mirrors getTurnstileToken via the shared container resolver: renders an invisible Procaptcha + * widget by default, or a smart widget inside the consumer-provided #clerk-captcha element when + * present. Note that Procaptcha's render() takes an Element rather than a selector — we resolve + * the Element here. */ export const getProcaptchaToken = async (opts: CaptchaOptions) => { - const { siteKey, widgetType, invisibleSiteKey, nonce } = opts; - const { modalContainerQuerySelector, modalWrapperQuerySelector, closeModal, openModal } = opts; - const captcha = await loadProcaptcha(nonce); + const { closeModal } = opts; + const captcha = await loadProcaptcha(opts.nonce); - let captchaToken = ''; - let prosopoSiteKey = siteKey; - let captchaTheme: ProcaptchaTheme | undefined; - let captchaLanguage: string | undefined; - let widgetContainerQuerySelector: string | undefined; - // The backend uses this to determine which site-key was used in order to verify the token - let captchaWidgetType: CaptchaWidgetType = null; - let captchaTypeUsed: 'invisible' | 'modal' | 'smart' = 'invisible'; - - // modal - if (modalContainerQuerySelector && modalWrapperQuerySelector) { - captchaWidgetType = widgetType; - widgetContainerQuerySelector = modalContainerQuerySelector; - captchaTypeUsed = 'modal'; - try { - await openModal?.(); - } catch { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw { captchaError: 'modal_component_not_ready' }; - } - const modalContainderEl = await waitForElement(modalContainerQuerySelector); - if (modalContainderEl) { - const { theme, language } = getCaptchaAttibutesFromElemenet(modalContainderEl); - captchaTheme = theme; - captchaLanguage = language; - } - } + const resolved = await resolveCaptchaContainer(opts); + const { containerSelector, containerType, effectiveSiteKey, captchaWidgetType, attributes } = resolved; + const captchaTheme = narrowTheme(attributes.theme); + const captchaLanguage = attributes.language; - // smart widget with container provided by user - if (!widgetContainerQuerySelector && widgetType === 'smart') { + // Reserve space for the smart widget; Procaptcha shows its widget from the start so we cannot + // hide it the way Turnstile does with maxHeight=0. + if (containerType === 'smart') { const visibleDiv = document.getElementById(CAPTCHA_ELEMENT_ID); if (visibleDiv) { - captchaTypeUsed = 'smart'; - captchaWidgetType = 'smart'; - widgetContainerQuerySelector = `#${CAPTCHA_ELEMENT_ID}`; - // Procaptcha's smart widget is visible from the start, unlike Turnstile's interaction-only mode. - // Reserve a reasonable min-height to reduce layout shift while the widget renders. - visibleDiv.style.minHeight = '78px'; + visibleDiv.style.minHeight = PROSOPO_SMART_MIN_HEIGHT; visibleDiv.style.marginBottom = '1.5rem'; - const { theme, language } = getCaptchaAttibutesFromElemenet(visibleDiv); - captchaTheme = theme; - captchaLanguage = language; - } 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/guides/development/custom-flows/authentication/bot-sign-up-protection for instructions', - ); } } - // invisible widget for which we create the container automatically - if (!widgetContainerQuerySelector) { - captchaTypeUsed = 'invisible'; - prosopoSiteKey = invisibleSiteKey; - captchaWidgetType = 'invisible'; - widgetContainerQuerySelector = `.${CAPTCHA_INVISIBLE_CLASSNAME}`; - const div = document.createElement('div'); - div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); - div.style.display = 'none'; - document.body.appendChild(div); - } + let captchaToken = ''; const handleCaptchaTokenGeneration = async (): Promise => { - const containerEl = widgetContainerQuerySelector ? document.querySelector(widgetContainerQuerySelector) : null; + const containerEl = document.querySelector(containerSelector); if (!containerEl) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw 'captcha_container_missing'; @@ -150,7 +102,7 @@ export const getProcaptchaToken = async (opts: CaptchaOptions) => { return new Promise((resolve, reject) => { const renderOptions: ProcaptchaRenderOptions = { - siteKey: prosopoSiteKey, + siteKey: effectiveSiteKey, theme: captchaTheme, language: captchaLanguage, callback: (token: string) => { @@ -158,6 +110,8 @@ export const getProcaptchaToken = async (opts: CaptchaOptions) => { resolve(token); }, 'error-callback': () => { + // Procaptcha's error-callback signature does not include an error code; emit a stable + // identifier so callers can branch on it. reject('procaptcha_error'); }, 'expired-callback': () => { @@ -165,15 +119,15 @@ export const getProcaptchaToken = async (opts: CaptchaOptions) => { }, }; - if (captchaTypeUsed === 'invisible') { + if (containerType === 'invisible') { renderOptions.size = 'invisible'; } Promise.resolve(captcha.render(containerEl, renderOptions)) .then(() => { - // Invisible widgets do not auto-challenge — execute() dispatches the event - // the bundle's invisible component is listening for once it has mounted. - if (captchaTypeUsed === 'invisible') { + // Invisible widgets do not auto-challenge — execute() dispatches the event the bundle's + // invisible component is listening for once it has mounted. + if (containerType === 'invisible') { captcha.execute(); } }) @@ -189,28 +143,21 @@ export const getProcaptchaToken = async (opts: CaptchaOptions) => { captchaError: typeof e === 'string' ? e : (e as Error)?.message || 'unexpected_captcha_error', }; } finally { - // cleanup + // The bundle's reset() unmounts every Procaptcha root it has registered, including ours. try { captcha.reset(); } catch { // best-effort cleanup } - if (captchaTypeUsed === 'modal') { - closeModal?.(); - } - if (captchaTypeUsed === 'invisible') { - const invisibleWidget = document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`); - if (invisibleWidget) { - document.body.removeChild(invisibleWidget); - } - } - if (captchaTypeUsed === 'smart') { + // Revert smart-flow style mutations before delegating to the shared cleanup. + if (containerType === 'smart') { const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID); if (visibleWidget) { visibleWidget.style.minHeight = 'unset'; visibleWidget.style.marginBottom = 'unset'; } } + cleanupCaptchaContainer(containerType, opts); } return { captchaToken, captchaWidgetType }; diff --git a/packages/clerk-js/src/utils/captcha/turnstile.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts index 228bfd85d8c..16c5bcc7609 100644 --- a/packages/clerk-js/src/utils/captcha/turnstile.ts +++ b/packages/clerk-js/src/utils/captcha/turnstile.ts @@ -1,21 +1,13 @@ -import { waitForElement } from '@clerk/shared/dom'; -import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants'; +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { loadScript } from '@clerk/shared/loadScript'; -import type { CaptchaWidgetType } from '@clerk/shared/types'; +import { cleanupCaptchaContainer, resolveCaptchaContainer } from './containerResolver'; import type { CaptchaOptions } from './types'; // We use the explicit render mode to be able to control when the widget is rendered. // CF docs: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#disable-implicit-rendering const CLOUDFLARE_TURNSTILE_ORIGINAL_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; -// These belong to `clerk/ui` now -type CaptchaAttributes = { - theme?: unknown; - language?: unknown; - size: unknown; -}; - declare global { export interface Window { turnstile: Turnstile.Turnstile; @@ -52,18 +44,6 @@ async function loadCaptchaFromCloudflareURL(nonce?: string) { } } -function getCaptchaAttibutesFromElemenet(element: HTMLElement): CaptchaAttributes { - try { - const theme = element.getAttribute('data-cl-theme') || undefined; - const language = element.getAttribute('data-cl-language') || undefined; - const size = element.getAttribute('data-cl-size') || undefined; - - return { theme, language, size }; - } catch { - return { theme: undefined, language: undefined, size: undefined }; - } -} - /* * How this function works: * The widgetType is either 'invisible' or 'smart'. @@ -72,88 +52,34 @@ function getCaptchaAttibutesFromElemenet(element: HTMLElement): CaptchaAttribute * 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 getTurnstileToken = async (opts: CaptchaOptions) => { - const { siteKey, widgetType, invisibleSiteKey, nonce } = opts; - const { modalContainerQuerySelector, modalWrapperQuerySelector, closeModal, openModal } = opts; - const captcha: Turnstile.Turnstile = await loadCaptcha(nonce); + const { modalWrapperQuerySelector, closeModal } = opts; + const captcha: Turnstile.Turnstile = await loadCaptcha(opts.nonce); const errorCodes: (string | number)[] = []; let captchaToken = ''; let id = ''; - let turnstileSiteKey = siteKey; - let captchaTheme: any; - let captchaSize: any; - let captchaLanguage: any; let retries = 0; - let widgetContainerQuerySelector: string | undefined; - // The backend uses this to determine which Turnstile site-key was used in order to verify the token - let captchaWidgetType: CaptchaWidgetType = null; - let captchaTypeUsed: 'invisible' | 'modal' | 'smart' = 'invisible'; - // modal - if (modalContainerQuerySelector && modalWrapperQuerySelector) { - // if invisible is selected but modal is provided, - // we're going to render the invisible widget in the modal - // but we won't show the modal as it will never escalate to interactive mode - captchaWidgetType = widgetType; - widgetContainerQuerySelector = modalContainerQuerySelector; - captchaTypeUsed = 'modal'; - try { - await openModal?.(); - } catch { - // When a client is captcha_block the first attempt to open the modal will fail with 'ClerkJS components are not ready yet.' - // This happens consistently in the first attempt, because in clerk.#loadInStandardBrowser we first await for the `/client` response - // and then we run initComponents to initialize the components. - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw { captchaError: 'modal_component_not_ready' }; - } - const modalContainderEl = await waitForElement(modalContainerQuerySelector); - if (modalContainderEl) { - const { theme, language, size } = getCaptchaAttibutesFromElemenet(modalContainderEl); - captchaTheme = theme; - captchaLanguage = language; - captchaSize = size; - } - } + const resolved = await resolveCaptchaContainer(opts); + const { containerSelector, containerType, effectiveSiteKey, captchaWidgetType, attributes } = resolved; + const { theme: captchaTheme, language: captchaLanguage, size: captchaSize } = attributes; - // smart widget with container provided by user - if (!widgetContainerQuerySelector && widgetType === 'smart') { + // Smart-flow pre-render: hide the consumer's #clerk-captcha until the widget asks to escalate. + if (containerType === 'smart') { const visibleDiv = document.getElementById(CAPTCHA_ELEMENT_ID); if (visibleDiv) { - captchaTypeUsed = 'smart'; - captchaWidgetType = 'smart'; - widgetContainerQuerySelector = `#${CAPTCHA_ELEMENT_ID}`; - visibleDiv.style.maxHeight = '0'; // This is to prevent the layout shift when the render method is called - const { theme, language, size } = getCaptchaAttibutesFromElemenet(visibleDiv); - captchaTheme = theme; - captchaLanguage = language; - captchaSize = size; - } 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/guides/development/custom-flows/authentication/bot-sign-up-protection for instructions', - ); + visibleDiv.style.maxHeight = '0'; } } - // invisible widget for which we create the container automatically - if (!widgetContainerQuerySelector) { - captchaTypeUsed = 'invisible'; - turnstileSiteKey = invisibleSiteKey; - captchaWidgetType = 'invisible'; - widgetContainerQuerySelector = `.${CAPTCHA_INVISIBLE_CLASSNAME}`; - const div = document.createElement('div'); - div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); - div.style.display = 'none'; // This is to prevent the layout shift when the render method is called - document.body.appendChild(div); - } - const handleCaptchaTokenGeneration = async (): Promise<[string, string]> => { return new Promise((resolve, reject) => { try { - const id = captcha.render(widgetContainerQuerySelector, { - sitekey: turnstileSiteKey, + const id = captcha.render(containerSelector, { + sitekey: effectiveSiteKey, appearance: 'interaction-only', - theme: captchaTheme || 'auto', - size: captchaSize || 'normal', + theme: (captchaTheme as Turnstile.RenderParameters['theme']) || 'auto', + size: (captchaSize as Turnstile.RenderParameters['size']) || 'normal', language: captchaLanguage || 'auto', action: opts.action, retry: 'never', @@ -189,7 +115,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { */ if (retries < 2 && shouldRetryTurnstileErrorCode(errorCode.toString())) { setTimeout(() => { - if (widgetContainerQuerySelector && !document.querySelector(widgetContainerQuerySelector)) { + if (containerSelector && !document.querySelector(containerSelector)) { reject([errorCodes.join(','), id]); return; } @@ -230,17 +156,8 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { captchaError: e, }; } finally { - // cleanup - if (captchaTypeUsed === 'modal') { - closeModal?.(); - } - if (captchaTypeUsed === 'invisible') { - const invisibleWidget = document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`); - if (invisibleWidget) { - document.body.removeChild(invisibleWidget); - } - } - if (captchaTypeUsed === 'smart') { + // Revert smart-flow style mutations before delegating to the shared cleanup. + if (containerType === 'smart') { const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID); if (visibleWidget) { delete visibleWidget.dataset.clInteractive; @@ -249,6 +166,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { visibleWidget.style.marginBottom = 'unset'; } } + cleanupCaptchaContainer(containerType, opts); } return { captchaToken, captchaWidgetType }; From b784d1e6845dc06093f8381b796a883479695355 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Mon, 22 Jun 2026 17:06:11 +0100 Subject: [PATCH 3/4] fix(js): propagate Procaptcha error-callback message The Procaptcha runtime calls error-callback with an Error built from the underlying challenge failure (procaptcha/modules/Manager.ts:193 and procaptcha-frictionless/ProcaptchaFrictionless.tsx:222,240 both call events.onError(new Error(message)), which defaultCallbacks.ts:129 forwards to the user-supplied error-callback). Earlier draft of this file assumed no argument was passed and rejected with a hardcoded 'procaptcha_error'. Type the callback the way the runtime actually behaves and surface error.message, with the previous identifier kept as a defensive fallback. New test asserts both branches. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../utils/captcha/__tests__/prosopo.test.ts | 22 ++++++++++++++++--- .../clerk-js/src/utils/captcha/prosopo.ts | 14 +++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts b/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts index cf5a0b9fc9f..8532f4ce33c 100644 --- a/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts +++ b/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts @@ -219,9 +219,9 @@ describe('getProcaptchaToken', () => { }); describe('rejection paths', () => { - it('rejects with procaptcha_error when error-callback fires', async () => { + it('propagates the error message when error-callback fires with an Error', async () => { installProcaptchaMock(call => { - setTimeout(() => call.options['error-callback'](), 0); + setTimeout(() => call.options['error-callback'](new Error('challenge_failed')), 0); }); await expect( @@ -231,12 +231,28 @@ describe('getProcaptchaToken', () => { invisibleSiteKey: 'invisible-key', widgetType: 'invisible', }), - ).rejects.toMatchObject({ captchaError: 'procaptcha_error' }); + ).rejects.toMatchObject({ captchaError: 'challenge_failed' }); // Invisible container still cleaned up after error. expect(document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toBeNull(); }); + it('falls back to "procaptcha_error" when error-callback fires without an argument', async () => { + installProcaptchaMock(call => { + // Defensive fallback for runtimes that omit the Error argument the bundle currently passes. + setTimeout(() => call.options['error-callback'](), 0); + }); + + await expect( + getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + }), + ).rejects.toMatchObject({ captchaError: 'procaptcha_error' }); + }); + it('rejects with procaptcha_expired when expired-callback fires', async () => { installProcaptchaMock(call => { setTimeout(() => call.options['expired-callback'](), 0); diff --git a/packages/clerk-js/src/utils/captcha/prosopo.ts b/packages/clerk-js/src/utils/captcha/prosopo.ts index f8f5b14616c..3d00ca464de 100644 --- a/packages/clerk-js/src/utils/captcha/prosopo.ts +++ b/packages/clerk-js/src/utils/captcha/prosopo.ts @@ -17,7 +17,11 @@ type ProcaptchaRenderOptions = { siteKey: string; theme?: ProcaptchaTheme; callback?: (token: string) => void; - 'error-callback'?: () => void; + // The Procaptcha runtime invokes error-callback with an Error built from the underlying + // challenge failure (procaptcha modules/Manager.ts → events.onError(new Error(message))). + // The public type in @prosopo/types declares this as `() => void` and omits the argument — + // we type it the way the runtime actually behaves so the message can be propagated. + 'error-callback'?: (error: Error) => void; 'expired-callback'?: () => void; 'close-callback'?: () => void; size?: 'invisible'; @@ -109,10 +113,10 @@ export const getProcaptchaToken = async (opts: CaptchaOptions) => { closeModal?.(); resolve(token); }, - 'error-callback': () => { - // Procaptcha's error-callback signature does not include an error code; emit a stable - // identifier so callers can branch on it. - reject('procaptcha_error'); + 'error-callback': (error: Error) => { + // Surface the underlying challenge error message (e.g. from procaptcha's Manager). + // Fall back to a stable identifier if the runtime omits the argument for any reason. + reject(error?.message || 'procaptcha_error'); }, 'expired-callback': () => { reject('procaptcha_expired'); From 3629098ddf5b2c0b70239b09003012182b479640 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 23 Jun 2026 12:00:33 +0100 Subject: [PATCH 4/4] fix(js): address CodeRabbit review on containerResolver - waitForElement on the modal container is unbounded, so a missing modal container could hang the auth flow indefinitely. Race against a 5s timeout and throw { captchaError: 'modal_container_not_found' } on expiry. - The invisible flow used a shared .clerk-invisible-captcha class selector for both render and cleanup. Two concurrent challenges would render into the same node and the second cleanup could remove the first's container. Mint a per-instance id ('--'), return that id as the selector, and pass it into cleanup so each challenge only removes its own node. - New containerResolver.test.ts covers the timeout path and asserts concurrent invisible challenges resolve to distinct containers and clean up independently. Existing turnstile.test.ts updated to match the new id-based selector format (the element still carries the class). Both issues were pre-existing in turnstile.ts and inherited by the refactor; flagged by CodeRabbit on PR #8944. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/containerResolver.test.ts | 70 +++++++++++++++++++ .../utils/captcha/__tests__/turnstile.test.ts | 6 +- .../src/utils/captcha/containerResolver.ts | 37 +++++++--- .../clerk-js/src/utils/captcha/prosopo.ts | 2 +- .../clerk-js/src/utils/captcha/turnstile.ts | 2 +- 5 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 packages/clerk-js/src/utils/captcha/__tests__/containerResolver.test.ts diff --git a/packages/clerk-js/src/utils/captcha/__tests__/containerResolver.test.ts b/packages/clerk-js/src/utils/captcha/__tests__/containerResolver.test.ts new file mode 100644 index 00000000000..c852dc784a9 --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/__tests__/containerResolver.test.ts @@ -0,0 +1,70 @@ +import { CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@clerk/shared/dom', () => ({ + waitForElement: vi.fn(), +})); + +import { waitForElement } from '@clerk/shared/dom'; + +import { cleanupCaptchaContainer, resolveCaptchaContainer } from '../containerResolver'; + +const baseOpts = { + captchaProvider: 'turnstile' as const, + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible' as const, +}; + +describe('resolveCaptchaContainer', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + describe('invisible flow — per-instance containers', () => { + it('hands out a unique selector per call so concurrent challenges do not collide', async () => { + const a = await resolveCaptchaContainer(baseOpts); + const b = await resolveCaptchaContainer(baseOpts); + + expect(a.containerSelector).not.toBe(b.containerSelector); + expect(document.querySelectorAll(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toHaveLength(2); + expect(document.querySelector(a.containerSelector)).not.toBeNull(); + expect(document.querySelector(b.containerSelector)).not.toBeNull(); + }); + + it('cleanup only removes the resolved instance, leaving concurrent containers intact', async () => { + const a = await resolveCaptchaContainer(baseOpts); + const b = await resolveCaptchaContainer(baseOpts); + + cleanupCaptchaContainer('invisible', {}, a.containerSelector); + + expect(document.querySelector(a.containerSelector)).toBeNull(); + expect(document.querySelector(b.containerSelector)).not.toBeNull(); + }); + }); + + describe('modal flow — timeout', () => { + it('throws modal_container_not_found when waitForElement does not resolve in time', async () => { + vi.useFakeTimers(); + // waitForElement returns a promise that never resolves. + (waitForElement as any).mockReturnValue(new Promise(() => {})); + + const openModal = vi.fn(async () => undefined); + const promise = resolveCaptchaContainer({ + ...baseOpts, + modalContainerQuerySelector: '#cl-modal-captcha-container', + modalWrapperQuerySelector: '#cl-modal-captcha-wrapper', + openModal, + }); + + const expectation = expect(promise).rejects.toMatchObject({ captchaError: 'modal_container_not_found' }); + + // Advance past the timeout so the Promise.race resolves the null branch. + await vi.advanceTimersByTimeAsync(5001); + await expectation; + + vi.useRealTimers(); + }); + }); +}); diff --git a/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts b/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts index d49164ce99d..9469d5c3a1d 100644 --- a/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts +++ b/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts @@ -51,8 +51,10 @@ describe('getTurnstileToken — invisible flow', () => { expect(result).toEqual({ captchaToken: 'mock_token', captchaWidgetType: 'invisible' }); - // The invisible flow targets its own `.clerk-invisible-captcha` div with the invisible key. - expect((window as any).turnstile.render).toHaveBeenCalledWith(`.${CAPTCHA_INVISIBLE_CLASSNAME}`, expect.anything()); + // The invisible flow targets a per-instance container (id starts with `clerk-invisible-captcha-`) + // and uses the invisible key. Per-instance ids prevent concurrent challenges from colliding. + const renderCall = (window as any).turnstile.render.mock.calls[0]; + expect(renderCall[0]).toMatch(new RegExp(`^#${CAPTCHA_INVISIBLE_CLASSNAME}-`)); expect(renderConfig?.sitekey).toBe('invisible-key'); // The spotlight signal on #clerk-captcha is untouched throughout → no spotlight. diff --git a/packages/clerk-js/src/utils/captcha/containerResolver.ts b/packages/clerk-js/src/utils/captcha/containerResolver.ts index 27ce07f177e..75c75582638 100644 --- a/packages/clerk-js/src/utils/captcha/containerResolver.ts +++ b/packages/clerk-js/src/utils/captcha/containerResolver.ts @@ -22,6 +22,14 @@ export type ResolvedCaptchaContainer = { attributes: CaptchaContainerAttributes; }; +// Auth flows should fail fast rather than hang if Clerk's modal never mounts the container. +const MODAL_CONTAINER_TIMEOUT_MS = 5000; + +// Per-instance suffix for invisible containers so concurrent challenges don't render into or +// remove each other's nodes. +let invisibleContainerCounter = 0; +const nextInvisibleContainerId = () => `${CAPTCHA_INVISIBLE_CLASSNAME}-${Date.now()}-${++invisibleContainerCounter}`; + function readContainerAttributes(element: Element): CaptchaContainerAttributes { try { const el = element as HTMLElement; @@ -44,7 +52,7 @@ function readContainerAttributes(element: Element): CaptchaContainerAttributes { * has not mounted a `#clerk-captcha` element. * * Side effects: opens the modal (when modal selectors are passed) and appends a hidden - * `.clerk-invisible-captcha` div to the body for the invisible flow. Both are reverted by + * per-instance invisible container to the body for the invisible flow. Both are reverted by * {@link cleanupCaptchaContainer}. */ export const resolveCaptchaContainer = async (opts: CaptchaOptions): Promise => { @@ -60,13 +68,21 @@ export const resolveCaptchaContainer = async (opts: CaptchaOptions): Promise([ + waitForElement(modalContainerQuerySelector), + new Promise(resolve => setTimeout(() => resolve(null), MODAL_CONTAINER_TIMEOUT_MS)), + ]); + if (!el) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw { captchaError: 'modal_container_not_found' }; + } return { containerSelector: modalContainerQuerySelector, containerType: 'modal', effectiveSiteKey: opts.siteKey, captchaWidgetType: opts.widgetType, - attributes: el ? readContainerAttributes(el) : {}, + attributes: readContainerAttributes(el), }; } @@ -87,13 +103,16 @@ export const resolveCaptchaContainer = async (opts: CaptchaOptions): Promise