Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/witty-parks-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Adds Content Security Policy (CSP) nonce support to the Cloudflare Turnstile
229 changes: 228 additions & 1 deletion packages/clerk-js/src/utils/__tests__/captcha.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { shouldRetryTurnstileErrorCode } from '../captcha/turnstile';
import type { CaptchaOptions } from '../captcha/types';

describe('shouldRetryTurnstileErrorCode', () => {
it.each([
Expand All @@ -23,3 +24,229 @@ describe('shouldRetryTurnstileErrorCode', () => {
expect(shouldRetryTurnstileErrorCode(str)).toBe(expected);
});
});

describe('Nonce support', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});

describe('retrieveCaptchaInfo', () => {
it('should extract nonce from clerk options when available', async () => {
// Mock clerk instance with internal options
const mockClerk = {
__unstable__environment: {
displayConfig: {
captchaProvider: 'turnstile',
captchaPublicKey: 'test-site-key',
captchaWidgetType: 'managed',
captchaPublicKeyInvisible: 'test-invisible-key',
},
userSettings: {
signUp: {
captcha_enabled: true,
},
},
},
isStandardBrowser: true,
__internal_getOption: vi.fn().mockReturnValue('test-nonce-123'),
};

const { retrieveCaptchaInfo } = await import('../captcha/retrieveCaptchaInfo');
const result = retrieveCaptchaInfo(mockClerk as any);

expect(mockClerk.__internal_getOption).toHaveBeenCalledWith('nonce');
expect(result.nonce).toBe('test-nonce-123');
expect(result.captchaSiteKey).toBe('test-site-key');
expect(result.captchaProvider).toBe('turnstile');
});

it('should return undefined nonce when not available in clerk options', async () => {
const mockClerk = {
__unstable__environment: {
displayConfig: {
captchaProvider: 'turnstile',
captchaPublicKey: 'test-site-key',
captchaWidgetType: 'managed',
captchaPublicKeyInvisible: 'test-invisible-key',
},
userSettings: {
signUp: {
captcha_enabled: true,
},
},
},
isStandardBrowser: true,
__internal_getOption: vi.fn().mockReturnValue(undefined),
};

const { retrieveCaptchaInfo } = await import('../captcha/retrieveCaptchaInfo');
const result = retrieveCaptchaInfo(mockClerk as any);

expect(result.nonce).toBeUndefined();
});

it('should handle clerk instance without __internal_getOption method', async () => {
const mockClerk = {
__unstable__environment: {
displayConfig: {
captchaProvider: 'turnstile',
captchaPublicKey: 'test-site-key',
captchaWidgetType: 'managed',
captchaPublicKeyInvisible: 'test-invisible-key',
},
userSettings: {
signUp: {
captcha_enabled: true,
},
},
},
isStandardBrowser: true,
// No __internal_getOption method
};

const { retrieveCaptchaInfo } = await import('../captcha/retrieveCaptchaInfo');
const result = retrieveCaptchaInfo(mockClerk as any);

expect(result.nonce).toBeUndefined();
});
});

describe('CaptchaOptions type support', () => {
it('should accept nonce in CaptchaOptions type definition', () => {
// This test verifies that the CaptchaOptions type includes the nonce field
const validOptions: CaptchaOptions = {
action: 'signup',
captchaProvider: 'turnstile',
closeModal: async () => {},
invisibleSiteKey: 'test-invisible-key',
modalContainerQuerySelector: '.modal',
modalWrapperQuerySelector: '.wrapper',
nonce: 'test-nonce-from-csp',
openModal: async () => {},
siteKey: 'test-site-key',
widgetType: 'invisible',
};

// If this compiles without TypeScript errors, the test passes
expect(validOptions.nonce).toBe('test-nonce-from-csp');
});

it('should allow undefined nonce in CaptchaOptions', () => {
const validOptionsWithoutNonce: CaptchaOptions = {
action: 'signup',
captchaProvider: 'turnstile',
invisibleSiteKey: 'test-invisible-key',
siteKey: 'test-site-key',
widgetType: 'invisible',
// nonce is optional
};

expect(validOptionsWithoutNonce.nonce).toBeUndefined();
});
});

describe('CaptchaChallenge nonce integration', () => {
let mockClerk: any;

beforeEach(async () => {
// Mock clerk instance
mockClerk = {
__unstable__environment: {
displayConfig: {
captchaProvider: 'turnstile',
captchaPublicKey: 'test-site-key',
captchaWidgetType: 'managed',
captchaPublicKeyInvisible: 'test-invisible-key',
},
userSettings: {
signUp: {
captcha_enabled: true,
},
},
},
isStandardBrowser: true,
__internal_getOption: vi.fn().mockReturnValue('clerk-nonce-789'),
};

// Mock getCaptchaToken
vi.doMock('../captcha/getCaptchaToken', () => ({
getCaptchaToken: vi.fn().mockResolvedValue({
captchaToken: 'mock-token',
captchaWidgetType: 'invisible',
}),
}));
});

it('should use nonce from clerk options in invisible challenge', async () => {
const { getCaptchaToken } = await import('../captcha/getCaptchaToken');
const { CaptchaChallenge } = await import('../captcha/CaptchaChallenge');

const challenge = new CaptchaChallenge(mockClerk);
await challenge.invisible({ action: 'signup' });

expect(getCaptchaToken).toHaveBeenCalledWith(
expect.objectContaining({
nonce: 'clerk-nonce-789',
captchaProvider: 'turnstile',
siteKey: 'test-invisible-key',
widgetType: 'invisible',
}),
);
});

it('should use nonce from clerk options in managedOrInvisible challenge', async () => {
const { getCaptchaToken } = await import('../captcha/getCaptchaToken');
const { CaptchaChallenge } = await import('../captcha/CaptchaChallenge');

const challenge = new CaptchaChallenge(mockClerk);
await challenge.managedOrInvisible({ action: 'verify' });

expect(getCaptchaToken).toHaveBeenCalledWith(
expect.objectContaining({
nonce: 'clerk-nonce-789',
captchaProvider: 'turnstile',
siteKey: 'test-site-key',
widgetType: 'managed',
}),
);
});

it('should prefer explicit nonce over clerk options nonce', async () => {
const { getCaptchaToken } = await import('../captcha/getCaptchaToken');
const { CaptchaChallenge } = await import('../captcha/CaptchaChallenge');

const challenge = new CaptchaChallenge(mockClerk);
await challenge.invisible({
action: 'signup',
nonce: 'explicit-nonce-override',
});

expect(getCaptchaToken).toHaveBeenCalledWith(
expect.objectContaining({
nonce: 'explicit-nonce-override',
}),
);
});

it('should handle missing nonce gracefully', async () => {
// Mock clerk without nonce
const clerkWithoutNonce = {
...mockClerk,
__internal_getOption: vi.fn().mockReturnValue(undefined),
};

const { getCaptchaToken } = await import('../captcha/getCaptchaToken');
const { CaptchaChallenge } = await import('../captcha/CaptchaChallenge');

const challenge = new CaptchaChallenge(clerkWithoutNonce);
await challenge.invisible({ action: 'signup' });

expect(getCaptchaToken).toHaveBeenCalledWith(
expect.objectContaining({
nonce: undefined,
}),
);
});
});
});
16 changes: 9 additions & 7 deletions packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ export class CaptchaChallenge {
* always use the fallback key.
*/
public async invisible(opts?: Partial<CaptchaOptions>) {
const { captchaSiteKey, canUseCaptcha, captchaPublicKeyInvisible } = retrieveCaptchaInfo(this.clerk);
const { captchaSiteKey, canUseCaptcha, captchaPublicKeyInvisible, nonce } = retrieveCaptchaInfo(this.clerk);

if (canUseCaptcha && captchaSiteKey && captchaPublicKeyInvisible) {
const captchaResult = await getCaptchaToken({
siteKey: captchaPublicKeyInvisible,
action: opts?.action,
captchaProvider: 'turnstile',
invisibleSiteKey: captchaPublicKeyInvisible,
nonce: opts?.nonce || nonce || undefined,
siteKey: captchaPublicKeyInvisible,
widgetType: 'invisible',
captchaProvider: 'turnstile',
action: opts?.action,
}).catch(e => {
if (e.captchaError) {
return { captchaError: e.captchaError };
Expand All @@ -42,15 +43,16 @@ export class CaptchaChallenge {
* Managed challenged start as non-interactive and escalate to interactive if necessary.
*/
public async managedOrInvisible(opts?: Partial<CaptchaOptions>) {
const { captchaSiteKey, canUseCaptcha, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
const { captchaSiteKey, canUseCaptcha, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible, nonce } =
retrieveCaptchaInfo(this.clerk);

if (canUseCaptcha && captchaSiteKey && captchaPublicKeyInvisible) {
const captchaResult = await getCaptchaToken({
captchaProvider,
invisibleSiteKey: captchaPublicKeyInvisible,
nonce: nonce || undefined,
siteKey: captchaSiteKey,
widgetType: captchaWidgetType,
invisibleSiteKey: captchaPublicKeyInvisible,
captchaProvider,
...opts,
}).catch(e => {
if (e.captchaError) {
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => {
const _environment = clerk.__unstable__environment;
const captchaProvider = _environment ? _environment.displayConfig.captchaProvider : 'turnstile';

// Access nonce via internal options - casting to any since nonce is in IsomorphicClerkOptions but not ClerkOptions
const nonce = (clerk as any).__internal_getOption?.('nonce') as string | undefined;

return {
captchaSiteKey: _environment ? _environment.displayConfig.captchaPublicKey : null,
captchaWidgetType: _environment ? _environment.displayConfig.captchaWidgetType : null,
captchaProvider,
captchaPublicKeyInvisible: _environment ? _environment.displayConfig.captchaPublicKeyInvisible : null,
canUseCaptcha: _environment ? _environment.userSettings.signUp.captcha_enabled && clerk.isStandardBrowser : null,
nonce: nonce || undefined,
};
};
12 changes: 6 additions & 6 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,23 @@ export const shouldRetryTurnstileErrorCode = (errorCode: string) => {
return !!codesWithRetries.find(w => errorCode.startsWith(w));
};

async function loadCaptcha() {
async function loadCaptcha(nonce?: string) {
if (!window.turnstile) {
await loadCaptchaFromCloudflareURL().catch(() => {
await loadCaptchaFromCloudflareURL(nonce).catch(() => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw { captchaError: 'captcha_script_failed_to_load' };
});
}
return window.turnstile;
}

async function loadCaptchaFromCloudflareURL() {
async function loadCaptchaFromCloudflareURL(nonce?: string) {
try {
if (__BUILD_DISABLE_RHC__) {
return Promise.reject(new Error('Captcha not supported in this environment'));
}

return await loadScript(CLOUDFLARE_TURNSTILE_ORIGINAL_URL, { defer: true });
return await loadScript(CLOUDFLARE_TURNSTILE_ORIGINAL_URL, { defer: true, nonce });
} catch (err) {
console.warn(
'Clerk: Failed to load the CAPTCHA script from Cloudflare. 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.',
Expand Down Expand Up @@ -71,9 +71,9 @@ 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 } = opts;
const { siteKey, widgetType, invisibleSiteKey, nonce } = opts;
const { modalContainerQuerySelector, modalWrapperQuerySelector, closeModal, openModal } = opts;
const captcha: Turnstile.Turnstile = await loadCaptcha();
const captcha: Turnstile.Turnstile = await loadCaptcha(nonce);
const errorCodes: (string | number)[] = [];

let captchaToken = '';
Expand Down
11 changes: 6 additions & 5 deletions packages/clerk-js/src/utils/captcha/types.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { CaptchaProvider, CaptchaWidgetType } from '@clerk/types';

export type CaptchaOptions = {
siteKey: string;
widgetType: CaptchaWidgetType;
invisibleSiteKey: string;
action?: 'verify' | 'signup' | 'heartbeat';
captchaProvider: CaptchaProvider;
closeModal?: () => Promise<unknown>;
invisibleSiteKey: string;
modalContainerQuerySelector?: string;
modalWrapperQuerySelector?: string;
nonce?: string;
openModal?: () => Promise<unknown>;
closeModal?: () => Promise<unknown>;
action?: 'verify' | 'signup' | 'heartbeat';
siteKey: string;
widgetType: CaptchaWidgetType;
};

export type GetCaptchaTokenReturn = {
Expand Down
Loading