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
11 changes: 11 additions & 0 deletions .changeset/add-captcha-enterprise-invisible.md
Original file line number Diff line number Diff line change
@@ -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 `<Alert type="error">` 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.
7 changes: 1 addition & 6 deletions apps/login-app/src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!--

Copyright © 2025 Ping Identity Corporation. All right reserved.
Copyright © 2025 - 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.
Expand All @@ -23,11 +23,6 @@
<title>Login Application</title>
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script
src="https://www.google.com/recaptcha/api.js?render=6LdIqXMoAAAAAP4APBlw7_5WDeMTlAAQJf42rPWz"
async
></script>

<style>
/**
* Self-hosting Open Sans for better privacy, potential performance and control
Expand Down
20 changes: 11 additions & 9 deletions apps/login-app/src/routes/(app)/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<!--

Copyright © 2025-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.

-->

<script lang="ts">
Expand All @@ -28,12 +28,15 @@
const formPostEntryParam = $page.url.searchParams.get('form_post_entry');
const journeyParam = $page.url.searchParams.get('journey');
const suspendedIdParam = $page.url.searchParams.get('suspendedId');
const captchaModeParam = $page.url.searchParams.get('captchaMode') as
| 'visible'
| 'invisible'
| null;

const journeyStore: JourneyStore = initializeJourney({
serverConfig: {
wellknown: data.wellknown,
},
});
const journeyStore: JourneyStore = initializeJourney(
{ serverConfig: { wellknown: data.wellknown } },
captchaModeParam ? { captcha: { mode: captchaModeParam } } : null,
);

let hasSubmitted = false;
let redirectForm: HTMLFormElement | null = null;
Expand Down Expand Up @@ -67,7 +70,6 @@
journeyStore.start({
journey: journeyParam || authIndexValue || 'Login',
query,
// recaptchaAction: 'MyTestAction',
});
}
});
Expand Down
5 changes: 5 additions & 0 deletions apps/login-app/src/routes/e2e/widget/inline/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
let authIndexValueParam = $page.url.searchParams.get('authIndexValue');
let journeyParam = $page.url.searchParams.get('journey');
let recaptchaParam = $page.url.searchParams.get('recaptchaAction');
let captchaModeParam = $page.url.searchParams.get('captchaMode') as
| 'visible'
| 'invisible'
| null;
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
let formEl: HTMLDivElement;
let userEvent: UserStoreValue | null;
Expand Down Expand Up @@ -52,6 +56,7 @@
'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration',
},
},
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
forgerock: {
clientId: 'WebOAuthClient',
redirectUri: `${window.location.origin}/callback`,
Expand Down
5 changes: 5 additions & 0 deletions apps/login-app/src/routes/e2e/widget/modal/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
let authIndexValueParam = $page.url.searchParams.get('authIndexValue');
let journeyParam = $page.url.searchParams.get('journey');
let recaptchaParam = $page.url.searchParams.get('recaptchaAction');
let captchaModeParam = $page.url.searchParams.get('captchaMode') as
| 'visible'
| 'invisible'
| null;
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
let showPasswordParam = $page.url.searchParams.get('showPassword') as
| 'none'
Expand Down Expand Up @@ -127,6 +131,7 @@
header: false,
},
},
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
});

componentEvents = component();
Expand Down
12 changes: 12 additions & 0 deletions core/journey/_utilities/callback-mapper.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import PingProtectInitialize from '$journey/callbacks/ping-protect-initialize/ping-protect-initialize.svelte';
import PollingWait from '$journey/callbacks/polling-wait/polling-wait.svelte';
import Recaptcha from '$journey/callbacks/recaptcha/recaptcha.svelte';
import RecaptchaEnterprise from '$journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte';
import Redirect from '$journey/callbacks/redirect/redirect.svelte';
import SelectIdp from '$journey/callbacks/select-idp/select-idp.svelte';
import StringAttributeInput from '$journey/callbacks/string-attribute/string-attribute-input.svelte';
Expand All @@ -58,6 +59,7 @@
PingOneProtectInitializeCallback,
PollingWaitCallback,
ReCaptchaCallback,
ReCaptchaEnterpriseCallback,
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.

So we're now creating a distinct ReCaptchaEnterpriseCallback.

Because now in the new "Recaptcha world" there is no significant difference between this for google, this feels like we're diverting our structure away from the actual core Domain (ReCaptcha)

I think maybe what we need to do is align more against HCaptcha vs Google ReCaptcha.

Since GoogleReCaptcha has Enterprise, and others, but they are all under the domain of Enterprise Recaptcha, it can be a bit more resilient to future changes from Google. If they rename, add something, we have just Google based ReCaptcha component that handles that logic, we operate there.

HCaptcha being distinct creates the same separation.

My Concern is that we are creating the separation "Enterprise" vs not, and then if HCaptcha has or adds an Enterprise, are we going to put all Enterprise based logic in the recaptcha-enterprise component?

It feels like the abstraction may be on the wrong thing and should be on the Google vs HCaptcha.

This may help clean up some of the mess I originally created when writing this because now the logic for each is coupled (correctly) to the component that it is.

The issue this does create is in this file, since we check the type, we'd need one more layer of logic which says "Which type" of Recaptcha is this.

I think in this case, this second "check" is worth doing for the separation we can create.

Thoughts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point. The component split follows AM's callback structure, not our design. AM sends ReCaptchaEnterpriseCallback (different from ReCaptchaCallback) for the Enterprise captcha node, and it has a different API too. One component for both would need duck-typing or internal branching, which can be harder to read and test. hCaptcha only uses ReCaptchaCallback, no Enterprise variant, so it stays in the classic component. If AM adds an hCaptcha Enterprise callback later, it could get its own component or reuse the enterprise one.

If AM is our source of truth for callback components, which it mostly is (one exception aside), a dedicated ReCaptchaEnterpriseCallback makes more sense. That said, I get your point about the abstraction belonging at the ReCaptcha vs. hCaptcha level. It really comes down to following AM's structure or creating our own for captcha callbacks.

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.

I hear this, but i'm not sure that following AM nodes here is the right decision since the real implementation is more specific to the provider. I'm not hard set on this but just my thoughts

RedirectCallback,
SelectIdPCallback,
SuspendedTextOutputCallback,
Expand Down Expand Up @@ -113,6 +115,7 @@
let _MetadataCallback: MetadataCallback;
let _DeviceProfileCallback: DeviceProfileCallback;
let _RecaptchaCallback: ReCaptchaCallback;
let _RecaptchaEnterpriseCallback: ReCaptchaEnterpriseCallback;
let _PingProtectEvaluation: PingOneProtectEvaluationCallback;
let _PingProtectInitialize: PingOneProtectInitializeCallback;
let _BaseCallback: BaseCallback;
Expand Down Expand Up @@ -142,6 +145,9 @@
case callbackType.ReCaptchaCallback:
_RecaptchaCallback = props.callback as ReCaptchaCallback;
break;
case callbackType.ReCaptchaEnterpriseCallback:
_RecaptchaEnterpriseCallback = props.callback as ReCaptchaEnterpriseCallback;
break;
case callbackType.PasswordCallback:
_PasswordCallback = props.callback as PasswordCallback;
break;
Expand Down Expand Up @@ -319,6 +325,12 @@
callback: _RecaptchaCallback,
}}
<Recaptcha {...newProps} />
{:else if cbType === callbackType.ReCaptchaEnterpriseCallback}
{@const newProps = {
...props,
callback: _RecaptchaEnterpriseCallback,
}}
<RecaptchaEnterprise {...newProps} />
{:else if cbType === callbackType.PingOneProtectEvaluationCallback}
{@const newProps = {
...props,
Expand Down
106 changes: 106 additions & 0 deletions core/journey/_utilities/captcha.utilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
*
* 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 { loadCaptchaScript, resolveGrecaptcha } from './captcha.utilities';

vi.stubGlobal('window', globalThis);

describe('resolveGrecaptcha', () => {
beforeEach(() => {
vi.stubGlobal('grecaptcha', undefined);
});

it('returns grecaptcha.enterprise when enterprise namespace is present', () => {
const enterprise = { ready: vi.fn(), render: vi.fn(), execute: vi.fn() };
vi.stubGlobal('grecaptcha', { enterprise });
expect(resolveGrecaptcha()).toBe(enterprise);
});

it('falls back to window.grecaptcha when enterprise namespace is absent', () => {
const classic = { ready: vi.fn(), render: vi.fn(), execute: vi.fn() };
vi.stubGlobal('grecaptcha', classic);
expect(resolveGrecaptcha()).toBe(classic);
});
});

describe('loadCaptchaScript', () => {
let appendedScript: {
src: string;
async: boolean;
onload: (() => void) | null;
onerror: (() => void) | null;
};
let mockQuerySelector: ReturnType<typeof vi.fn>;
let mockAppendChild: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.stubGlobal('grecaptcha', undefined);
appendedScript = { src: '', async: false, onload: null, onerror: null };
mockAppendChild = vi.fn();
mockQuerySelector = vi.fn().mockReturnValue(null);
vi.stubGlobal('document', {
querySelector: mockQuerySelector,
createElement: () => appendedScript,
head: { appendChild: mockAppendChild },
});
});

it('injects a new script tag and resolves on load for hcaptcha', async () => {
const promise = loadCaptchaScript({
src: 'https://js.hcaptcha.com/1/api.js',
provider: 'hcaptcha',
});
expect(appendedScript.src).toBe('https://js.hcaptcha.com/1/api.js');
expect(mockAppendChild).toHaveBeenCalledOnce();
appendedScript.onload?.();
await promise;
});

it('injects a new script tag and waits for grecaptcha.ready on load', async () => {
const mockReady = vi.fn((fn: () => void) => fn());
const promise = loadCaptchaScript({
src: 'https://www.google.com/recaptcha/api.js',
provider: 'grecaptcha',
});
expect(appendedScript.src).toBe('https://www.google.com/recaptcha/api.js');
vi.stubGlobal('grecaptcha', { ready: mockReady });
appendedScript.onload?.();
await promise;
expect(mockReady).toHaveBeenCalledOnce();
});

it('reuses existing script and resolves immediately for hcaptcha', async () => {
mockQuerySelector.mockReturnValue({ addEventListener: vi.fn() });
await loadCaptchaScript({ src: 'https://js.hcaptcha.com/1/api.js', provider: 'hcaptcha' });
expect(mockAppendChild).not.toHaveBeenCalled();
});

it('reuses existing script and calls grecaptcha.ready when already present', async () => {
const mockReady = vi.fn((fn: () => void) => fn());
vi.stubGlobal('grecaptcha', { ready: mockReady });
mockQuerySelector.mockReturnValue({ addEventListener: vi.fn() });
await loadCaptchaScript({
src: 'https://www.google.com/recaptcha/api.js',
provider: 'grecaptcha',
});
expect(mockAppendChild).not.toHaveBeenCalled();
expect(mockReady).toHaveBeenCalledOnce();
});

it('rejects when the script fails to load', async () => {
const promise = loadCaptchaScript({
src: 'https://bad.example.com/api.js',
provider: 'hcaptcha',
});
appendedScript.onerror?.();
await expect(promise).rejects.toThrow('Failed to load CAPTCHA script');
});
});
74 changes: 74 additions & 0 deletions core/journey/_utilities/captcha.utilities.ts
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.

Since this is a new file, I'd like to move towards the pattern of utilities being pure, stateless functions. Since these manage "effects", like reading from the window object or interacting with the DOM, let's just reorganize/rename this as an effect.

Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
*
* 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.
*
**/

