diff --git a/.changeset/witty-parks-attack.md b/.changeset/witty-parks-attack.md new file mode 100644 index 00000000000..4037aa97327 --- /dev/null +++ b/.changeset/witty-parks-attack.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +Adds Content Security Policy (CSP) nonce support to the Cloudflare Turnstile diff --git a/packages/clerk-js/src/utils/__tests__/captcha.spec.ts b/packages/clerk-js/src/utils/__tests__/captcha.spec.ts index 63cb871f1a0..97665749f7d 100644 --- a/packages/clerk-js/src/utils/__tests__/captcha.spec.ts +++ b/packages/clerk-js/src/utils/__tests__/captcha.spec.ts @@ -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([ @@ -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, + }), + ); + }); + }); +}); diff --git a/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts b/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts index 57459dc9aa8..f7b81bb0f6a 100644 --- a/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts +++ b/packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts @@ -12,15 +12,16 @@ export class CaptchaChallenge { * always use the fallback key. */ public async invisible(opts?: Partial) { - 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 }; @@ -42,15 +43,16 @@ export class CaptchaChallenge { * Managed challenged start as non-interactive and escalate to interactive if necessary. */ public async managedOrInvisible(opts?: Partial) { - 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) { diff --git a/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts b/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts index 60f16de2a26..2985d4ce9c0 100644 --- a/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts +++ b/packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts @@ -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, }; }; diff --git a/packages/clerk-js/src/utils/captcha/turnstile.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts index 27a898831fe..f8eda4016db 100644 --- a/packages/clerk-js/src/utils/captcha/turnstile.ts +++ b/packages/clerk-js/src/utils/captcha/turnstile.ts @@ -26,9 +26,9 @@ 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' }; }); @@ -36,13 +36,13 @@ async function loadCaptcha() { 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.', @@ -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 = ''; diff --git a/packages/clerk-js/src/utils/captcha/types.ts b/packages/clerk-js/src/utils/captcha/types.ts index 9271baacf4a..3740c512be3 100644 --- a/packages/clerk-js/src/utils/captcha/types.ts +++ b/packages/clerk-js/src/utils/captcha/types.ts @@ -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; + invisibleSiteKey: string; modalContainerQuerySelector?: string; modalWrapperQuerySelector?: string; + nonce?: string; openModal?: () => Promise; - closeModal?: () => Promise; - action?: 'verify' | 'signup' | 'heartbeat'; + siteKey: string; + widgetType: CaptchaWidgetType; }; export type GetCaptchaTokenReturn = {