Skip to content
Closed
6 changes: 6 additions & 0 deletions .changeset/prosopo-captcha-provider.md
Original file line number Diff line number Diff line change
@@ -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'`.
6 changes: 4 additions & 2 deletions packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ export class CaptchaChallenge {
* always use the fallback key.
*/
public async invisible(opts?: Partial<CaptchaOptions>) {
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Original file line number Diff line number Diff line change
@@ -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<Element | null>(() => {}));

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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Loading