Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/add-captcha-enterprise-invisible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@forgerock/login-widget': minor
---

Add invisible reCAPTCHA v2, invisible hCaptcha, and reCAPTCHA Enterprise support.

- Support invisible mode for both Google reCAPTCHA v2 and hCaptcha via `configuration({ captcha: { mode: 'invisible' } })`.
- Add `ReCaptchaEnterpriseCallback` handler for AM journeys using the Enterprise CAPTCHA node — renders visible checkbox or score-based invisible flow automatically from callback data.
- Add `resolveGrecaptcha()` helper that prefers `window.grecaptcha.enterprise` and falls back to classic `window.grecaptcha`, keeping existing consumers with migrated keys working without changes.
- Show inline `<Alert type="error">` on CAPTCHA failure or expiry for invisible modes.
- Fix `renderCaptcha` to accept an optional `elementId` param to avoid DOM id collisions between classic and Enterprise components.
7 changes: 1 addition & 6 deletions apps/login-app/src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!--

Copyright © 2025 Ping Identity Corporation. All right reserved.
Copyright © 2025 - 2026 Ping Identity Corporation. All right reserved.

This software may be modified and distributed under the terms
of the MIT license. See the LICENSE file for details.
Expand All @@ -23,11 +23,6 @@
<title>Login Application</title>
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script
src="https://www.google.com/recaptcha/api.js?render=6LdIqXMoAAAAAP4APBlw7_5WDeMTlAAQJf42rPWz"
async
></script>

<style>
/**
* Self-hosting Open Sans for better privacy, potential performance and control
Expand Down
19 changes: 10 additions & 9 deletions apps/login-app/src/routes/(app)/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<!--

Copyright © 2025-2026 Ping Identity Corporation. All right reserved.

This software may be modified and distributed under the terms
of the MIT license. See the LICENSE file for details.

-->

<script lang="ts">
Expand All @@ -28,12 +28,14 @@
const formPostEntryParam = $page.url.searchParams.get('form_post_entry');
const journeyParam = $page.url.searchParams.get('journey');
const suspendedIdParam = $page.url.searchParams.get('suspendedId');
const captchaModeRaw = $page.url.searchParams.get('captchaMode');
const captchaModeParam =
captchaModeRaw === 'visible' || captchaModeRaw === 'invisible' ? captchaModeRaw : null;

const journeyStore: JourneyStore = initializeJourney({
serverConfig: {
wellknown: data.wellknown,
},
});
const journeyStore: JourneyStore = initializeJourney(
{ serverConfig: { wellknown: data.wellknown } },
captchaModeParam ? { captcha: { mode: captchaModeParam } } : null,
);

let hasSubmitted = false;
let redirectForm: HTMLFormElement | null = null;
Expand Down Expand Up @@ -67,7 +69,6 @@
journeyStore.start({
journey: journeyParam || authIndexValue || '',
query,
// recaptchaAction: 'MyTestAction',
});
}
});
Expand Down
4 changes: 4 additions & 0 deletions apps/login-app/src/routes/e2e/widget/inline/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
let authIndexValueParam = $page.url.searchParams.get('authIndexValue');
let journeyParam = $page.url.searchParams.get('journey');
let recaptchaParam = $page.url.searchParams.get('recaptchaAction');
const captchaModeRaw = $page.url.searchParams.get('captchaMode');
const captchaModeParam =
captchaModeRaw === 'visible' || captchaModeRaw === 'invisible' ? captchaModeRaw : null;
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
let formEl: HTMLDivElement;
let userEvent: UserStoreValue | null;
Expand Down Expand Up @@ -52,6 +55,7 @@
'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration',
},
},
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
forgerock: {
clientId: 'WebOAuthClient',
redirectUri: `${window.location.origin}/callback`,
Expand Down
4 changes: 4 additions & 0 deletions apps/login-app/src/routes/e2e/widget/modal/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
let authIndexValueParam = $page.url.searchParams.get('authIndexValue');
let journeyParam = $page.url.searchParams.get('journey');
let recaptchaParam = $page.url.searchParams.get('recaptchaAction');
const captchaModeRaw = $page.url.searchParams.get('captchaMode');
const captchaModeParam =
captchaModeRaw === 'visible' || captchaModeRaw === 'invisible' ? captchaModeRaw : null;
let suspendedIdParam = $page.url.searchParams.get('suspendedId');
let showPasswordParam = $page.url.searchParams.get('showPassword') as
| 'none'
Expand Down Expand Up @@ -127,6 +130,7 @@
header: false,
},
},
captcha: captchaModeParam ? { mode: captchaModeParam } : undefined,
});

componentEvents = component();
Expand Down
16 changes: 16 additions & 0 deletions core/captcha.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
*
* Copyright © 2026 Ping Identity Corporation. All right reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*
**/

import { z } from 'zod';

