Skip to content

Commit c8ec83e

Browse files
committed
feat(captcha): add invisible reCAPTCHA v2, hCaptcha and reCaptcha Enterprise support
1 parent b2bec85 commit c8ec83e

27 files changed

Lines changed: 1571 additions & 40 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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@
2323
<title>Login Application</title>
2424
<link rel="icon" href="/favicon.ico" />
2525
<meta name="viewport" content="width=device-width, initial-scale=1" />
26+
<!--
27+
For CAPTCHA-enabled journeys: use enterprise.js, not api.js —
28+
new Google keys require the Enterprise namespace.
29+
-->
2630
<script
27-
src="https://www.google.com/recaptcha/api.js?render=6LdIqXMoAAAAAP4APBlw7_5WDeMTlAAQJf42rPWz"
31+
src="https://www.google.com/recaptcha/enterprise.js"
2832
async
33+
defer
2934
></script>
3035

3136
<style>

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

Lines changed: 6 additions & 3 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">
@@ -16,6 +16,7 @@
1616
import Journey from '$journey/journey.svelte';
1717
import { initialize as initializeJourney } from '$journey/journey.store';
1818
import { initialize as initializeContent } from '$core/locale.store';
19+
import { initialize as initializeCaptcha } from '$core/captcha.store';
1920
2021
import type { JourneyStore } from '$journey/journey.interfaces';
2122
@@ -28,6 +29,7 @@
2829
const formPostEntryParam = $page.url.searchParams.get('form_post_entry');
2930
const journeyParam = $page.url.searchParams.get('journey');
3031
const suspendedIdParam = $page.url.searchParams.get('suspendedId');
32+
const captchaModeParam = $page.url.searchParams.get('captchaMode') as 'normal' | 'invisible' | null;
3133
3234
const journeyStore: JourneyStore = initializeJourney({
3335
serverConfig: {
@@ -44,6 +46,7 @@
4446
* Sets up locale store with appropriate content
4547
*/
4648
initializeContent(data.content);
49+
initializeCaptcha({ mode: captchaModeParam ?? 'normal' });
4750
4851
// Use if not initializing journey in a "context module"
4952
onMount(async () => {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
let authIndexValueParam = $page.url.searchParams.get('authIndexValue');
2424
let journeyParam = $page.url.searchParams.get('journey');
2525
let recaptchaParam = $page.url.searchParams.get('recaptchaAction');
26+
let captchaModeParam = $page.url.searchParams.get('captchaMode') as 'normal' | 'invisible' | null;
2627
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
2728
let formEl: HTMLDivElement;
2829
let userEvent: UserStoreValue | null;
@@ -50,6 +51,7 @@
5051
wellknown: 'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration',
5152
},
5253
},
54+
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
5355
forgerock: {
5456
clientId: 'WebOAuthClient',
5557
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'
@@ -126,6 +127,7 @@
126127
header: false,
127128
},
128129
},
130+
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
129131
});
130132
131133
componentEvents = component();

core/captcha.store.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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 { get } from 'svelte/store';
11+
import { describe, expect, it } from 'vitest';
12+
13+
import { captchaStore, initialize } from './captcha.store';
14+
15+
describe('captcha.store', () => {
16+
describe('initialize()', () => {
17+
it('should set mode to "normal" when no config is provided', () => {
18+
initialize();
19+
expect(get(captchaStore).mode).toBe('normal');
20+
});
21+
22+
it('should set mode to "invisible" when configured', () => {
23+
initialize({ mode: 'invisible' });
24+
expect(get(captchaStore).mode).toBe('invisible');
25+
});
26+
27+
it('should default to "normal" when mode is undefined', () => {
28+
initialize({ mode: undefined });
29+
expect(get(captchaStore).mode).toBe('normal');
30+
});
31+
32+
it('should return the captcha store', () => {
33+
const store = initialize({ mode: 'invisible' });
34+
expect(store).toBe(captchaStore);
35+
});
36+
37+
it('should update the store when called again with different config', () => {
38+
initialize({ mode: 'invisible' });
39+
expect(get(captchaStore).mode).toBe('invisible');
40+
41+
initialize({ mode: 'normal' });
42+
expect(get(captchaStore).mode).toBe('normal');
43+
});
44+
});
45+
});

