diff --git a/.changeset/add-captcha-enterprise-invisible.md b/.changeset/add-captcha-enterprise-invisible.md new file mode 100644 index 00000000..7920f94d --- /dev/null +++ b/.changeset/add-captcha-enterprise-invisible.md @@ -0,0 +1,11 @@ +--- +'@forgerock/login-widget': minor +--- + +Add invisible reCAPTCHA v2, invisible hCaptcha, and reCAPTCHA Enterprise support. + +- Support invisible mode for both Google reCAPTCHA v2 and hCaptcha via `configuration({ captcha: { mode: 'invisible' } })`. +- Add `ReCaptchaEnterpriseCallback` handler for AM journeys using the Enterprise CAPTCHA node — renders visible checkbox or score-based invisible flow automatically from callback data. +- Add `resolveGrecaptcha()` helper that prefers `window.grecaptcha.enterprise` and falls back to classic `window.grecaptcha`, keeping existing consumers with migrated keys working without changes. +- Show inline `` on CAPTCHA failure or expiry for invisible modes. +- Fix `renderCaptcha` to accept an optional `elementId` param to avoid DOM id collisions between classic and Enterprise components. diff --git a/apps/login-app/src/routes/(app)/+layout.svelte b/apps/login-app/src/routes/(app)/+layout.svelte index bb4867c3..a24522da 100644 --- a/apps/login-app/src/routes/(app)/+layout.svelte +++ b/apps/login-app/src/routes/(app)/+layout.svelte @@ -1,6 +1,6 @@ + + + + diff --git a/core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte b/core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte new file mode 100644 index 00000000..b23af449 --- /dev/null +++ b/core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte @@ -0,0 +1,120 @@ + + + + +{#if captchaMode === 'invisible'} +
+ {#if captchaError} + + {interpolate(captchaError, null, 'CAPTCHA verification failed. Please try again.')} + + {/if} +{:else} +
+{/if} diff --git a/core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.utilities.test.ts b/core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.utilities.test.ts new file mode 100644 index 00000000..767b1d6c --- /dev/null +++ b/core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.utilities.test.ts @@ -0,0 +1,194 @@ +/** + * + * Copyright © 2026 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + **/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + buildEnterpriseScriptSrc, + executeEnterpriseCaptcha, + renderEnterpriseCaptcha, +} from './recaptcha-enterprise.utilities'; + +import type { ReCaptchaEnterpriseCallback } from '@forgerock/journey-client/types'; + +vi.stubGlobal('window', globalThis); + +const makeCallbacks = () => ({ + onSuccess: vi.fn(), + onExpired: vi.fn(), + onError: vi.fn(), +}); + +describe('buildEnterpriseScriptSrc', () => { + it('appends ?render=siteKey for invisible mode', () => { + expect( + buildEnterpriseScriptSrc({ + apiUrl: 'https://www.google.com/recaptcha/enterprise.js', + siteKey: 'test-site-key', + mode: 'invisible', + }), + ).toBe('https://www.google.com/recaptcha/enterprise.js?render=test-site-key'); + }); + + it('does not append ?render for visible mode', () => { + expect( + buildEnterpriseScriptSrc({ + apiUrl: 'https://www.google.com/recaptcha/enterprise.js', + siteKey: 'test-site-key', + mode: 'visible', + }), + ).toBe('https://www.google.com/recaptcha/enterprise.js'); + }); +}); + +describe('renderEnterpriseCaptcha', () => { + beforeEach(() => { + vi.stubGlobal('grecaptcha', undefined); + }); + + it('calls grecaptcha.render with closure callbacks', () => { + const mockRender = vi.fn().mockReturnValue('grecaptcha-widget-id'); + vi.stubGlobal('grecaptcha', { render: mockRender }); + const { onSuccess, onExpired } = makeCallbacks(); + + renderEnterpriseCaptcha({ + siteKey: 'grecaptcha-key', + onSuccess, + onExpired, + }); + + expect(mockRender).toHaveBeenCalledWith('fr-recaptcha-enterprise', { + sitekey: 'grecaptcha-key', + callback: onSuccess, + 'expired-callback': onExpired, + }); + }); + + it('calls enterprise.render when enterprise namespace present', () => { + const mockRender = vi.fn().mockReturnValue('enterprise-widget-id'); + vi.stubGlobal('grecaptcha', { enterprise: { render: mockRender } }); + const { onSuccess, onExpired } = makeCallbacks(); + + renderEnterpriseCaptcha({ + siteKey: 'enterprise-key', + onSuccess, + onExpired, + }); + + expect(mockRender).toHaveBeenCalledWith( + 'fr-recaptcha-enterprise', + expect.objectContaining({ sitekey: 'enterprise-key' }), + ); + }); + + it('does not throw if grecaptcha is not loaded', () => { + const { onSuccess, onExpired } = makeCallbacks(); + expect(() => + renderEnterpriseCaptcha({ + siteKey: 'key', + onSuccess, + onExpired, + }), + ).not.toThrow(); + }); +}); + +describe('executeEnterpriseCaptcha', () => { + beforeEach(() => { + vi.stubGlobal('grecaptcha', undefined); + }); + + it('calls setResult and setAction on success', async () => { + const token = 'enterprise-token-xyz'; + const mockExecute = vi.fn().mockResolvedValue(token); + const mockReady = vi.fn((fn: () => void) => fn()); + vi.stubGlobal('grecaptcha', { + enterprise: { ready: mockReady, execute: mockExecute }, + }); + + const callback = { + setResult: vi.fn(), + setAction: vi.fn(), + setClientError: vi.fn(), + } as unknown as ReCaptchaEnterpriseCallback; + executeEnterpriseCaptcha({ siteKey: 'site-key', action: 'LOGIN', callback }); + + await vi.waitFor(() => expect(callback.setResult).toHaveBeenCalledWith(token)); + expect(callback.setAction).toHaveBeenCalledWith('LOGIN'); + expect(callback.setClientError).not.toHaveBeenCalled(); + }); + + it('calls setClientError and onError on execute failure', async () => { + const mockExecute = vi.fn().mockRejectedValue(new Error('network error')); + const mockReady = vi.fn((fn: () => void) => fn()); + vi.stubGlobal('grecaptcha', { + enterprise: { ready: mockReady, execute: mockExecute }, + }); + + const callback = { + setResult: vi.fn(), + setAction: vi.fn(), + setClientError: vi.fn(), + } as unknown as ReCaptchaEnterpriseCallback; + const onError = vi.fn(); + executeEnterpriseCaptcha({ siteKey: 'site-key', action: 'LOGIN', callback, onError }); + + await vi.waitFor(() => expect(callback.setClientError).toHaveBeenCalledWith('captcha_error')); + expect(callback.setResult).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + }); + + it('does nothing when action is empty', () => { + const mockReady = vi.fn(); + vi.stubGlobal('grecaptcha', { enterprise: { ready: mockReady } }); + + const callback = { + setResult: vi.fn(), + setAction: vi.fn(), + setClientError: vi.fn(), + } as unknown as ReCaptchaEnterpriseCallback; + executeEnterpriseCaptcha({ siteKey: 'site-key', action: '', callback }); + + expect(mockReady).not.toHaveBeenCalled(); + }); + + it('uses classic grecaptcha fallback when enterprise namespace absent', async () => { + const token = 'classic-token'; + const mockExecute = vi.fn().mockResolvedValue(token); + const mockReady = vi.fn((fn: () => void) => fn()); + vi.stubGlobal('grecaptcha', { ready: mockReady, execute: mockExecute }); + + const callback = { + setResult: vi.fn(), + setAction: vi.fn(), + setClientError: vi.fn(), + } as unknown as ReCaptchaEnterpriseCallback; + executeEnterpriseCaptcha({ siteKey: 'classic-key', action: 'SUBMIT', callback }); + + await vi.waitFor(() => expect(callback.setResult).toHaveBeenCalledWith(token)); + expect(callback.setAction).toHaveBeenCalledWith('SUBMIT'); + }); + + it('passes siteKey and action to execute', async () => { + const mockExecute = vi.fn().mockResolvedValue('tok'); + const mockReady = vi.fn((fn: () => void) => fn()); + vi.stubGlobal('grecaptcha', { enterprise: { ready: mockReady, execute: mockExecute } }); + + const callback = { + setResult: vi.fn(), + setAction: vi.fn(), + setClientError: vi.fn(), + } as unknown as ReCaptchaEnterpriseCallback; + executeEnterpriseCaptcha({ siteKey: 'my-site-key', action: 'REGISTER', callback }); + + await vi.waitFor(() => + expect(mockExecute).toHaveBeenCalledWith('my-site-key', { action: 'REGISTER' }), + ); + }); +}); diff --git a/core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.utilities.ts b/core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.utilities.ts new file mode 100644 index 00000000..9e82f1a3 --- /dev/null +++ b/core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.utilities.ts @@ -0,0 +1,92 @@ +/** + * + * Copyright © 2026 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + **/ + +import { loadCaptchaScript, resolveGrecaptcha } from '$journey/_utilities/captcha.utilities'; + +import type { ReCaptchaEnterpriseCallback } from '@forgerock/journey-client/types'; + +/** + * Builds the script URL for the reCAPTCHA Enterprise provider. + * Score-based (invisible) keys require `?render=SITE_KEY` for `execute()` to work. + */ +export function buildEnterpriseScriptSrc({ + apiUrl, + siteKey, + mode, +}: { + apiUrl: string; + siteKey: string; + mode: 'invisible' | 'visible'; +}): string { + return mode === 'invisible' ? `${apiUrl}?render=${siteKey}` : apiUrl; +} + +export function loadEnterpriseScript({ + apiUrl, + siteKey, + mode, +}: { + apiUrl: string; + siteKey: string; + mode: 'invisible' | 'visible'; +}): Promise { + const src = buildEnterpriseScriptSrc({ apiUrl, siteKey, mode }); + return loadCaptchaScript({ src, provider: 'grecaptcha' }); +} + +export function renderEnterpriseCaptcha({ + siteKey, + elementId = 'fr-recaptcha-enterprise', + onSuccess, + onExpired, +}: { + siteKey: string; + elementId?: string; + onSuccess: (token: string) => void; + onExpired: () => void; +}) { + const grecaptcha = resolveGrecaptcha(); + if (grecaptcha) { + return grecaptcha.render(elementId, { + sitekey: siteKey, + callback: onSuccess, + 'expired-callback': onExpired, + }); + } +} + +export function executeEnterpriseCaptcha({ + siteKey, + action, + callback, + onError, +}: { + siteKey: string; + action: string; + callback: ReCaptchaEnterpriseCallback; + onError?: () => void; +}) { + if (!action.length) return; + const grc = resolveGrecaptcha(); + grc.ready(async function () { + try { + const token = await grc.execute(siteKey, { action }); + callback?.setResult(token); + try { + callback?.setAction(action); + } catch { + // AM node may not expose the action input — non-fatal, token already set + } + } catch (err) { + console.error('[recaptcha-enterprise] execute() threw:', err); + callback?.setClientError('captcha_error'); + onError?.(); + } + }); +} diff --git a/core/journey/callbacks/recaptcha/recaptcha.mock.ts b/core/journey/callbacks/recaptcha/recaptcha.mock.ts new file mode 100644 index 00000000..3eaa638f --- /dev/null +++ b/core/journey/callbacks/recaptcha/recaptcha.mock.ts @@ -0,0 +1,66 @@ +/** + * + * Copyright © 2026 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + **/ + +import { callbackType } from '@forgerock/journey-client'; + +export const visibleGrecaptcha = { + authId: 'test-auth-id', + callbacks: [ + { + type: callbackType.ReCaptchaCallback, + output: [ + { name: 'recaptchaSiteKey', value: 'site-key-visible-g' }, + { name: 'captchaDivClass', value: 'g-recaptcha' }, + { name: 'reCaptchaV3', value: false }, + ], + input: [{ name: 'IDToken1', value: '' }], + _id: 0, + }, + ], + stage: 'DefaultLogin', +}; + +export const visibleHcaptcha = { + authId: 'test-auth-id', + callbacks: [ + { + type: callbackType.ReCaptchaCallback, + output: [ + { name: 'recaptchaSiteKey', value: 'site-key-visible-h' }, + { name: 'captchaDivClass', value: 'h-captcha' }, + { name: 'reCaptchaV3', value: false }, + ], + input: [{ name: 'IDToken1', value: '' }], + _id: 0, + }, + ], + stage: 'DefaultLogin', +}; + +export const invisibleGrecaptcha = visibleGrecaptcha; +export const invisibleHcaptcha = visibleHcaptcha; + +export const v3Grecaptcha = { + authId: 'test-auth-id', + callbacks: [ + { + type: callbackType.ReCaptchaCallback, + output: [ + { name: 'recaptchaSiteKey', value: 'site-key-v3' }, + { name: 'captchaDivClass', value: 'g-recaptcha' }, + { name: 'reCaptchaV3', value: true }, + ], + input: [{ name: 'IDToken1', value: '' }], + _id: 0, + }, + ], + stage: 'DefaultLogin', +}; + +export default visibleGrecaptcha; diff --git a/core/journey/callbacks/recaptcha/recaptcha.stories.js b/core/journey/callbacks/recaptcha/recaptcha.stories.js new file mode 100644 index 00000000..b227ff88 --- /dev/null +++ b/core/journey/callbacks/recaptcha/recaptcha.stories.js @@ -0,0 +1,96 @@ +/** + * + * Copyright © 2026 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + **/ + +import { callbackType } from '@forgerock/journey-client'; +import { expect } from 'storybook/test'; + +import { createJourneyStep } from '$journey/_utilities/step.mock'; +import { invisibleGrecaptcha, visibleGrecaptcha, visibleHcaptcha } from './recaptcha.mock'; +import Recaptcha from './recaptcha.story.svelte'; + +function mockGrecaptcha() { + window.grecaptcha = { + ready: (cb) => cb(), + render: () => 'widget-id', + execute: () => 'token', + reset: () => undefined, + getResponse: () => 'token', + }; +} + +function mockHcaptcha() { + window.hcaptcha = { + render: () => 'widget-id', + execute: () => undefined, + reset: () => undefined, + getResponse: () => 'token', + }; +} + +function makeCallbackMetadata(mode) { + return { + derived: { + canForceUserInputOptionality: false, + isFirstInvalidInput: false, + isReadyForSubmission: false, + isSelfSubmitting: false, + isUserInputRequired: false, + isPasskeyAutofillEligible: false, + }, + idx: 0, + initOptions: { mode }, + }; +} + +export default { + argTypes: { + callback: { control: false }, + callbackMetadata: { control: false }, + }, + component: Recaptcha, + parameters: { + layout: 'fullscreen', + }, + title: 'Callbacks/ReCaptcha', +}; + +export const VisibleGoogle = { + args: { + callback: createJourneyStep(visibleGrecaptcha).getCallbackOfType( + callbackType.ReCaptchaCallback, + ), + callbackMetadata: makeCallbackMetadata('visible'), + }, + play: async () => { + mockGrecaptcha(); + }, +}; + +export const VisibleHCaptcha = { + args: { + callback: createJourneyStep(visibleHcaptcha).getCallbackOfType(callbackType.ReCaptchaCallback), + callbackMetadata: makeCallbackMetadata('visible'), + }, + play: async () => { + mockHcaptcha(); + }, +}; + +export const InvisibleGoogle = { + args: { + callback: createJourneyStep(invisibleGrecaptcha).getCallbackOfType( + callbackType.ReCaptchaCallback, + ), + callbackMetadata: makeCallbackMetadata('invisible'), + }, + play: async ({ canvasElement }) => { + mockGrecaptcha(); + await expect(canvasElement.querySelector('#fr-recaptcha')).toBeTruthy(); + }, +}; diff --git a/core/journey/callbacks/recaptcha/recaptcha.story.svelte b/core/journey/callbacks/recaptcha/recaptcha.story.svelte new file mode 100644 index 00000000..5882d857 --- /dev/null +++ b/core/journey/callbacks/recaptcha/recaptcha.story.svelte @@ -0,0 +1,25 @@ + + + + + + + diff --git a/core/journey/callbacks/recaptcha/recaptcha.svelte b/core/journey/callbacks/recaptcha/recaptcha.svelte index ff68a95c..b5313e59 100644 --- a/core/journey/callbacks/recaptcha/recaptcha.svelte +++ b/core/journey/callbacks/recaptcha/recaptcha.svelte @@ -1,20 +1,21 @@ {#if isV3 === false} -
+ {#if captchaMode === 'invisible'} +
+ {#if captchaError} + + {interpolate(captchaError, null, 'CAPTCHA verification failed. Please try again.')} + + {/if} + {:else} +
+ {/if} {/if} diff --git a/core/journey/journey.interfaces.ts b/core/journey/journey.interfaces.ts index 08e9aa32..ab3d3cbf 100644 --- a/core/journey/journey.interfaces.ts +++ b/core/journey/journey.interfaces.ts @@ -35,6 +35,7 @@ export interface CallbackMetadata { isPasskeyAutofillEligible: boolean; }; idx: number; + initOptions?: Record; platform?: Record; } export interface JourneyStore extends Pick, 'subscribe'> { @@ -76,7 +77,6 @@ export interface JourneyStoreValue { step?: StepTypes; successful: boolean; response: Maybe; - recaptchaAction?: Maybe; } export interface StackStore extends Pick, 'subscribe'> { latest: () => Promise; diff --git a/core/journey/journey.store.ts b/core/journey/journey.store.ts index 7c3b11b1..e031cea2 100644 --- a/core/journey/journey.store.ts +++ b/core/journey/journey.store.ts @@ -183,7 +183,6 @@ export const journeyStore: Writable = writable({ step: null, successful: false, response: null, - recaptchaAction: null, }); /** @@ -192,15 +191,20 @@ export const journeyStore: Writable = writable({ * @throws {Error} If no Journey Client configuration is available. * @returns {JourneyStore} Journey store API. */ -export function initialize(config?: JourneyClientConfig | undefined): JourneyStore { +export function initialize( + config?: JourneyClientConfig | undefined, + initializationOptions?: Record | null, +): JourneyStore { setJourneyClientConfig(config); const stack = initializeStack(); let stepNumber = 0; + let currentRecaptchaAction: string | null = null; // TODO: JourneyResult is not currently exported by Journey Client, so we define it here type JourneyResult = Awaited>; async function start(startOptions?: StartParam, recaptchaAction?: string) { + currentRecaptchaAction = recaptchaAction ?? startOptions?.journey ?? null; journeyStore.update((current) => ({ ...current, completed: false, @@ -209,7 +213,6 @@ export function initialize(config?: JourneyClientConfig | undefined): JourneySto step: null, successful: false, response: null, - recaptchaAction: recaptchaAction ?? startOptions?.journey ?? null, })); if (startOptions) { @@ -359,7 +362,6 @@ export function initialize(config?: JourneyClientConfig | undefined): JourneySto step: null, successful: false, response: null, - recaptchaAction: null, }); } @@ -390,7 +392,10 @@ export function initialize(config?: JourneyClientConfig | undefined): JourneySto stageName = stageAttribute; } - const callbackMetadata = buildCallbackMetadata(stepResult, initCheckValidation(), stageJson); + const callbackMetadata = buildCallbackMetadata(stepResult, initCheckValidation(), stageJson, { + ...initializationOptions, + recaptchaAction: currentRecaptchaAction, + }); const stepMetadata = buildStepMetadata(callbackMetadata, stageJson, stageName); // Iterate on a successful progression @@ -540,6 +545,7 @@ export function initialize(config?: JourneyClientConfig | undefined): JourneySto restartedStep, initCheckValidation(), stageJson, + { ...initializationOptions, recaptchaAction: currentRecaptchaAction }, ); const stepMetadata = buildStepMetadata(callbackMetadata, stageJson, stageName); diff --git a/core/journey/stages/_utilities/recaptcha.utilities.test.ts b/core/journey/stages/_utilities/recaptcha.utilities.test.ts new file mode 100644 index 00000000..e5f40163 --- /dev/null +++ b/core/journey/stages/_utilities/recaptcha.utilities.test.ts @@ -0,0 +1,283 @@ +/** + * + * Copyright © 2026 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + **/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { checkForHCaptcha, renderCaptcha, renderCaptchaInvisible } from './recaptcha.utilities'; + +vi.stubGlobal('window', globalThis); + +const makeCallbacks = () => ({ + onSuccess: vi.fn(), + onExpired: vi.fn(), + onError: vi.fn(), +}); + +describe('checkForHCaptcha', () => { + it('should return a match for h-captcha classname', () => { + expect(checkForHCaptcha('h-captcha')).toBeTruthy(); + }); + + it('should return null for g-recaptcha classname', () => { + expect(checkForHCaptcha('g-recaptcha')).toBeNull(); + }); +}); + +describe('renderCaptcha (visible mode)', () => { + beforeEach(() => { + vi.stubGlobal('hcaptcha', undefined); + vi.stubGlobal('grecaptcha', undefined); + }); + + it('should call hcaptcha.render when provider is hcaptcha and window.hcaptcha is available', () => { + const mockRender = vi.fn().mockReturnValue('widget-id-1'); + vi.stubGlobal('hcaptcha', { render: mockRender }); + const { onSuccess, onExpired, onError } = makeCallbacks(); + + renderCaptcha({ + nameOfCaptcha: 'hcaptcha', + siteKey: 'hcaptcha-site-key', + onSuccess, + onExpired, + onError, + }); + + expect(mockRender).toHaveBeenCalledWith('fr-recaptcha', { + sitekey: 'hcaptcha-site-key', + callback: onSuccess, + 'expired-callback': onExpired, + 'chalexpired-callback': onExpired, + 'error-callback': onError, + }); + }); + + it('should call grecaptcha.render when provider is grecaptcha and window.grecaptcha is available', () => { + const mockRender = vi.fn().mockReturnValue('widget-id-2'); + vi.stubGlobal('grecaptcha', { render: mockRender }); + const { onSuccess, onExpired, onError } = makeCallbacks(); + + renderCaptcha({ + nameOfCaptcha: 'grecaptcha', + siteKey: 'grecaptcha-site-key', + onSuccess, + onExpired, + onError, + }); + + expect(mockRender).toHaveBeenCalledWith('fr-recaptcha', { + sitekey: 'grecaptcha-site-key', + callback: onSuccess, + 'expired-callback': onExpired, + }); + }); + + it('should not throw if hcaptcha is not loaded', () => { + const { onSuccess, onExpired, onError } = makeCallbacks(); + expect(() => + renderCaptcha({ nameOfCaptcha: 'hcaptcha', siteKey: 'key', onSuccess, onExpired, onError }), + ).not.toThrow(); + }); + + it('should not throw if grecaptcha is not loaded', () => { + const { onSuccess, onExpired, onError } = makeCallbacks(); + expect(() => + renderCaptcha({ nameOfCaptcha: 'grecaptcha', siteKey: 'key', onSuccess, onExpired, onError }), + ).not.toThrow(); + }); +}); + +describe('renderCaptchaInvisible (invisible mode)', () => { + beforeEach(() => { + vi.stubGlobal('hcaptcha', undefined); + vi.stubGlobal('grecaptcha', undefined); + }); + + it('should render and execute hcaptcha invisible when window.hcaptcha is available', () => { + const mockRender = vi.fn(); + const mockExecute = vi.fn(); + vi.stubGlobal('hcaptcha', { render: mockRender, execute: mockExecute }); + const { onSuccess, onExpired, onError } = makeCallbacks(); + + renderCaptchaInvisible({ + nameOfCaptcha: 'hcaptcha', + siteKey: 'hcaptcha-site-key', + onSuccess, + onExpired, + onError, + }); + + expect(mockRender).toHaveBeenCalledWith('fr-recaptcha', { + sitekey: 'hcaptcha-site-key', + size: 'invisible', + callback: onSuccess, + 'expired-callback': onExpired, + 'chalexpired-callback': onExpired, + 'error-callback': onError, + }); + expect(mockExecute).toHaveBeenCalledOnce(); + }); + + it('should render and execute grecaptcha invisible when window.grecaptcha is available', () => { + const mockRender = vi.fn().mockReturnValue('widget-id-invisible'); + const mockExecute = vi.fn(); + const mockReady = vi.fn((fn: () => void) => fn()); + vi.stubGlobal('grecaptcha', { ready: mockReady, render: mockRender, execute: mockExecute }); + const { onSuccess, onExpired, onError } = makeCallbacks(); + + renderCaptchaInvisible({ + nameOfCaptcha: 'grecaptcha', + siteKey: 'grecaptcha-site-key', + onSuccess, + onExpired, + onError, + }); + + expect(mockReady).toHaveBeenCalledOnce(); + expect(mockRender).toHaveBeenCalledWith('fr-recaptcha', { + sitekey: 'grecaptcha-site-key', + size: 'invisible', + callback: onSuccess, + 'expired-callback': onExpired, + 'error-callback': onError, + }); + expect(mockExecute).toHaveBeenCalledWith('widget-id-invisible'); + }); + + it('should not throw if hcaptcha is not loaded', () => { + const { onSuccess, onExpired, onError } = makeCallbacks(); + expect(() => + renderCaptchaInvisible({ + nameOfCaptcha: 'hcaptcha', + siteKey: 'key', + onSuccess, + onExpired, + onError, + }), + ).not.toThrow(); + }); + + it('should not throw if grecaptcha is not loaded', () => { + const { onSuccess, onExpired, onError } = makeCallbacks(); + expect(() => + renderCaptchaInvisible({ + nameOfCaptcha: 'grecaptcha', + siteKey: 'key', + onSuccess, + onExpired, + onError, + }), + ).not.toThrow(); + }); +}); + +describe('renderCaptcha — Enterprise namespace', () => { + beforeEach(() => { + vi.stubGlobal('grecaptcha', undefined); + }); + + it('calls enterprise.render when grecaptcha.enterprise is present', () => { + const mockRender = vi.fn().mockReturnValue('enterprise-widget-id'); + vi.stubGlobal('grecaptcha', { enterprise: { render: mockRender } }); + const { onSuccess, onExpired, onError } = makeCallbacks(); + + renderCaptcha({ + nameOfCaptcha: 'grecaptcha', + siteKey: 'enterprise-site-key', + onSuccess, + onExpired, + onError, + }); + + expect(mockRender).toHaveBeenCalledWith('fr-recaptcha', { + sitekey: 'enterprise-site-key', + callback: onSuccess, + 'expired-callback': onExpired, + }); + }); + + it('calls classic render when enterprise namespace is absent', () => { + const mockRender = vi.fn().mockReturnValue('classic-widget-id'); + vi.stubGlobal('grecaptcha', { render: mockRender }); + const { onSuccess, onExpired, onError } = makeCallbacks(); + + renderCaptcha({ + nameOfCaptcha: 'grecaptcha', + siteKey: 'classic-site-key', + onSuccess, + onExpired, + onError, + }); + + expect(mockRender).toHaveBeenCalledWith('fr-recaptcha', { + sitekey: 'classic-site-key', + callback: onSuccess, + 'expired-callback': onExpired, + }); + }); +}); + +describe('renderCaptchaInvisible — Enterprise namespace', () => { + beforeEach(() => { + vi.stubGlobal('grecaptcha', undefined); + }); + + it('calls enterprise.ready/render/execute when enterprise namespace is present', () => { + const mockRender = vi.fn().mockReturnValue('enterprise-invisible-id'); + const mockExecute = vi.fn(); + const mockReady = vi.fn((fn: () => void) => fn()); + vi.stubGlobal('grecaptcha', { + enterprise: { ready: mockReady, render: mockRender, execute: mockExecute }, + }); + const { onSuccess, onExpired, onError } = makeCallbacks(); + + renderCaptchaInvisible({ + nameOfCaptcha: 'grecaptcha', + siteKey: 'enterprise-site-key', + onSuccess, + onExpired, + onError, + }); + + expect(mockReady).toHaveBeenCalledOnce(); + expect(mockRender).toHaveBeenCalledWith('fr-recaptcha', { + sitekey: 'enterprise-site-key', + size: 'invisible', + callback: onSuccess, + 'expired-callback': onExpired, + 'error-callback': onError, + }); + expect(mockExecute).toHaveBeenCalledWith('enterprise-invisible-id'); + }); + + it('falls back to classic ready/render/execute when enterprise namespace is absent', () => { + const mockRender = vi.fn().mockReturnValue('classic-invisible-id'); + const mockExecute = vi.fn(); + const mockReady = vi.fn((fn: () => void) => fn()); + vi.stubGlobal('grecaptcha', { ready: mockReady, render: mockRender, execute: mockExecute }); + const { onSuccess, onExpired, onError } = makeCallbacks(); + + renderCaptchaInvisible({ + nameOfCaptcha: 'grecaptcha', + siteKey: 'classic-site-key', + onSuccess, + onExpired, + onError, + }); + + expect(mockReady).toHaveBeenCalledOnce(); + expect(mockRender).toHaveBeenCalledWith('fr-recaptcha', { + sitekey: 'classic-site-key', + size: 'invisible', + callback: onSuccess, + 'expired-callback': onExpired, + 'error-callback': onError, + }); + expect(mockExecute).toHaveBeenCalledWith('classic-invisible-id'); + }); +}); diff --git a/core/journey/stages/_utilities/recaptcha.utilities.ts b/core/journey/stages/_utilities/recaptcha.utilities.ts index 0f420770..f93623b2 100644 --- a/core/journey/stages/_utilities/recaptcha.utilities.ts +++ b/core/journey/stages/_utilities/recaptcha.utilities.ts @@ -7,57 +7,100 @@ * **/ -import type { ReCaptchaCallback } from '@forgerock/journey-client/types'; +import { resolveGrecaptcha } from '$journey/_utilities/captcha.utilities'; +export { resolveGrecaptcha }; /* - * Because hcaptch and grecaptcha would + * Because hcaptcha and grecaptcha would * both be loaded on the page, we need to just leverage * the classnames to determine which one is being rendered. * This is retrieved from the step */ + export function checkForHCaptcha(captchaClassname: string) { return captchaClassname.match('h-captcha'); } +interface HCaptcha { + render: (id: string, options: Record) => void; + execute: () => void; +} + +// hcaptcha has no @types package — declare minimal interface and cast via unknown +const hcaptcha = () => (window as Window & { hcaptcha?: HCaptcha }).hcaptcha; + export function renderCaptcha({ nameOfCaptcha, siteKey, + elementId = 'fr-recaptcha', + onSuccess, + onExpired, + onError, }: { nameOfCaptcha: 'hcaptcha' | 'grecaptcha'; siteKey: string; + elementId?: string; + onSuccess: (token: string) => void; + onExpired: () => void; + onError: () => void; }) { - if (nameOfCaptcha === 'hcaptcha' && window.hcaptcha) { - return window.hcaptcha.render('fr-recaptcha', { + const hc = hcaptcha(); + if (nameOfCaptcha === 'hcaptcha' && hc) { + return hc.render(elementId, { sitekey: siteKey, - callback: 'frHandleCaptcha', - 'expired-callback': 'frHandleExpiredCallback', - 'chalexpired-callback': 'frHandleExpiredCallback', - 'error-callback': 'frHandleErrorCallback', + callback: onSuccess, + 'expired-callback': onExpired, + 'chalexpired-callback': onExpired, + 'error-callback': onError, }); } - if (nameOfCaptcha === 'grecaptcha' && window.grecaptcha) { - return window.grecaptcha.render('fr-recaptcha', { + const grc = resolveGrecaptcha(); + if (nameOfCaptcha === 'grecaptcha' && grc) { + return grc.render(elementId, { sitekey: siteKey, - callback: window.frHandleCaptcha, // grecaptcha uses different types so passing real function here - 'expired-callback': window.frHandleExpiredCallback, + callback: onSuccess, + 'expired-callback': onExpired, }); } } -export function handleCaptchaError(callback: ReCaptchaCallback) { - const siteKey = callback?.getSiteKey() ?? ''; - const className: string = - callback?.getOutputByName('captchaDivClass', 'h-captcha') ?? 'h-captcha'; - - if (checkForHCaptcha(className)) { - return () => renderCaptcha({ nameOfCaptcha: 'hcaptcha', siteKey }); - } else { - return () => renderCaptcha({ nameOfCaptcha: 'grecaptcha', siteKey }); +export function renderCaptchaInvisible({ + nameOfCaptcha, + siteKey, + onSuccess, + onExpired, + onError, +}: { + nameOfCaptcha: 'hcaptcha' | 'grecaptcha'; + siteKey: string; + onSuccess: (token: string) => void; + onExpired: () => void; + onError: () => void; +}) { + const hc = hcaptcha(); + if (nameOfCaptcha === 'hcaptcha' && hc) { + hc.render('fr-recaptcha', { + sitekey: siteKey, + size: 'invisible', + callback: onSuccess, + 'expired-callback': onExpired, + 'chalexpired-callback': onExpired, + 'error-callback': onError, + }); + hc.execute(); + return; + } + const grc = resolveGrecaptcha(); + if (nameOfCaptcha === 'grecaptcha' && grc) { + grc.ready(function () { + const widgetId = grc.render('fr-recaptcha', { + sitekey: siteKey, + size: 'invisible', + callback: onSuccess, + 'expired-callback': onExpired, + 'error-callback': onError, + }); + grc.execute(widgetId); + }); } -} - -export function handleCaptchaToken(callback: ReCaptchaCallback) { - return (token: string) => { - callback?.setResult(token); - }; } diff --git a/core/locale.store.ts b/core/locale.store.ts index 1c03d095..1537b069 100644 --- a/core/locale.store.ts +++ b/core/locale.store.ts @@ -22,6 +22,7 @@ export const stringsSchema = z alreadyHaveAnAccount: z.string(), backToDefault: z.string(), backToLogin: z.string(), + captchaError: z.string(), dontHaveAnAccount: z.string(), closeModal: z.string(), charactersCannotRepeatMoreThan: z.string(), diff --git a/core/locales/us/en/index.json b/core/locales/us/en/index.json index 4251afb8..0b4d45a1 100644 --- a/core/locales/us/en/index.json +++ b/core/locales/us/en/index.json @@ -2,6 +2,7 @@ "alreadyHaveAnAccount": "Already have an account? Sign in here!", "backToDefault": "Back to Sign In", "backToLogin": "Back to Sign In", + "captchaError": "CAPTCHA verification failed. Please try again.", "closeModal": "Close", "charactersCannotRepeatMoreThan": "A character cannot repeat more than {max} times", "charactersCannotRepeatMoreThanCaseInsensitive": "A character cannot repeat more than {max} times (case insensitive)", diff --git a/e2e/tests/widget/inline/widget-inline.recaptcha.test.js b/e2e/tests/widget/inline/widget-inline.recaptcha.test.js new file mode 100644 index 00000000..70a7ffb1 --- /dev/null +++ b/e2e/tests/widget/inline/widget-inline.recaptcha.test.js @@ -0,0 +1,190 @@ +/** + * + * Copyright © 2026 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + **/ + +import { expect, test } from '@playwright/test'; + +import { asyncEvents } from '../../utilities/async-events.js'; + +async function blockRecaptchaScript(page) { + await page.route('**/recaptcha/**', (route) => route.abort()); +} + +async function stubGrecaptchaClassic(page) { + await blockRecaptchaScript(page); + await page.addInitScript(() => { + window.__grecaptchaRenderArgs = null; + window.grecaptcha = { + ready: (cb) => cb(), + render: (el, opts) => { + window.__grecaptchaRenderArgs = { el, opts }; + if (typeof opts.callback === 'function') { + opts.callback('classic-token'); + } else if (typeof window[opts.callback] === 'function') { + window[opts.callback]('classic-token'); + } + return 'widget-id'; + }, + execute: (siteKey, o) => { + window.__grecaptchaExecuteArgs = { siteKey, action: o?.action }; + return Promise.resolve('classic-token'); + }, + reset: () => undefined, + getResponse: () => 'classic-token', + }; + }); +} + +async function stubGrecaptchaEnterprise(page) { + await blockRecaptchaScript(page); + await page.addInitScript(() => { + window.__grecaptchaEnterpriseExecuteArgs = null; + window.grecaptcha = { + enterprise: { + ready: (cb) => cb(), + render: () => 'widget-id', + execute: (siteKey, o) => { + window.__grecaptchaEnterpriseExecuteArgs = { siteKey, action: o?.action }; + return Promise.resolve('enterprise-token'); + }, + reset: () => undefined, + getResponse: () => 'enterprise-token', + }, + }; + }); +} + +function routeAuthenticate(page, firstCallbacks, onSubmit) { + let count = 0; + return page.route('**/authenticate**', async (route) => { + count += 1; + if (count === 1) { + await route.fulfill({ + status: 200, + json: { authId: 'mock-captcha-auth-id', callbacks: firstCallbacks }, + }); + return; + } + const body = route.request().postDataJSON(); + onSubmit?.(body); + await route.fulfill({ + status: 200, + json: { + authId: 'mock-captcha-success', + callbacks: [ + { + type: 'TextOutputCallback', + output: [ + { name: 'message', value: 'Captcha submitted.' }, + { name: 'messageType', value: '0' }, + ], + _id: 0, + }, + ], + }, + }); + }); +} + +test('Classic ReCaptcha visible submits token from grecaptcha (inline)', async ({ page }) => { + await stubGrecaptchaClassic(page); + const submitted = []; + await routeAuthenticate( + page, + [ + { + type: 'ReCaptchaCallback', + output: [ + { name: 'recaptchaSiteKey', value: 'classic-site-key' }, + { name: 'captchaDivClass', value: 'g-recaptcha' }, + { name: 'reCaptchaV3', value: false }, + ], + input: [{ name: 'IDToken1', value: '' }], + _id: 0, + }, + ], + (body) => submitted.push(body), + ); + + const { clickButton, navigate } = asyncEvents(page); + await navigate('widget/inline?journey=TEST_Login'); + + await page.waitForFunction(() => window.__grecaptchaRenderArgs !== null); + await clickButton('Next', '/authenticate'); + await expect(page.getByText('Captcha submitted.')).toBeVisible(); + + expect(submitted[0].callbacks[0].input[0].value).toBe('classic-token'); +}); + +test('ReCaptchaEnterpriseCallback visible renders via enterprise namespace (inline)', async ({ + page, +}) => { + await stubGrecaptchaEnterprise(page); + await routeAuthenticate(page, [ + { + type: 'ReCaptchaEnterpriseCallback', + output: [ + { name: 'recaptchaSiteKey', value: 'enterprise-site-key' }, + { name: 'captchaApiUri', value: 'https://www.google.com/recaptcha/enterprise.js' }, + { name: 'captchaDivClass', value: 'g-recaptcha' }, + ], + input: [ + { name: 'IDToken1token', value: '' }, + { name: 'IDToken1action', value: '' }, + { name: 'IDToken1clientError', value: '' }, + { name: 'IDToken1payload', value: '' }, + ], + _id: 0, + }, + ]); + + const { navigate } = asyncEvents(page); + await navigate('widget/inline?journey=TEST_Login'); + + await expect(page.locator('#fr-recaptcha-enterprise')).toBeAttached(); +}); + +test('ReCaptchaEnterpriseCallback invisible executes with action and submits token (inline)', async ({ + page, +}) => { + await stubGrecaptchaEnterprise(page); + const submitted = []; + await routeAuthenticate( + page, + [ + { + type: 'ReCaptchaEnterpriseCallback', + output: [ + { name: 'recaptchaSiteKey', value: 'enterprise-site-key' }, + { name: 'captchaApiUri', value: 'https://www.google.com/recaptcha/enterprise.js' }, + { name: 'captchaDivClass', value: 'g-recaptcha' }, + ], + input: [ + { name: 'IDToken1token', value: '' }, + { name: 'IDToken1action', value: '' }, + { name: 'IDToken1clientError', value: '' }, + { name: 'IDToken1payload', value: '' }, + ], + _id: 0, + }, + ], + (body) => submitted.push(body), + ); + + const { clickButton, navigate } = asyncEvents(page); + await navigate('widget/inline?journey=TEST_Login&captchaMode=invisible&recaptchaAction=LOGIN'); + + await page.waitForFunction(() => window.__grecaptchaEnterpriseExecuteArgs !== null); + const args = await page.evaluate(() => window.__grecaptchaEnterpriseExecuteArgs); + expect(args).toEqual({ siteKey: 'enterprise-site-key', action: 'LOGIN' }); + + await clickButton('Next', '/authenticate'); + await expect(page.getByText('Captcha submitted.')).toBeVisible(); + expect(submitted[0].callbacks[0].input[0].value).toBe('enterprise-token'); + expect(submitted[0].callbacks[0].input[1].value).toBe('LOGIN'); +}); diff --git a/e2e/tests/widget/modal/widget-modal.recaptcha.test.js b/e2e/tests/widget/modal/widget-modal.recaptcha.test.js new file mode 100644 index 00000000..3fb71c52 --- /dev/null +++ b/e2e/tests/widget/modal/widget-modal.recaptcha.test.js @@ -0,0 +1,191 @@ +/** + * + * Copyright © 2026 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + **/ + +import { expect, test } from '@playwright/test'; + +import { asyncEvents } from '../../utilities/async-events.js'; + +async function blockRecaptchaScript(page) { + await page.route('**/recaptcha/**', (route) => route.abort()); +} + +async function stubGrecaptchaClassic(page) { + await blockRecaptchaScript(page); + await page.addInitScript(() => { + window.__grecaptchaRenderArgs = null; + window.grecaptcha = { + ready: (cb) => cb(), + render: (el, opts) => { + window.__grecaptchaRenderArgs = { el, opts }; + if (typeof opts.callback === 'function') { + opts.callback('classic-token'); + } else if (typeof window[opts.callback] === 'function') { + window[opts.callback]('classic-token'); + } + return 'widget-id'; + }, + execute: (siteKey, o) => { + window.__grecaptchaExecuteArgs = { siteKey, action: o?.action }; + return Promise.resolve('classic-token'); + }, + reset: () => undefined, + getResponse: () => 'classic-token', + }; + }); +} + +async function stubGrecaptchaEnterprise(page) { + await blockRecaptchaScript(page); + await page.addInitScript(() => { + window.__grecaptchaEnterpriseExecuteArgs = null; + window.grecaptcha = { + enterprise: { + ready: (cb) => cb(), + render: () => 'widget-id', + execute: (siteKey, o) => { + window.__grecaptchaEnterpriseExecuteArgs = { siteKey, action: o?.action }; + return Promise.resolve('enterprise-token'); + }, + reset: () => undefined, + getResponse: () => 'enterprise-token', + }, + }; + }); +} + +function routeAuthenticate(page, firstCallbacks, onSubmit) { + let count = 0; + return page.route('**/authenticate**', async (route) => { + count += 1; + if (count === 1) { + await route.fulfill({ + status: 200, + json: { authId: 'mock-captcha-auth-id', callbacks: firstCallbacks }, + }); + return; + } + const body = route.request().postDataJSON(); + onSubmit?.(body); + await route.fulfill({ + status: 200, + json: { + authId: 'mock-captcha-success', + callbacks: [ + { + type: 'TextOutputCallback', + output: [ + { name: 'message', value: 'Captcha submitted.' }, + { name: 'messageType', value: '0' }, + ], + _id: 0, + }, + ], + }, + }); + }); +} + +test('Classic ReCaptcha visible submits token from grecaptcha', async ({ page }) => { + await stubGrecaptchaClassic(page); + const submitted = []; + await routeAuthenticate( + page, + [ + { + type: 'ReCaptchaCallback', + output: [ + { name: 'recaptchaSiteKey', value: 'classic-site-key' }, + { name: 'captchaDivClass', value: 'g-recaptcha' }, + { name: 'reCaptchaV3', value: false }, + ], + input: [{ name: 'IDToken1', value: '' }], + _id: 0, + }, + ], + (body) => submitted.push(body), + ); + + const { clickButton, navigate } = asyncEvents(page); + await navigate('widget/modal?journey=TEST_Login'); + await clickButton('Open Login Modal', '/authenticate'); + + await page.waitForFunction(() => window.__grecaptchaRenderArgs !== null); + await clickButton('Next', '/authenticate'); + await expect(page.getByText('Captcha submitted.')).toBeVisible(); + + expect(submitted[0].callbacks[0].input[0].value).toBe('classic-token'); +}); + +test('ReCaptchaEnterpriseCallback visible renders via enterprise namespace', async ({ page }) => { + await stubGrecaptchaEnterprise(page); + await routeAuthenticate(page, [ + { + type: 'ReCaptchaEnterpriseCallback', + output: [ + { name: 'recaptchaSiteKey', value: 'enterprise-site-key' }, + { name: 'captchaApiUri', value: 'https://www.google.com/recaptcha/enterprise.js' }, + { name: 'captchaDivClass', value: 'g-recaptcha' }, + ], + input: [ + { name: 'IDToken1token', value: '' }, + { name: 'IDToken1action', value: '' }, + { name: 'IDToken1clientError', value: '' }, + { name: 'IDToken1payload', value: '' }, + ], + _id: 0, + }, + ]); + + const { clickButton, navigate } = asyncEvents(page); + await navigate('widget/modal?journey=TEST_Login'); + await clickButton('Open Login Modal', '/authenticate'); + + await expect(page.locator('#fr-recaptcha-enterprise')).toBeAttached(); +}); + +test('ReCaptchaEnterpriseCallback invisible executes with action and submits token', async ({ + page, +}) => { + await stubGrecaptchaEnterprise(page); + const submitted = []; + await routeAuthenticate( + page, + [ + { + type: 'ReCaptchaEnterpriseCallback', + output: [ + { name: 'recaptchaSiteKey', value: 'enterprise-site-key' }, + { name: 'captchaApiUri', value: 'https://www.google.com/recaptcha/enterprise.js' }, + { name: 'captchaDivClass', value: 'g-recaptcha' }, + ], + input: [ + { name: 'IDToken1token', value: '' }, + { name: 'IDToken1action', value: '' }, + { name: 'IDToken1clientError', value: '' }, + { name: 'IDToken1payload', value: '' }, + ], + _id: 0, + }, + ], + (body) => submitted.push(body), + ); + + const { clickButton, navigate } = asyncEvents(page); + await navigate('widget/modal?journey=TEST_Login&captchaMode=invisible&recaptchaAction=LOGIN'); + await clickButton('Open Login Modal', '/authenticate'); + + await page.waitForFunction(() => window.__grecaptchaEnterpriseExecuteArgs !== null); + const args = await page.evaluate(() => window.__grecaptchaEnterpriseExecuteArgs); + expect(args).toEqual({ siteKey: 'enterprise-site-key', action: 'LOGIN' }); + + await clickButton('Next', '/authenticate'); + await expect(page.getByText('Captcha submitted.')).toBeVisible(); + expect(submitted[0].callbacks[0].input[0].value).toBe('enterprise-token'); + expect(submitted[0].callbacks[0].input[1].value).toBe('LOGIN'); +}); diff --git a/packages/login-widget/README.md b/packages/login-widget/README.md index 3bc2c276..27354878 100644 --- a/packages/login-widget/README.md +++ b/packages/login-widget/README.md @@ -499,6 +499,28 @@ config.set({ }); ``` +### CAPTCHA Configuration + +AM does not signal invisible mode in the callback payload for either `ReCaptchaCallback` or `ReCaptchaEnterpriseCallback`. Use the `captcha` option to configure invisible rendering: + +```js +config.set({ + captcha: { + mode: 'invisible', // 'normal' (default) | 'invisible' + }, +}); +``` + +**Script loading:** The widget does not inject CAPTCHA scripts. Load them in your host page before the widget mounts: + +```html + + + + + +``` + ## Supported Callbacks The widget supports the following ForgeRock callbacks: @@ -511,7 +533,7 @@ The widget supports the following ForgeRock callbacks: - Social login (Apple, Facebook, Google) - Email suspend ("magic links") - Device profile -- reCAPTCHA / hCaptcha +- reCAPTCHA v2 (visible + invisible), reCAPTCHA Enterprise, hCaptcha (visible + invisible) - QR codes - Ping Protect @@ -525,4 +547,4 @@ This project is licensed under the MIT License — see the [LICENSE](https://git --- -© Copyright 2022-2025 Ping Identity Corporation. All Rights Reserved. +© Copyright 2022-2026 Ping Identity Corporation. All Rights Reserved. diff --git a/packages/login-widget/package.json b/packages/login-widget/package.json index 7c831081..0fed0920 100644 --- a/packages/login-widget/package.json +++ b/packages/login-widget/package.json @@ -57,8 +57,8 @@ "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "@vueless/storybook-dark-mode": "^10.0.4", - "autoprefixer": "^10.4.17", "axe-playwright": "^1.2.3", + "autoprefixer": "^10.4.17", "babel-loader": "^9.1.2", "chromatic": "^10.9.1", "playwright": "^1.44.0", diff --git a/packages/login-widget/src/lib/_utilities/api.utilities.ts b/packages/login-widget/src/lib/_utilities/api.utilities.ts index b3a3bebc..a44d0266 100644 --- a/packages/login-widget/src/lib/_utilities/api.utilities.ts +++ b/packages/login-widget/src/lib/_utilities/api.utilities.ts @@ -95,7 +95,9 @@ export function widgetApiFactory(componentApi: ReturnType) /** * Initialize all the stores. */ - journeyStore = initializeJourney(options?.journeyClient); + journeyStore = initializeJourney(options?.journeyClient, { + ...(options?.captcha && { captcha: options.captcha }), + }); oauthStore = initializeOauth(options?.forgerock); userStore = initializeUser(options?.forgerock); @@ -137,7 +139,9 @@ export function widgetApiFactory(componentApi: ReturnType) * Initialize the stores and ensure both variables point to the same reference. * Variables with _ are the reactive version of the original variable from above. */ - journeyStore = initializeJourney(setOptions?.journeyClient); + journeyStore = initializeJourney(setOptions?.journeyClient, { + ...(setOptions?.captcha && { captcha: setOptions.captcha }), + }); oauthStore = initializeOauth(setOptions?.forgerock); userStore = initializeUser(setOptions?.forgerock); diff --git a/packages/login-widget/src/lib/interfaces.ts b/packages/login-widget/src/lib/interfaces.ts index 1a13991a..65bfc9ae 100644 --- a/packages/login-widget/src/lib/interfaces.ts +++ b/packages/login-widget/src/lib/interfaces.ts @@ -17,7 +17,6 @@ import type { partialConfigSchema } from '$core/sdk.config'; import type { partialStyleSchema } from '$core/style.store'; import type { UserStoreValue } from '$core/user/user.store'; import type { journeyConfigSchema } from '$journey/config.store'; -// Import store types import type { JourneyStoreValue } from '$journey/journey.interfaces'; import type { journeyClientConfigSchema } from '$journey/journey.store'; @@ -55,6 +54,7 @@ export interface Protect { } export interface WidgetConfigOptions { + captcha?: { mode?: 'visible' | 'invisible' }; forgerock?: z.infer; journeyClient?: z.infer; content?: z.infer;