Skip to content

Commit 6199372

Browse files
committed
feat(captcha): add invisible reCAPTCHA v2, hCaptcha and reCaptcha Enterprise support
fix(captcha): replace javascript-sdk imports with journey-client in story/mock files and restore vitest coverage-v8 dep refactor(captcha): replace captcha.store with initOptions in buildCallbackMetadata refactor(captcha): thread recaptchaAction through callbackMetadata, remove recaptchaActionStore refactor(captcha): unify script injection via shared captcha.utilities
1 parent 3022ed2 commit 6199372

31 files changed

Lines changed: 1860 additions & 100 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@forgerock/login-widget': minor
3+
---
4+
5+
Add invisible reCAPTCHA v2, invisible hCaptcha, and reCAPTCHA Enterprise support.
6+
7+
- Support invisible mode for both Google reCAPTCHA v2 and hCaptcha via `configuration({ captcha: { mode: 'invisible' } })`.
8+
- Add `ReCaptchaEnterpriseCallback` handler for AM journeys using the Enterprise CAPTCHA node — renders visible checkbox or score-based invisible flow automatically from callback data.
9+
- Add `resolveGrecaptcha()` helper that prefers `window.grecaptcha.enterprise` and falls back to classic `window.grecaptcha`, keeping existing consumers with migrated keys working without changes.
10+
- Show inline `<Alert type="error">` on CAPTCHA failure or expiry for invisible modes.
11+
- Fix `renderCaptcha` to accept an optional `elementId` param to avoid DOM id collisions between classic and Enterprise components.

