Skip to content

Commit 48f1ac6

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

26 files changed

Lines changed: 1260 additions & 50 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/app.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
document.body.classList.add('tw_dark');
2020
}
2121
</script>
22+
<!--
23+
For CAPTCHA-enabled journeys, load the reCAPTCHA Enterprise script:
24+
<script src="https://www.google.com/recaptcha/enterprise.js" async></script>
25+
(Use enterprise.js, not api.js — new Google keys require the Enterprise namespace.)
26+
-->
2227
<div id="svelte" class="root">%sveltekit.body%</div>
2328
</body>
2429
</html>

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424
<link rel="icon" href="/favicon.ico" />
2525
<meta name="viewport" content="width=device-width, initial-scale=1" />
2626
<script
27-
src="https://www.google.com/recaptcha/api.js?render=6LdIqXMoAAAAAP4APBlw7_5WDeMTlAAQJf42rPWz"
27+
src="https://www.google.com/recaptcha/enterprise.js"
2828
async
29+
defer
2930
></script>
3031

3132
<style>

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

Lines changed: 5 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
@@ -40,6 +41,7 @@
4041
* Sets up locale store with appropriate content
4142
*/
4243
initializeContent(data.content);
44+
initializeCaptcha({ mode: 'invisible' });
4345
4446
// Use if not initializing journey in a "context module"
4547
onMount(async () => {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
let authIndexValueParam = $page.url.searchParams.get('authIndexValue');
2121
let journeyParam = $page.url.searchParams.get('journey');
2222
let recaptchaParam = $page.url.searchParams.get('recaptchaAction');
23+
let captchaModeParam = $page.url.searchParams.get('captchaMode') as 'normal' | 'invisible' | null;
2324
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
2425
let showPasswordParam = $page.url.searchParams.get('showPassword') as
2526
| 'none'
@@ -139,6 +140,7 @@
139140
header: false,
140141
},
141142
},
143+
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
142144
});
143145
new Widget({ target: widgetEl });
144146
if (initializePingProtectEarly) {

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
@@ -41,6 +41,7 @@
4141
import ValidatedCreateUsername from '$journey/callbacks/username/validated-create-username.svelte';
4242
import DeviceProfile from '$journey/callbacks/device-profile/device-profile.svelte';
4343
import Recaptcha from '$journey/callbacks/recaptcha/recaptcha.svelte';
44+
import RecaptchaEnterprise from '$journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte';
4445
import Metadata from '$journey/callbacks/metadata/metadata.svelte';
4546
import PingProtectEvaluation from '$journey/callbacks/ping-protect-evaluation/ping-protect-evaluation.svelte';
4647
import PingProtectInitialize from '$journey/callbacks/ping-protect-initialize/ping-protect-initialize.svelte';
@@ -66,6 +67,7 @@
6667
DeviceProfileCallback,
6768
MetadataCallback,
6869
ReCaptchaCallback,
70+
ReCaptchaEnterpriseCallback,
6971
PingOneProtectEvaluationCallback,
7072
PingOneProtectInitializeCallback,
7173
} from '@forgerock/javascript-sdk';
@@ -112,6 +114,7 @@
112114
let _MetadataCallback: MetadataCallback;
113115
let _DeviceProfileCallback: DeviceProfileCallback;
114116
let _RecaptchaCallback: ReCaptchaCallback;
117+
let _RecaptchaEnterpriseCallback: ReCaptchaEnterpriseCallback;
115118
let _PingProtectEvaluation: PingOneProtectEvaluationCallback;
116119
let _PingProtectInitialize: PingOneProtectInitializeCallback;
117120
let _FRCallback: FRCallback;
@@ -141,6 +144,9 @@
141144
case CallbackType.ReCaptchaCallback:
142145
_RecaptchaCallback = props.callback as ReCaptchaCallback;
143146
break;
147+
case CallbackType.ReCaptchaEnterpriseCallback:
148+
_RecaptchaEnterpriseCallback = props.callback as ReCaptchaEnterpriseCallback;
149+
break;
144150
case CallbackType.PasswordCallback:
145151
_PasswordCallback = props.callback as PasswordCallback;
146152
break;
@@ -318,6 +324,12 @@
318324
callback: _RecaptchaCallback,
319325
}}
320326
<Recaptcha {...newProps} />
327+
{:else if cbType === CallbackType.ReCaptchaEnterpriseCallback}
328+
{@const newProps = {
329+
...props,
330+
callback: _RecaptchaEnterpriseCallback,
331+
}}
332+
<RecaptchaEnterprise {...newProps} />
321333
{:else if cbType === CallbackType.PingOneProtectEvaluationCallback}
322334
{@const newProps = {
323335
...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)