/**
* Resolves the active reCAPTCHA namespace. Prefers `grecaptcha.enterprise`
* (loaded by enterprise.js) and falls back to the classic `grecaptcha` global
* (loaded by api.js or auto-migrated keys). This makes all call sites
* transparent to which script the consumer loaded.
*/
export function resolveGrecaptcha(): ReCaptchaV2.ReCaptcha {
const grecaptcha = window.grecaptcha as ReCaptchaV2.ReCaptcha & {
enterprise?: ReCaptchaV2.ReCaptcha;
};
return grecaptcha?.enterprise ?? window.grecaptcha;
}

/**
* Injects a CAPTCHA script tag into <head> and resolves when the provider API
* is ready to use. No-ops if the provider API is already present on window (e.g.
* pre-loaded by consumer or stubbed in tests), or if a script with the same src
* is already in the document. For grecaptcha, waits for grecaptcha.ready().
*/
export function loadCaptchaScript({
src,
provider,
}: {
src: string;
provider: 'grecaptcha' | 'hcaptcha';
}): Promise<void> {
if (provider === 'hcaptcha') {
const hc = (window as Window & { hcaptcha?: unknown }).hcaptcha;
if (hc) return Promise.resolve();
} else {
const grc = resolveGrecaptcha();
if (grc) return new Promise((resolve) => grc.ready(() => resolve()));
}

const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
if (existing) {
return new Promise((resolve) => {
if (provider === 'hcaptcha') {
resolve();
} else {
const grc = resolveGrecaptcha();
if (grc) {
grc.ready(() => resolve());
} else {
existing.addEventListener('load', () => resolve(), { once: true });
}
}
});
}
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onerror = () => reject(new Error(`Failed to load CAPTCHA script: ${src}`));
script.onload = () => {
if (provider === 'hcaptcha') {
resolve();
} else {
const grc = resolveGrecaptcha();
grc ? grc.ready(() => resolve()) : resolve();
}
};
document.head.appendChild(script);
});
}
18 changes: 16 additions & 2 deletions core/journey/_utilities/metadata.utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,30 @@ import type { BaseCallback, JourneyStep } from '@forgerock/journey-client/types'