apps/login-app/src/routes/(app)/+layout.svelte

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!--
22
3-
Copyright © 2025 Ping Identity Corporation. All right reserved.
3+
Copyright © 2025 - 2026 Ping Identity Corporation. All right reserved.
44
55
This software may be modified and distributed under the terms
66
of the MIT license. See the LICENSE file for details.
@@ -23,11 +23,6 @@
2323
<title>Login Application</title>
2424
<link rel="icon" href="/favicon.ico" />
2525
<meta name="viewport" content="width=device-width, initial-scale=1" />
26-
<script
27-
src="https://www.google.com/recaptcha/api.js?render=6LdIqXMoAAAAAP4APBlw7_5WDeMTlAAQJf42rPWz"
28-
async
29-
></script>
30-
3126
<style>
3227
/**
3328
* Self-hosting Open Sans for better privacy, potential performance and control

apps/login-app/src/routes/(app)/+page.svelte

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<!--
2-
2+
33
Copyright © 2025-2026 Ping Identity Corporation. All right reserved.
4-
4+
55
This software may be modified and distributed under the terms
66
of the MIT license. See the LICENSE file for details.
7-
7+
88
-->
99

1010
<script lang="ts">
@@ -28,12 +28,15 @@
2828
const formPostEntryParam = $page.url.searchParams.get('form_post_entry');
2929
const journeyParam = $page.url.searchParams.get('journey');
3030
const suspendedIdParam = $page.url.searchParams.get('suspendedId');
31+
const captchaModeParam = $page.url.searchParams.get('captchaMode') as
32+
| 'visible'
33+
| 'invisible'
34+
| null;
3135
32-
const journeyStore: JourneyStore = initializeJourney({
33-
serverConfig: {
34-
wellknown: data.wellknown,
35-
},
36-
});
36+
const journeyStore: JourneyStore = initializeJourney(
37+
{ serverConfig: { wellknown: data.wellknown } },
38+
captchaModeParam ? { captcha: { mode: captchaModeParam } } : null,
39+
);
3740
3841
let hasSubmitted = false;
3942
let redirectForm: HTMLFormElement | null = null;
@@ -67,7 +70,6 @@
6770
journeyStore.start({
6871
journey: journeyParam || authIndexValue || 'Login',
6972
query,
70-
// recaptchaAction: 'MyTestAction',
7173
});
7274
}
7375
});

apps/login-app/src/routes/e2e/widget/inline/+page.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
let authIndexValueParam = $page.url.searchParams.get('authIndexValue');
2525
let journeyParam = $page.url.searchParams.get('journey');
2626
let recaptchaParam = $page.url.searchParams.get('recaptchaAction');
27+
let captchaModeParam = $page.url.searchParams.get('captchaMode') as
28+
| 'visible'
29+
| 'invisible'
30+
| null;
2731
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
2832
let formEl: HTMLDivElement;
2933
let userEvent: UserStoreValue | null;
@@ -52,6 +56,7 @@
5256
'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration',
5357
},
5458
},
59+
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
5560
forgerock: {
5661
clientId: 'WebOAuthClient',
5762
redirectUri: `${window.location.origin}/callback`,

apps/login-app/src/routes/e2e/widget/modal/+page.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
let authIndexValueParam = $page.url.searchParams.get('authIndexValue');
2020
let journeyParam = $page.url.searchParams.get('journey');
2121
let recaptchaParam = $page.url.searchParams.get('recaptchaAction');
22+
let captchaModeParam = $page.url.searchParams.get('captchaMode') as
23+
| 'visible'
24+
| 'invisible'
25+
| null;
2226
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
2327
let showPasswordParam = $page.url.searchParams.get('showPassword') as
2428
| 'none'
@@ -127,6 +131,7 @@
127131
header: false,
128132
},
129133
},
134+
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
130135
});
131136
132137
componentEvents = component();

core/journey/_utilities/callback-mapper.svelte

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import PingProtectInitialize from '$journey/callbacks/ping-protect-initialize/ping-protect-initialize.svelte';
3333
import PollingWait from '$journey/callbacks/polling-wait/polling-wait.svelte';
3434
import Recaptcha from '$journey/callbacks/recaptcha/recaptcha.svelte';
35+
import RecaptchaEnterprise from '$journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte';
3536
import Redirect from '$journey/callbacks/redirect/redirect.svelte';
3637
import SelectIdp from '$journey/callbacks/select-idp/select-idp.svelte';
3738
import StringAttributeInput from '$journey/callbacks/string-attribute/string-attribute-input.svelte';
@@ -58,6 +59,7 @@
5859
PingOneProtectInitializeCallback,
5960
PollingWaitCallback,
6061
ReCaptchaCallback,
62+
ReCaptchaEnterpriseCallback,
6163
RedirectCallback,
6264
SelectIdPCallback,
6365
SuspendedTextOutputCallback,
@@ -113,6 +115,7 @@
113115
let _MetadataCallback: MetadataCallback;
114116
let _DeviceProfileCallback: DeviceProfileCallback;
115117
let _RecaptchaCallback: ReCaptchaCallback;
118+
let _RecaptchaEnterpriseCallback: ReCaptchaEnterpriseCallback;
116119
let _PingProtectEvaluation: PingOneProtectEvaluationCallback;
117120
let _PingProtectInitialize: PingOneProtectInitializeCallback;
118121
let _BaseCallback: BaseCallback;
@@ -142,6 +145,9 @@
142145
case callbackType.ReCaptchaCallback:
143146
_RecaptchaCallback = props.callback as ReCaptchaCallback;
144147
break;
148+
case callbackType.ReCaptchaEnterpriseCallback:
149+
_RecaptchaEnterpriseCallback = props.callback as ReCaptchaEnterpriseCallback;
150+
break;
145151
case callbackType.PasswordCallback:
146152
_PasswordCallback = props.callback as PasswordCallback;
147153
break;
@@ -319,6 +325,12 @@
319325
callback: _RecaptchaCallback,
320326
}}
321327
<Recaptcha {...newProps} />
328+
{:else if cbType === callbackType.ReCaptchaEnterpriseCallback}
329+
{@const newProps = {
330+
...props,
331+
callback: _RecaptchaEnterpriseCallback,
332+
}}
333+
<RecaptchaEnterprise {...newProps} />
322334
{:else if cbType === callbackType.PingOneProtectEvaluationCallback}
323335
{@const newProps = {
324336
...props,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
*
3+
* Copyright © 2026 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
**/
9+
10+
import { beforeEach, describe, expect, it, vi } from 'vitest';
11+
12+
import { loadCaptchaScript, resolveGrecaptcha } from './captcha.utilities';
13+
14+
vi.stubGlobal('window', globalThis);
15+
16+
describe('resolveGrecaptcha', () => {
17+
beforeEach(() => {
18+
vi.stubGlobal('grecaptcha', undefined);
19+
});
20+
21+
it('returns grecaptcha.enterprise when enterprise namespace is present', () => {
22+
const enterprise = { ready: vi.fn(), render: vi.fn(), execute: vi.fn() };
23+
vi.stubGlobal('grecaptcha', { enterprise });
24+
expect(resolveGrecaptcha()).toBe(enterprise);
25+
});
26+
27+
it('falls back to window.grecaptcha when enterprise namespace is absent', () => {
28+
const classic = { ready: vi.fn(), render: vi.fn(), execute: vi.fn() };
29+
vi.stubGlobal('grecaptcha', classic);
30+
expect(resolveGrecaptcha()).toBe(classic);
31+
});
32+
});
33+
34+
describe('loadCaptchaScript', () => {
35+
let appendedScript: {
36+
src: string;
37+
async: boolean;
38+
onload: (() => void) | null;
39+
onerror: (() => void) | null;
40+
};
41+
let mockQuerySelector: ReturnType<typeof vi.fn>;
42+
let mockAppendChild: ReturnType<typeof vi.fn>;
43+
44+
beforeEach(() => {
45+
vi.stubGlobal('grecaptcha', undefined);
46+
appendedScript = { src: '', async: false, onload: null, onerror: null };
47+
mockAppendChild = vi.fn();
48+
mockQuerySelector = vi.fn().mockReturnValue(null);
49+
vi.stubGlobal('document', {
50+
querySelector: mockQuerySelector,
51+
createElement: () => appendedScript,
52+
head: { appendChild: mockAppendChild },
53+
});
54+
});
55+
56+
it('injects a new script tag and resolves on load for hcaptcha', async () => {
57+
const promise = loadCaptchaScript({
58+
src: 'https://js.hcaptcha.com/1/api.js',
59+
provider: 'hcaptcha',
60+
});
61+
expect(appendedScript.src).toBe('https://js.hcaptcha.com/1/api.js');
62+
expect(mockAppendChild).toHaveBeenCalledOnce();
63+
appendedScript.onload?.();
64+
await promise;
65+
});
66+
67+
it('injects a new script tag and waits for grecaptcha.ready on load', async () => {
68+
const mockReady = vi.fn((fn: () => void) => fn());
69+
const promise = loadCaptchaScript({
70+
src: 'https://www.google.com/recaptcha/api.js',
71+
provider: 'grecaptcha',
72+
});
73+
expect(appendedScript.src).toBe('https://www.google.com/recaptcha/api.js');
74+
vi.stubGlobal('grecaptcha', { ready: mockReady });
75+
appendedScript.onload?.();
76+
await promise;
77+
expect(mockReady).toHaveBeenCalledOnce();
78+
});
79+
80+
it('reuses existing script and resolves immediately for hcaptcha', async () => {
81+
mockQuerySelector.mockReturnValue({ addEventListener: vi.fn() });
82+
await loadCaptchaScript({ src: 'https://js.hcaptcha.com/1/api.js', provider: 'hcaptcha' });
83+
expect(mockAppendChild).not.toHaveBeenCalled();
84+
});
85+
86+
it('reuses existing script and calls grecaptcha.ready when already present', async () => {
87+
const mockReady = vi.fn((fn: () => void) => fn());
88+
vi.stubGlobal('grecaptcha', { ready: mockReady });
89+
mockQuerySelector.mockReturnValue({ addEventListener: vi.fn() });
90+
await loadCaptchaScript({
91+
src: 'https://www.google.com/recaptcha/api.js',
92+
provider: 'grecaptcha',
93+
});
94+
expect(mockAppendChild).not.toHaveBeenCalled();
95+
expect(mockReady).toHaveBeenCalledOnce();
96+
});
97+
98+
it('rejects when the script fails to load', async () => {
99+
const promise = loadCaptchaScript({
100+
src: 'https://bad.example.com/api.js',
101+
provider: 'hcaptcha',
102+
});
103+
appendedScript.onerror?.();
104+
await expect(promise).rejects.toThrow('Failed to load CAPTCHA script');
105+
});
106+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
*
3+
* Copyright © 2026 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
**/
9+
10+
/**
11+
* Resolves the active reCAPTCHA namespace. Prefers `grecaptcha.enterprise`
12+
* (loaded by enterprise.js) and falls back to the classic `grecaptcha` global
13+
* (loaded by api.js or auto-migrated keys). This makes all call sites
14+
* transparent to which script the consumer loaded.
15+
*/
16+
export function resolveGrecaptcha(): ReCaptchaV2.ReCaptcha {
17+
const grecaptcha = window.grecaptcha as ReCaptchaV2.ReCaptcha & {
18+
enterprise?: ReCaptchaV2.ReCaptcha;
19+
};
20+
return grecaptcha?.enterprise ?? window.grecaptcha;
21+
}
22+
23+
/**
24+
* Injects a CAPTCHA script tag into <head> and resolves when the provider API
25+
* is ready to use. No-ops if the provider API is already present on window (e.g.
26+
* pre-loaded by consumer or stubbed in tests), or if a script with the same src
27+
* is already in the document. For grecaptcha, waits for grecaptcha.ready().
28+
*/
29+
export function loadCaptchaScript({
30+
src,
31+
provider,
32+
}: {
33+
src: string;
34+
provider: 'grecaptcha' | 'hcaptcha';
35+
}): Promise<void> {
36+
if (provider === 'hcaptcha') {
37+
const hc = (window as Window & { hcaptcha?: unknown }).hcaptcha;
38+
if (hc) return Promise.resolve();
39+
} else {
40+
const grc = resolveGrecaptcha();
41+
if (grc) return new Promise((resolve) => grc.ready(() => resolve()));
42+
}
43+
44+
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
45+
if (existing) {
46+
return new Promise((resolve) => {
47+
if (provider === 'hcaptcha') {
48+
resolve();
49+
} else {
50+
const grc = resolveGrecaptcha();
51+
if (grc) {
52+
grc.ready(() => resolve());
53+
} else {
54+
existing.addEventListener('load', () => resolve(), { once: true });
55+
}
56+
}
57+
});
58+
}
59+
return new Promise((resolve, reject) => {
60+
const script = document.createElement('script');
61+
script.src = src;
62+
script.async = true;
63+
script.onerror = () => reject(new Error(`Failed to load CAPTCHA script: ${src}`));
64+
script.onload = () => {
65+
if (provider === 'hcaptcha') {
66+
resolve();
67+
} else {
68+
const grc = resolveGrecaptcha();
69+
grc ? grc.ready(() => resolve()) : resolve();
70+
}
71+
};
72+
document.head.appendChild(script);
73+
});
74+
}

core/journey/_utilities/metadata.utilities.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,25 +21,30 @@ import type { BaseCallback, JourneyStep } from '@forgerock/journey-client/types'
2121

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

24+
const captchaCallbackTypes = new Set(['ReCaptchaCallback', 'ReCaptchaEnterpriseCallback']);
25+
2426
/**
2527
* @function buildCallbackMetadata - Constructs an array of callback metadata that matches to original callback array
2628
* @param {object} step - The modified Widget step object
2729
* @param {function} checkValidation - function that checks if current callback is the first invalid callback
30+
* @param {object} stageJson - Optional stage JSON from AM
31+
* @param {object} initializationOptions - Optional widget-level initialization options (e.g. captcha config)
2832
* @returns {array}
2933
*/
3034
export function buildCallbackMetadata(
3135
step: JourneyStep,
3236
checkValidation: (callback: BaseCallback) => boolean,
3337
stageJson?: Record<string, unknown> | null,
38+
initializationOptions?: Record<string, unknown> | null,
3439
) {
3540
const callbackCount: Record<string, number> = {};
3641
const isPasskeyAutofillEligible = isMixedLoginWebAuthnStep(step);
3742

3843
return step?.callbacks.map((callback, idx) => {
39-
const cb = callback;
40-
const callbackType = cb.getType();
44+
const callbackType = callback.getType();
4145

4246
let stageCbMetadata;
47+
let initOptions;
4348

4449
if (callbackCount[callbackType]) {
4550
callbackCount[callbackType] = callbackCount[callbackType] + 1;
@@ -52,6 +57,14 @@ export function buildCallbackMetadata(
5257
stageCbMetadata = stageCbArray[callbackCount[callbackType] - 1];
5358
}
5459

60+
if (captchaCallbackTypes.has(callbackType)) {
61+
const captchaConfig = initializationOptions?.captcha as Record<string, unknown> | undefined;
62+
const recaptchaAction = initializationOptions?.recaptchaAction as string | null | undefined;
63+
if (captchaConfig || recaptchaAction) {
64+
initOptions = { ...captchaConfig, ...(recaptchaAction && { recaptchaAction }) };
65+
}
66+
}
67+
5568
return {
5669
derived: {
5770
canForceUserInputOptionality: canForceUserInputOptionality(callback),
@@ -68,6 +81,7 @@ export function buildCallbackMetadata(
6881
...stageCbMetadata,
6982
},
7083
}),
84+
...(initOptions && { initOptions }),
7185
};
7286
});
7387
}

0 commit comments

Comments
 (0)