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__/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__/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__/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 new file mode 100644 index 00000000000..8532f4ce33c --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/__tests__/prosopo.test.ts @@ -0,0 +1,292 @@ +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; +}; + +/** + * 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', () => { + afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).procaptcha; + vi.restoreAllMocks(); + }); + + 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 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'); + }); + + it('drops invalid theme attributes silently', async () => { + installProcaptchaMock(call => setTimeout(() => call.options.callback('t'), 0)); + + const inline = document.createElement('div'); + inline.id = CAPTCHA_ELEMENT_ID; + inline.setAttribute('data-cl-theme', 'rainbow'); + document.body.appendChild(inline); + + await getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'smart', + }); + + 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('propagates the error message when error-callback fires with an Error', async () => { + installProcaptchaMock(call => { + setTimeout(() => call.options['error-callback'](new Error('challenge_failed')), 0); + }); + + await expect( + getProcaptchaToken({ + captchaProvider: 'prosopo', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + }), + ).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); + }); + + 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/__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 new file mode 100644 index 00000000000..75c75582638 --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/containerResolver.ts @@ -0,0 +1,144 @@ +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; +}; + +// 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; + 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 + * per-instance invisible container 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' }; + } + // waitForElement never rejects, so race it against a timeout to keep the auth flow from hanging. + const el = await Promise.race([ + 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: 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 with a unique id so + // concurrent challenges resolve to their own node. + const containerId = nextInvisibleContainerId(); + const div = document.createElement('div'); + div.id = containerId; + div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); + div.style.display = 'none'; + document.body.appendChild(div); + return { + containerSelector: `#${containerId}`, + 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. `containerSelector` is required for invisible containers so + * the right per-instance node is removed when challenges run concurrently. + */ +export const cleanupCaptchaContainer = ( + containerType: CaptchaContainerType, + opts: Pick, + containerSelector?: string, +) => { + if (containerType === 'modal') { + opts.closeModal?.(); + return; + } + if (containerType === 'invisible' && containerSelector) { + const invisibleWidget = document.querySelector(containerSelector); + if (invisibleWidget && invisibleWidget.parentNode === document.body) { + document.body.removeChild(invisibleWidget); + } + } +}; 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..0a9a59486b0 --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/prosopo.ts @@ -0,0 +1,168 @@ +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; +import { loadScript } from '@clerk/shared/loadScript'; + +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 = { + siteKey: string; + theme?: ProcaptchaTheme; + callback?: (token: string) => 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'; + 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 (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 narrowTheme(theme: string | undefined): ProcaptchaTheme | undefined { + return theme === 'dark' || theme === 'light' ? theme : undefined; +} + +/* + * 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 { closeModal } = opts; + const captcha = await loadProcaptcha(opts.nonce); + + const resolved = await resolveCaptchaContainer(opts); + const { containerSelector, containerType, effectiveSiteKey, captchaWidgetType, attributes } = resolved; + const captchaTheme = narrowTheme(attributes.theme); + const captchaLanguage = attributes.language; + + // 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) { + visibleDiv.style.minHeight = PROSOPO_SMART_MIN_HEIGHT; + visibleDiv.style.marginBottom = '1.5rem'; + } + } + + let captchaToken = ''; + + const handleCaptchaTokenGeneration = async (): Promise => { + const containerEl = document.querySelector(containerSelector); + if (!containerEl) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'captcha_container_missing'; + } + + return new Promise((resolve, reject) => { + const renderOptions: ProcaptchaRenderOptions = { + siteKey: effectiveSiteKey, + theme: captchaTheme, + language: captchaLanguage, + callback: (token: string) => { + closeModal?.(); + resolve(token); + }, + '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'); + }, + }; + + 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 (containerType === '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 { + // The bundle's reset() unmounts every Procaptcha root it has registered, including ours. + try { + captcha.reset(); + } catch { + // best-effort cleanup + } + // 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, containerSelector); + } + + 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..a077b21a77d 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, containerSelector); } 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';