Skip to content
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();
});
Comment on lines +20 to +23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate file =="
fd -a 'containerResolver.test.ts' . || true

echo "== File excerpt =="
file="$(fd 'containerResolver.test.ts' . | head -n 1)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  cat -n "$file"
fi

echo "== Relevant timer usages =="
file="$(fd 'containerResolver.test.ts' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  rg -n "vi\.use(Fake|Real)Timers|waitForElement|await" "$file"
fi

echo "== Nearby tests summary =="
python3 - <<'PY'
from pathlib import Path
p = next(Path('.').rglob('containerResolver.test.ts'), None)
if not p:
    raise SystemExit
print(p)
text = p.read_text()
for i, line in enumerate(text.splitlines(), 1):
    if 'it(' in line or "vi.useFakeTimers" in line or "vi.useRealTimers" in line or 'afterEach' in line or 'waitForElement' in line:
        print(f"{i}: {line}")
PY

Repository: clerk/javascript

Length of output: 5024


🌐 Web query:

Vitest vi.useFakeTimers beforeEach afterEach useRealTimers documentation

💡 Result:

In Vitest, you use fake timers to gain control over time-dependent functions like setTimeout, setInterval, and Date, allowing you to run tests instantly instead of waiting for real time to pass [1][2]. Key functions and their usage: vi.useFakeTimers: Enables fake timers [3][2]. It is best practice to call this in a beforeEach hook to ensure a clean state for each test [1][4]. It intercepts calls to global timer APIs and, optionally, other platform methods like nextTick or queueMicrotask [2][5]. vi.useRealTimers: Restores the native timer implementations [3][2]. It is highly recommended to call this in an afterEach hook to avoid leaking mocked timer behavior into subsequent tests, which can cause instability or unexpected test failures [4]. Example Pattern: import { beforeEach, afterEach, describe, it, vi } from 'vitest'; describe('timer tests', => { beforeEach( => { // Enable fake timers before the test runs vi.useFakeTimers; }); afterEach( => { // Restore real timers after each test to prevent side effects vi.useRealTimers; }); it('example test', => { // Test logic here }); }); Important Considerations: - Date Mocking: vi.useFakeTimers also mocks the global Date object [3][2]. If you use vi.setSystemTime to control the date, remember that this state persists unless you manually reset it or restore real timers [6][7]. - Configuration: You can configure which APIs are faked (e.g., nextTick, requestAnimationFrame) by passing an options object to vi.useFakeTimers({ toFake: [...] }) [2][5]. - Cleanup: Always ensure cleanup occurs in afterEach, even if a test fails, to maintain a consistent environment for the rest of your test suite [4].

Citations:


Restore fake timers in afterEach.

This test enables fake timers, but vi.useRealTimers() only runs after the awaited rejection expectation. If the awaited promise rejects before that line runs, fake timers remain enabled and can affect later tests. Move vi.useRealTimers() into the existing afterEach block.

Proposed fix
 afterEach(() => {
   document.body.innerHTML = '';
+  vi.useRealTimers();
   vi.restoreAllMocks();
 });
...
-      vi.useRealTimers();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
afterEach(() => {
document.body.innerHTML = '';
vi.useRealTimers();
vi.restoreAllMocks();
});
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 20-20: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: document.body.innerHTML = ''
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation

(dom-content-modification)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/utils/captcha/__tests__/containerResolver.test.ts`
around lines 20 - 23, Update the existing afterEach cleanup in containerResolver
tests to call vi.useRealTimers() alongside vi.restoreAllMocks(), ensuring fake
timers are restored even when an awaited rejection exits early. Remove the later
success-path-only timer restoration if it becomes redundant.


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