export const captchaConfigSchema = z
.object({
mode: z.enum(['visible', 'invisible']).optional(),
})
.strict();
12 changes: 12 additions & 0 deletions core/journey/_utilities/callback-mapper.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import PingProtectInitialize from '$journey/callbacks/ping-protect-initialize/ping-protect-initialize.svelte';
import PollingWait from '$journey/callbacks/polling-wait/polling-wait.svelte';
import Recaptcha from '$journey/callbacks/recaptcha/recaptcha.svelte';
import RecaptchaEnterprise from '$journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte';
import Redirect from '$journey/callbacks/redirect/redirect.svelte';
import SelectIdp from '$journey/callbacks/select-idp/select-idp.svelte';
import StringAttributeInput from '$journey/callbacks/string-attribute/string-attribute-input.svelte';
Expand All @@ -58,6 +59,7 @@
PingOneProtectInitializeCallback,
PollingWaitCallback,
ReCaptchaCallback,
ReCaptchaEnterpriseCallback,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we're now creating a distinct ReCaptchaEnterpriseCallback.

Because now in the new "Recaptcha world" there is no significant difference between this for google, this feels like we're diverting our structure away from the actual core Domain (ReCaptcha)

I think maybe what we need to do is align more against HCaptcha vs Google ReCaptcha.

Since GoogleReCaptcha has Enterprise, and others, but they are all under the domain of Enterprise Recaptcha, it can be a bit more resilient to future changes from Google. If they rename, add something, we have just Google based ReCaptcha component that handles that logic, we operate there.

HCaptcha being distinct creates the same separation.

My Concern is that we are creating the separation "Enterprise" vs not, and then if HCaptcha has or adds an Enterprise, are we going to put all Enterprise based logic in the recaptcha-enterprise component?

It feels like the abstraction may be on the wrong thing and should be on the Google vs HCaptcha.

This may help clean up some of the mess I originally created when writing this because now the logic for each is coupled (correctly) to the component that it is.

The issue this does create is in this file, since we check the type, we'd need one more layer of logic which says "Which type" of Recaptcha is this.

I think in this case, this second "check" is worth doing for the separation we can create.

Thoughts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The component split follows AM's callback structure, not our design. AM sends ReCaptchaEnterpriseCallback (different from ReCaptchaCallback) for the Enterprise captcha node, and it has a different API too. One component for both would need duck-typing or internal branching, which can be harder to read and test. hCaptcha only uses ReCaptchaCallback, no Enterprise variant, so it stays in the classic component. If AM adds an hCaptcha Enterprise callback later, it could get its own component or reuse the enterprise one.

If AM is our source of truth for callback components, which it mostly is (one exception aside), a dedicated ReCaptchaEnterpriseCallback makes more sense. That said, I get your point about the abstraction belonging at the ReCaptcha vs. hCaptcha level. It really comes down to following AM's structure or creating our own for captcha callbacks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hear this, but i'm not sure that following AM nodes here is the right decision since the real implementation is more specific to the provider. I'm not hard set on this but just my thoughts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point. The existing reCaptchaCallback naming can be specially misleading/confusing since it's used by both reCaptcha and hCaptcha, even though that's the callback name returned by the Captcha node. reCaptchaEnterprise is more consistent with the journey node and its intended use.

I'll spend some time splitting the callback components into hcaptcha.svelte and recaptcha.svelte (classic and enterprise) to see if organizing them by provider makes more sense. Thanks!