core/captcha.store.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
*
3+
* Copyright © 2025 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 { writable, type Writable } from 'svelte/store';
11+
import { z } from 'zod';
12+
13+
export const captchaSchema = z
14+
.object({
15+
mode: z.union([z.literal('normal'), z.literal('invisible')]).optional(),
16+
})
17+
.strict();
18+
19+
export const partialCaptchaSchema = captchaSchema.partial();
20+
21+
/** Convenience type alias for the resolved captcha config object. */
22+
export type CaptchaConfig = z.infer<typeof partialCaptchaSchema>;
23+
24+
const fallbackCaptcha: CaptchaConfig = {
25+
mode: 'normal',
26+
};
27+
28+
export const captchaStore: Writable<CaptchaConfig> = writable(fallbackCaptcha);
29+
30+
/**
31+
* @function initialize - Initialize the captcha store
32+
* @param {object} customCaptcha - Optional captcha configuration to apply
33+
* @returns {object} - The captcha store
34+
* @example initialize({ mode: 'invisible' });
35+
*/
36+
export function initialize(customCaptcha?: CaptchaConfig) {
37+
captchaStore.set({ mode: customCaptcha?.mode ?? 'normal' });
38+
return captchaStore;
39+
}

core/journey/_utilities/callback-mapper.svelte

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import ValidatedCreateUsername from '$journey/callbacks/username/validated-create-username.svelte';
4343
import DeviceProfile from '$journey/callbacks/device-profile/device-profile.svelte';
4444
import Recaptcha from '$journey/callbacks/recaptcha/recaptcha.svelte';
45+
import RecaptchaEnterprise from '$journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte';
4546
import Metadata from '$journey/callbacks/metadata/metadata.svelte';
4647
import PingProtectEvaluation from '$journey/callbacks/ping-protect-evaluation/ping-protect-evaluation.svelte';
4748
import PingProtectInitialize from '$journey/callbacks/ping-protect-initialize/ping-protect-initialize.svelte';
@@ -67,6 +68,7 @@
6768
DeviceProfileCallback,
6869
MetadataCallback,
6970
ReCaptchaCallback,
71+
ReCaptchaEnterpriseCallback,
7072
PingOneProtectEvaluationCallback,
7173
PingOneProtectInitializeCallback,
7274
} from '@forgerock/journey-client/types';
@@ -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: 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/javascript-sdk';
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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 { FRStep, CallbackType } from '@forgerock/javascript-sdk';
11+
import { expect, within } from 'storybook/test';
12+
13+
import {
14+
visibleGrecaptchaEnterprise,
15+
invisibleGrecaptchaEnterprise,
16+
} from './recaptcha-enterprise.mock';
17+
import RecaptchaEnterprise from './recaptcha-enterprise.story.svelte';
18+
import { captchaStore } from '$core/captcha.store';
19+
import { journeyStore } from '$journey/journey.store';
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+
export default {
34+
argTypes: {
35+
callback: { control: false },
36+
},
37+
component: RecaptchaEnterprise,
38+
parameters: {
39+
layout: 'fullscreen',
40+
},
41+
title: 'Callbacks/ReCaptchaEnterprise',
42+
};
43+
44+
export const VisibleEnterprise = {
45+
args: {
46+
callback: new FRStep(visibleGrecaptchaEnterprise).getCallbackOfType(
47+
CallbackType.ReCaptchaEnterpriseCallback,
48+
),
49+
},
50+
play: async () => {
51+
mockGrecaptchaEnterprise();
52+
captchaStore.set({ mode: 'normal' });
53+
},
54+
};
55+
56+
export const InvisibleEnterprise = {
57+
args: {
58+
callback: new FRStep(invisibleGrecaptchaEnterprise).getCallbackOfType(
59+
CallbackType.ReCaptchaEnterpriseCallback,
60+
),
61+
},
62+
play: async () => {
63+
mockGrecaptchaEnterprise();
64+
captchaStore.set({ mode: 'invisible' });
65+
journeyStore.update((v) => ({ ...v, recaptchaAction: 'LOGIN' }));
66+
},
67+
};
68+
69+
export const InvisibleEnterpriseError = {
70+
args: { ...InvisibleEnterprise.args },
71+
play: async ({ canvasElement }) => {
72+
mockGrecaptchaEnterprise();
73+
captchaStore.set({ mode: 'invisible' });
74+
window.frHandleCaptchaInvisibleError?.();
75+
const canvas = within(canvasElement);
76+
await expect(canvas.findByText(/CAPTCHA verification failed/i)).resolves.toBeInTheDocument();
77+
},
78+
};

0 commit comments

Comments
 (0)