Skip to content

Commit daa76ed

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
1 parent 52fdfb3 commit daa76ed

28 files changed

Lines changed: 1595 additions & 136 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: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@
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>
26+
<!--
27+
For CAPTCHA-enabled journeys: use enterprise.js, not api.js —
28+
new Google keys require the Enterprise namespace.
29+
-->
30+
<script src="https://www.google.com/recaptcha/enterprise.js" async defer></script>
3031

3132
<style>
3233
/**

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

Lines changed: 11 additions & 8 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+
| 'normal'
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;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
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 'normal' | 'invisible' | null;
2728
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
2829
let formEl: HTMLDivElement;
2930
let userEvent: UserStoreValue | null;
@@ -52,6 +53,7 @@
5253
'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration',
5354
},
5455
},
56+
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
5557
forgerock: {
5658
clientId: 'WebOAuthClient',
5759
redirectUri: `${window.location.origin}/callback`,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
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 'normal' | 'invisible' | null;
2223
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
2324
let showPasswordParam = $page.url.searchParams.get('showPassword') as
2425
| 'none'
@@ -127,6 +128,7 @@
127128
header: false,
128129
},
129130
},
131+
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
130132
});
131133
132134
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,

core/journey/_utilities/metadata.utilities.ts

Lines changed: 12 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,10 @@ export function buildCallbackMetadata(
5257
stageCbMetadata = stageCbArray[callbackCount[callbackType] - 1];
5358
}
5459

60+
if (captchaCallbackTypes.has(callbackType) && initializationOptions?.captcha) {
61+
initOptions = initializationOptions.captcha as Record<string, unknown>;
62+
}
63+
5564
return {
5665
derived: {
5766
canForceUserInputOptionality: canForceUserInputOptionality(callback),
@@ -68,6 +77,7 @@ export function buildCallbackMetadata(
6877
...stageCbMetadata,
6978
},
7079
}),
80+
...(initOptions && { initOptions }),
7181
};
7282
});
7383
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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 { callbackType } from '@forgerock/journey-client';
11+
12+
export const visibleGrecaptchaEnterprise = {
13+
authId: 'test-auth-id',
14+
callbacks: [
15+
{
16+
type: callbackType.ReCaptchaEnterpriseCallback,
17+
output: [
18+
{ name: 'recaptchaSiteKey', value: 'enterprise-site-key' },
19+
{ name: 'captchaApiUri', value: 'https://www.google.com/recaptcha/enterprise.js' },
20+
{ name: 'captchaDivClass', value: 'g-recaptcha' },
21+
],
22+
input: [
23+
{ name: 'IDToken1token', value: '' },
24+
{ name: 'IDToken1action', value: '' },
25+
{ name: 'IDToken1clientError', value: '' },
26+
{ name: 'IDToken1payload', value: '' },
27+
],
28+
_id: 0,
29+
},
30+
],
31+
stage: 'DefaultLogin',
32+
};
33+
34+
export const invisibleGrecaptchaEnterprise = visibleGrecaptchaEnterprise;
35+
36+
export default visibleGrecaptchaEnterprise;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 { callbackType } from '@forgerock/journey-client';
11+
import { expect, within } from 'storybook/test';
12+
13+
import { createJourneyStep } from '$journey/_utilities/step.mock';
14+
import { journeyStore } from '$journey/journey.store';
15+
import {
16+
invisibleGrecaptchaEnterprise,
17+
visibleGrecaptchaEnterprise,
18+
} from './recaptcha-enterprise.mock';
19+
import RecaptchaEnterprise from './recaptcha-enterprise.story.svelte';
20+
21+
function mockGrecaptchaEnterprise() {
22+
window.grecaptcha = {
23+
enterprise: {
24+
ready: (cb) => cb(),
25+
render: () => 'widget-id',
26+
execute: async () => 'enterprise-token',
27+
reset: () => undefined,
28+
getResponse: () => 'enterprise-token',
29+
},
30+
};
31+
}
32+
33+
function makeCallbackMetadata(mode) {
34+
return {
35+
derived: {
36+
canForceUserInputOptionality: false,
37+
isFirstInvalidInput: false,
38+
isReadyForSubmission: false,
39+
isSelfSubmitting: false,
40+
isUserInputRequired: false,
41+
isPasskeyAutofillEligible: false,
42+
},
43+
idx: 0,
44+
initOptions: { mode },
45+
};
46+
}
47+
48+
export default {
49+
argTypes: {
50+
callback: { control: false },
51+
callbackMetadata: { control: false },
52+
},
53+
component: RecaptchaEnterprise,
54+
parameters: {
55+
layout: 'fullscreen',
56+
},
57+
title: 'Callbacks/ReCaptchaEnterprise',
58+
};
59+
60+
export const VisibleEnterprise = {
61+
args: {
62+
callback: createJourneyStep(visibleGrecaptchaEnterprise).getCallbackOfType(
63+
callbackType.ReCaptchaEnterpriseCallback,
64+
),
65+
callbackMetadata: makeCallbackMetadata('normal'),
66+
},
67+
play: async () => {
68+
mockGrecaptchaEnterprise();
69+
},
70+
};
71+
72+
export const InvisibleEnterprise = {
73+
args: {
74+
callback: createJourneyStep(invisibleGrecaptchaEnterprise).getCallbackOfType(
75+
callbackType.ReCaptchaEnterpriseCallback,
76+
),
77+
callbackMetadata: makeCallbackMetadata('invisible'),
78+
},
79+
play: async () => {
80+
mockGrecaptchaEnterprise();
81+
journeyStore.update((v) => ({ ...v, recaptchaAction: 'LOGIN' }));
82+
},
83+
};
84+
85+
export const InvisibleEnterpriseError = {
86+
args: { ...InvisibleEnterprise.args },
87+
play: async ({ canvasElement }) => {
88+
mockGrecaptchaEnterprise();
89+
window.frHandleCaptchaInvisibleError?.();
90+
const canvas = within(canvasElement);
91+
await expect(canvas.findByText(/CAPTCHA verification failed/i)).resolves.toBeInTheDocument();
92+
},
93+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
<script lang="ts">
11+
import Centered from '$components/primitives/box/centered.svelte';
12+
import RecaptchaEnterprise from './recaptcha-enterprise.svelte';
13+
14+
import type { ReCaptchaEnterpriseCallback } from '@forgerock/journey-client/types';
15+
16+
import type { Maybe } from '$core/interfaces';
17+
import type { CallbackMetadata } from '$journey/journey.interfaces';
18+
19+
export let callback: ReCaptchaEnterpriseCallback;
20+
export let callbackMetadata: Maybe<CallbackMetadata> = null;
21+
</script>
22+
23+
<Centered>
24+
<RecaptchaEnterprise {callback} {callbackMetadata} />
25+
</Centered>

0 commit comments

Comments
 (0)