RedirectCallback,
SelectIdPCallback,
SuspendedTextOutputCallback,
Expand Down Expand Up @@ -113,6 +115,7 @@
let _MetadataCallback: MetadataCallback;
let _DeviceProfileCallback: DeviceProfileCallback;
let _RecaptchaCallback: ReCaptchaCallback;
let _RecaptchaEnterpriseCallback: ReCaptchaEnterpriseCallback;
let _PingProtectEvaluation: PingOneProtectEvaluationCallback;
let _PingProtectInitialize: PingOneProtectInitializeCallback;
let _BaseCallback: BaseCallback;
Expand Down Expand Up @@ -142,6 +145,9 @@
case callbackType.ReCaptchaCallback:
_RecaptchaCallback = props.callback as ReCaptchaCallback;
break;
case callbackType.ReCaptchaEnterpriseCallback:
_RecaptchaEnterpriseCallback = props.callback as ReCaptchaEnterpriseCallback;
break;
case callbackType.PasswordCallback:
_PasswordCallback = props.callback as PasswordCallback;
break;
Expand Down Expand Up @@ -319,6 +325,12 @@
callback: _RecaptchaCallback,
}}
<Recaptcha {...newProps} />
{:else if cbType === callbackType.ReCaptchaEnterpriseCallback}
{@const newProps = {
...props,
callback: _RecaptchaEnterpriseCallback,
}}
<RecaptchaEnterprise {...newProps} />
{:else if cbType === callbackType.PingOneProtectEvaluationCallback}
{@const newProps = {
...props,
Expand Down
84 changes: 83 additions & 1 deletion core/journey/_utilities/metadata.utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { describe, expect, it } from 'vitest';

import { buildCallbackMetadata, buildStepMetadata } from './metadata.utilities';
import { step1, step2 } from './step.mock';
import { createJourneyStep, step1, step2 } from './step.mock';

describe('Test metadata builder function for callbacks', () => {
it('should have metadata without stage attributes', () => {
Expand Down Expand Up @@ -138,6 +138,88 @@ describe('Test metadata builder function for callbacks', () => {
});
});

describe('Test metadata builder function for callbacks with initializationOptions', () => {
const captchaStep = createJourneyStep({
authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9',
callbacks: [
{
type: 'ReCaptchaCallback',
output: [{ name: 'recaptchaSiteKey', value: 'test-site-key' }],
input: [{ name: 'IDToken1', value: '' }],
_id: 0,
},
],
status: 200,
});

const enterpriseStep = createJourneyStep({
authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9',
callbacks: [
{
type: 'ReCaptchaEnterpriseCallback',
output: [
{ name: 'recaptchaSiteKey', value: 'enterprise-site-key' },
{ name: 'captchaApiUri', value: 'https://www.google.com/recaptcha/enterprise.js' },
{ name: 'captchaDivClass', value: 'g-recaptcha' },
],
input: [
{ name: 'IDToken1token', value: '' },
{ name: 'IDToken1action', value: '' },
],
_id: 0,
},
],
status: 200,
});

it('should attach initOptions.mode to ReCaptchaCallback when captcha config provided', () => {
const result = buildCallbackMetadata(captchaStep, () => false, null, {
captcha: { mode: 'invisible' },
});

expect(result[0].initOptions).toStrictEqual({ mode: 'invisible' });
});

it('should attach initOptions.mode to ReCaptchaEnterpriseCallback when captcha config provided', () => {
const result = buildCallbackMetadata(enterpriseStep, () => false, null, {
captcha: { mode: 'visible' },
});

expect(result[0].initOptions).toStrictEqual({ mode: 'visible' });
});

it('should attach initOptions.recaptchaAction when provided', () => {
const result = buildCallbackMetadata(captchaStep, () => false, null, {
recaptchaAction: 'LOGIN',
});

expect(result[0].initOptions).toStrictEqual({ recaptchaAction: 'LOGIN' });
});

it('should merge captcha config and recaptchaAction into initOptions', () => {
const result = buildCallbackMetadata(captchaStep, () => false, null, {
captcha: { mode: 'invisible' },
recaptchaAction: 'SIGNUP',
});

expect(result[0].initOptions).toStrictEqual({ mode: 'invisible', recaptchaAction: 'SIGNUP' });
});

it('should not attach initOptions when initializationOptions is null', () => {
const result = buildCallbackMetadata(captchaStep, () => false, null, null);

expect(result[0].initOptions).toBeUndefined();
});

it('should not attach initOptions when captcha config and recaptchaAction are absent', () => {
const result = buildCallbackMetadata(captchaStep, () => false, null, {
someOtherOption: true,
});

expect(result[0].initOptions).toBeUndefined();
});
});

describe('Test metadata builder function for step', () => {
it('should have metadata without stage attributes', () => {
const callbackMetadata = [
Expand Down
18 changes: 16 additions & 2 deletions core/journey/_utilities/metadata.utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,30 @@ import type { BaseCallback, JourneyStep } from '@forgerock/journey-client/types'

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

const captchaCallbackTypes = new Set(['ReCaptchaCallback', 'ReCaptchaEnterpriseCallback']);

/**
* @function buildCallbackMetadata - Constructs an array of callback metadata that matches to original callback array
* @param {object} step - The modified Widget step object
* @param {function} checkValidation - function that checks if current callback is the first invalid callback
* @param {object} stageJson - Optional stage JSON from AM
* @param {object} initializationOptions - Optional widget-level initialization options (e.g. captcha config)
* @returns {array}
*/
export function buildCallbackMetadata(
step: JourneyStep,
checkValidation: (callback: BaseCallback) => boolean,
stageJson?: Record<string, unknown> | null,
initializationOptions?: Record<string, unknown> | null,
) {
const callbackCount: Record<string, number> = {};
const isPasskeyAutofillEligible = isMixedLoginWebAuthnStep(step);

return step?.callbacks.map((callback, idx) => {
const cb = callback;
const callbackType = cb.getType();
const callbackType = callback.getType();

let stageCbMetadata;
let initOptions;

if (callbackCount[callbackType]) {
callbackCount[callbackType] = callbackCount[callbackType] + 1;
Expand All @@ -52,6 +57,14 @@ export function buildCallbackMetadata(
stageCbMetadata = stageCbArray[callbackCount[callbackType] - 1];
}

if (captchaCallbackTypes.has(callbackType)) {
const captchaConfig = initializationOptions?.captcha as Record<string, unknown> | undefined;
const recaptchaAction = initializationOptions?.recaptchaAction as string | null | undefined;
if (captchaConfig || recaptchaAction) {
initOptions = { ...captchaConfig, ...(recaptchaAction && { recaptchaAction }) };
}
}

return {
derived: {
canForceUserInputOptionality: canForceUserInputOptionality(callback),
Expand All @@ -68,6 +81,7 @@ export function buildCallbackMetadata(
...stageCbMetadata,
},
}),
...(initOptions && { initOptions }),
};
});
}
Expand Down
Loading
Loading