Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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