import type { CallbackMetadata } from '$journey/journey.interfaces';

const captchaCallbackTypes = new Set(['ReCaptchaCallback', 'ReCaptchaEnterpriseCallback']);

/**
* @function buildCallbackMetadata - Constructs an array of callback metadata that matches to original callback array
* @param {object} step - The modified Widget step object
* @param {function} checkValidation - function that checks if current callback is the first invalid callback
* @param {object} stageJson - Optional stage JSON from AM
* @param {object} initializationOptions - Optional widget-level initialization options (e.g. captcha config)
* @returns {array}
*/
export function buildCallbackMetadata(
step: JourneyStep,
checkValidation: (callback: BaseCallback) => boolean,
stageJson?: Record<string, unknown> | null,
initializationOptions?: Record<string, unknown> | null,
) {
const callbackCount: Record<string, number> = {};
const isPasskeyAutofillEligible = isMixedLoginWebAuthnStep(step);

return step?.callbacks.map((callback, idx) => {
const cb = callback;
const callbackType = cb.getType();
const callbackType = callback.getType();

let stageCbMetadata;
let initOptions;

if (callbackCount[callbackType]) {
callbackCount[callbackType] = callbackCount[callbackType] + 1;
Expand All @@ -52,6 +57,14 @@ export function buildCallbackMetadata(
stageCbMetadata = stageCbArray[callbackCount[callbackType] - 1];
}

if (captchaCallbackTypes.has(callbackType)) {
const captchaConfig = initializationOptions?.captcha as Record<string, unknown> | undefined;
const recaptchaAction = initializationOptions?.recaptchaAction as string | null | undefined;
if (captchaConfig || recaptchaAction) {
initOptions = { ...captchaConfig, ...(recaptchaAction && { recaptchaAction }) };
}
}

return {
derived: {
canForceUserInputOptionality: canForceUserInputOptionality(callback),
Expand All @@ -68,6 +81,7 @@ export function buildCallbackMetadata(
...stageCbMetadata,
},
}),
...(initOptions && { initOptions }),
};
});
}
Expand Down
Loading
Loading