Skip to content

Commit a6cc346

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 refactor(captcha): separate effectful functions into *.effects.ts files fix(captcha): guard executeEnterpriseCaptcha against missing grecaptcha and prevent double-render fix(captcha): add Zod validation schema for captcha config option docs(captcha): correct README — widget injects scripts automatically fix(captcha): fix visible onError infinite loop, unsafe captchaMode cast, and console.error noise fix(captcha): fix layer violation, TDZ, v3 error handling, and add initOptions tests fix(captcha): remove redundant mountMode local; use reactive captchaMode in onMount fix(captcha): fix existing-script race, empty apiUrl fallback, and v3 null deref fix(captcha): fix Tier 2 findings — elementId in invisible render, enterprise onError, v3 error UI
1 parent 3022ed2 commit a6cc346

37 files changed

Lines changed: 2151 additions & 133 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: 10 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,14 @@
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 captchaModeRaw = $page.url.searchParams.get('captchaMode');
32+
const captchaModeParam: 'visible' | 'invisible' | null =
33+
captchaModeRaw === 'visible' || captchaModeRaw === 'invisible' ? captchaModeRaw : null;
3134
32-
const journeyStore: JourneyStore = initializeJourney({
33-
serverConfig: {
34-
wellknown: data.wellknown,
35-
},
36-
});
35+
const journeyStore: JourneyStore = initializeJourney(
36+
{ serverConfig: { wellknown: data.wellknown } },
37+
captchaModeParam ? { captcha: { mode: captchaModeParam } } : null,
38+
);
3739
3840
let hasSubmitted = false;
3941
let redirectForm: HTMLFormElement | null = null;
@@ -67,7 +69,6 @@
6769
journeyStore.start({
6870
journey: journeyParam || authIndexValue || 'Login',
6971
query,
70-
// recaptchaAction: 'MyTestAction',
7172
});
7273
}
7374
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
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+
const captchaModeRaw = $page.url.searchParams.get('captchaMode');
28+
let captchaModeParam: 'visible' | 'invisible' | null =
29+
captchaModeRaw === 'visible' || captchaModeRaw === 'invisible' ? captchaModeRaw : null;
2730
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
2831
let formEl: HTMLDivElement;
2932
let userEvent: UserStoreValue | null;
@@ -52,6 +55,7 @@
5255
'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration',
5356
},
5457
},
58+
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
5559
forgerock: {
5660
clientId: 'WebOAuthClient',
5761
redirectUri: `${window.location.origin}/callback`,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
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+
const captchaModeRaw = $page.url.searchParams.get('captchaMode');
23+
let captchaModeParam: 'visible' | 'invisible' | null =
24+
captchaModeRaw === 'visible' || captchaModeRaw === 'invisible' ? captchaModeRaw : null;
2225
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
2326
let showPasswordParam = $page.url.searchParams.get('showPassword') as
2427
| 'none'
@@ -127,6 +130,7 @@
127130
header: false,
128131
},
129132
},
133+
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
130134
});
131135
132136
componentEvents = component();

core/captcha.config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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 { z } from 'zod';
11+
12+
export const captchaConfigSchema = z
13+
.object({
14+
mode: z.enum(['visible', 'invisible']).optional(),
15+
})
16+
.strict();

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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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.effects';
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 hcaptcha script and resolves on load event', async () => {
81+
const listeners: Record<string, () => void> = {};
82+
mockQuerySelector.mockReturnValue({
83+
addEventListener: vi.fn((event: string, cb: () => void) => {
84+
listeners[event] = cb;
85+
}),
86+
});
87+
const promise = loadCaptchaScript({
88+
src: 'https://js.hcaptcha.com/1/api.js',
89+
provider: 'hcaptcha',
90+
});
91+
expect(mockAppendChild).not.toHaveBeenCalled();
92+
listeners['load']?.();
93+
await promise;
94+
});
95+
96+
it('reuses existing hcaptcha script and rejects on error event', async () => {
97+
const listeners: Record<string, () => void> = {};
98+
mockQuerySelector.mockReturnValue({
99+
addEventListener: vi.fn((event: string, cb: () => void) => {
100+
listeners[event] = cb;
101+
}),
102+
});
103+
const promise = loadCaptchaScript({
104+
src: 'https://js.hcaptcha.com/1/api.js',
105+
provider: 'hcaptcha',
106+
});
107+
listeners['error']?.();
108+
await expect(promise).rejects.toThrow('Failed to load CAPTCHA script');
109+
});
110+
111+
it('reuses existing grecaptcha script and calls ready when grecaptcha already present', async () => {
112+
const mockReady = vi.fn((fn: () => void) => fn());
113+
vi.stubGlobal('grecaptcha', { ready: mockReady });
114+
const listeners: Record<string, () => void> = {};
115+
mockQuerySelector.mockReturnValue({
116+
addEventListener: vi.fn((event: string, cb: () => void) => {
117+
listeners[event] = cb;
118+
}),
119+
});
120+
await loadCaptchaScript({
121+
src: 'https://www.google.com/recaptcha/api.js',
122+
provider: 'grecaptcha',
123+
});
124+
expect(mockAppendChild).not.toHaveBeenCalled();
125+
expect(mockReady).toHaveBeenCalledOnce();
126+
});
127+
128+
it('reuses existing grecaptcha script and waits for load then ready when not yet initialized', async () => {
129+
const mockReady = vi.fn((fn: () => void) => fn());
130+
const listeners: Record<string, () => void> = {};
131+
mockQuerySelector.mockReturnValue({
132+
addEventListener: vi.fn((event: string, cb: () => void) => {
133+
listeners[event] = cb;
134+
}),
135+
});
136+
const promise = loadCaptchaScript({
137+
src: 'https://www.google.com/recaptcha/api.js',
138+
provider: 'grecaptcha',
139+
});
140+
expect(mockAppendChild).not.toHaveBeenCalled();
141+
vi.stubGlobal('grecaptcha', { ready: mockReady });
142+
listeners['load']?.();
143+
await promise;
144+
expect(mockReady).toHaveBeenCalledOnce();
145+
});
146+
147+
it('rejects when the script fails to load', async () => {
148+
const promise = loadCaptchaScript({
149+
src: 'https://bad.example.com/api.js',
150+
provider: 'hcaptcha',
151+
});
152+
appendedScript.onerror?.();
153+
await expect(promise).rejects.toThrow('Failed to load CAPTCHA script');
154+
});
155+
});

0 commit comments

Comments
 (0)