Skip to content

SDKS-2810: Add Invisible reCAPTCHA, hCaptcha, and Enterprise#478

Open
SteinGabriel wants to merge 1 commit into
mainfrom
SDKS-2810
Open

SDKS-2810: Add Invisible reCAPTCHA, hCaptcha, and Enterprise#478
SteinGabriel wants to merge 1 commit into
mainfrom
SDKS-2810

Conversation

@SteinGabriel
Copy link
Copy Markdown
Contributor

Summary

https://pingidentity.atlassian.net/browse/SDKS-2810

Adds invisible reCAPTCHA v2 and invisible hCaptcha support to the login widget via a new configuration({ captcha: { mode: 'invisible' } }) option. Also adds a dedicated ReCaptchaEnterpriseCallback handler for AM journeys using the Enterprise CAPTCHA node, and a resolveGrecaptcha() helper that prefers window.grecaptcha.enterprise and falls back to classic window.grecaptcha — keeping existing consumers with migrated keys working without changes.

Changes

core/captcha.store.ts

  • New Zod-validated store for captcha.mode ('normal' | 'invisible') — consumer configures via configuration({ captcha: { mode: 'invisible' } })

core/journey/callbacks/recaptcha/

  • recaptcha.svelte — extended with invisible mode: renders hidden div, calls renderCaptchaInvisible on mount, shows inline <Alert type="error"> on failure/expiry, uses resolveGrecaptcha() for Enterprise API compatibility
  • recaptcha-enterprise.svelte — new component for ReCaptchaEnterpriseCallback; visible checkbox or score-based invisible flow driven by callback data; calls setAction(), setResult(), and setClientError() on execute
  • recaptcha-enterprise.utilities.tsexecuteEnterpriseCaptcha and detectEnterpriseProvider helpers extracted for testability

core/journey/stages/_utilities/recaptcha.utilities.ts

  • renderCaptchaInvisible — new helper for invisible rendering for both Google and hCaptcha
  • resolveGrecaptcha() — prefers window.grecaptcha.enterprise, falls back to window.grecaptcha; applied to all window.grecaptcha.* call sites and guard conditions

core/journey/_utilities/callback-mapper.svelte

  • Registered CallbackType.ReCaptchaEnterpriseCallbackrecaptcha-enterprise.svelte

Tests

  • 5 unit tests for captcha.store (initialize(), mode transitions)
  • 17 unit tests for recaptcha.utilities (visible/invisible render, Enterprise namespace, classic fallback, resolveGrecaptcha)
  • 8 unit tests for recaptcha-enterprise.utilities (provider detection, token/action/error set, empty action guard)
  • Storybook stories: 4 classic variants (VisibleGoogle, VisibleHCaptcha, InvisibleGoogle, InvisibleError) + 3 Enterprise variants (VisibleEnterprise, InvisibleEnterprise, InvisibleEnterpriseError)
  • 3 Playwright E2E tests: classic visible token submit, Enterprise visible render, Enterprise invisible execute+submit — AM mocked via route.fulfill

How to test

1. Unit tests

pnpm --filter @forgerock/login-widget exec vitest run 'captcha'

All 30 tests should pass.

2. Storybook

pnpm storybook

Navigate to Callbacks/ReCaptcha and Callbacks/ReCaptchaEnterprise — verify all story variants render without errors.

3. E2E tests

pnpm build:app
pnpm --filter @forgerock/login-widget-e2e exec playwright install chromium
pnpm ci:e2e -- tests/widget/modal/widget-modal.recaptcha.test.js

4. Verify Enterprise backward compatibility

Load enterprise.js in a test page. Confirm resolveGrecaptcha() resolves to window.grecaptcha.enterprise. Load api.js instead — confirm it falls back to window.grecaptcha without errors.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 30, 2026

🦋 Changeset detected

Latest commit: daa76ed

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@forgerock/login-widget Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@SteinGabriel SteinGabriel marked this pull request as draft April 30, 2026 22:31
@SteinGabriel SteinGabriel marked this pull request as ready for review April 30, 2026 22:42
Copy link
Copy Markdown
Contributor

@vatsalparikh vatsalparikh left a comment

Choose a reason for hiding this comment

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

I see that Gabriel has added callbacks and stages using the old legacy forgerock sdk, so this will stop working completely as soon as my journey client changes are merged to main. I suggest we use the new journey client here, because my journey client PR #468 will soon be merged to main.

Copy link
Copy Markdown
Contributor

@vatsalparikh vatsalparikh left a comment

Choose a reason for hiding this comment

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

Initial review comments

Comment thread apps/login-app/src/app.html Outdated
Comment thread apps/login-app/src/routes/(app)/+page.svelte Outdated
const recaptchaClass: string =
callback?.getOutputByName<string>('captchaDivClass', 'h-captcha') ?? 'h-captcha';

const captchaProvider: 'hcaptcha' | 'grecaptcha' =
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.

We are handling both hCaptcha and recaptcha in this file and in the folder in general, so we should rename all files under and along with recaptcha folder to be captcha instead

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. I think we should add this to a new tech debt ticket, along with a few other comments from this PR, to avoid adding more changes into an already large diff.

Comment thread core/journey/stages/_utilities/recaptcha.utilities.ts Outdated
Comment thread core/journey/stages/_utilities/recaptcha.utilities.ts
Comment thread e2e/tests/widget/modal/widget-modal.recaptcha.test.js
Comment thread e2e/tests/widget/modal/widget-modal.recaptcha.test.js
Comment thread packages/login-widget/src/lib/_utilities/api.utilities.ts Outdated
Copy link
Copy Markdown
Contributor

@cerebrl cerebrl left a comment

Choose a reason for hiding this comment

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

I'd really like for us to reconsider some architectural choices here. If you want, we can jump on a call tomorrow and discuss it.

Comment on lines +11 to +23
import type { ReCaptchaEnterpriseCallback } from '@forgerock/javascript-sdk';
import type { z } from 'zod';
import { onMount } from 'svelte';
import { journeyStore } from '$journey/journey.store';

import type { SelfSubmitFunction, StepMetadata } from '$journey/journey.interfaces';
import type { styleSchema } from '$core/style.store';
import type { Maybe } from '$core/interfaces';
import { renderCaptcha } from '$journey/stages/_utilities/recaptcha.utilities';
import { executeEnterpriseCaptcha, detectEnterpriseProvider } from './recaptcha-enterprise.utilities';
import { captchaStore } from '$core/captcha.store';
import Alert from '$components/primitives/alert/alert.svelte';
import { interpolate } from '$core/_utilities/i18n.utilities';
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'd like to reorder these imports to the below pattern with a blank lines separating the groups. Imports should be alphabetized according to the imported filename:

// import external runtime dependencies

// import external type dependencies

// import internal runtime dependencies

// import internal type dependencies

// rest of code ...

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 already have the import order PR approved, and we should be able to handle it automatically. As soon as I merge journey client, I will merge import order PR as well.

#473

Just wanted to put it out here so Gabriel doesn't spend time fixing it.

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.

Updated the SDK import to journey-clients and applied the import ordering convention. Since this is a new file, it was not updated automatically after journey-client PR was merged.

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.

It should apply to new or existing files just the same. Dunno why that did not work for you. I reshuffled a few imports in core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte and it put them back in the import order.

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.

Confirmed, recaptcha-enterprise.svelte import order is correct and ESLint passes clean. The auto-fix must not have run on this new file initially because the import ordering PR was not merged yet at that point. The imports must've got ordered when I updated this branch from main after that PR was merged.

import type { SelfSubmitFunction, StepMetadata } from '$journey/journey.interfaces';
import type { styleSchema } from '$core/style.store';
import type { Maybe } from '$core/interfaces';
import { renderCaptcha } from '$journey/stages/_utilities/recaptcha.utilities';
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'd like to avoid this pattern of a callback component relying on a stage utility. To me, a stage-related file can pull in a callback-related file, but the reverse is an anti-pattern. This is a part of the hierarchical design of the system.

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.

For sure, that makes sense. I moved resolveGrecaptcha inline into the enterprise utilities file and added a renderEnterpriseCaptcha helper. No more stage imports from this callback file.

Note: recaptcha.svelte still imports handleCaptchaError, handleCaptchaToken, renderCaptcha, renderCaptchaInvisible, and resolveGrecaptcha from the stage utilities file. That's a pre-existing pattern. Fully decoupling it would require either duplicating those helpers or extracting them to a shared non-stage utilities file, which feels like a larger refactor. Should we include that in this PR or create a tech debt ticket instead?


journeyStore.subscribe((value) => {
recaptchaAction = value?.recaptchaAction ?? '';
});
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.

This concerns me. I'd really like to find a way to do this that doesn't involve pulling in the entire Journey Store into a callback component.

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.

Exported a recaptchaActionStore derived store from journey.store.ts that exposes only recaptchaAction. Both captcha callback components now subscribe to that instead of the full store.

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.

Sorry, I wrote this comment before I saw this one: #478 (comment)

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 see you added the recaptchaAction to the callbackMetadata, but I don't see the use of it in this component. My desire is to use the value that comes from the metadata, rather than relying on this store.

}) {
if (nameOfCaptcha === 'hcaptcha' && window.hcaptcha) {
return window.hcaptcha.render('fr-recaptcha', {
return window.hcaptcha.render(elementId, {
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.

This deviates from our conventions of utility functions being pure, stateless functions. Typically, anything that involves DOM or Window use is in a Svelte component. But, if something needs to access external systems, we would call it an "effect" (not to be confused with Effect, the library).

With that said, I see that this file already has window use, so we can leave it. But, I do want to stress that we should move away from this as it should be considered an anti-pattern.

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.

Agreed. We can tackle this alongside the stage import in recaptcha.svelte callback file in a new ticket.

@SteinGabriel SteinGabriel force-pushed the SDKS-2810 branch 3 times, most recently from 63bd15f to c8ec83e Compare May 5, 2026 21:33
Comment thread core/captcha.store.ts Outdated
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.

This store feels like it's a lot for just a single string value, essentially. I'm also a bit unsure about it living at the root of the core directory.

I'd like to take advantage of the Callback Metadata structure we have for this value. This value seems to only be relevant for the Enterprise Callback, and nothing else. So, it's prominence in this project is quite asymmetrical to its actual use.

Can we pass this value into the system using the existing code and attach it to the appropriate callback using the Callback Metadata structure. I know @vatsalparikh used this very pattern for his WebAuthn Autofill work, which I really liked. Can we think on this a bit more this?

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.

Looked into this more carefully. I think captchaMode can't fit the Callback Metadata pattern that returns derived from buildCallbackMetadata(the autofill path @vatsalparikh used), which computes values from the step/callback structure in widget code. isPasskeyAutofillEligible works because the step structure itself signals eligibility (NameCallback + WebAuthn metadata + outcome callback all present). captchaMode has no equivalent signal in the callback payload. Neither ReCaptchaCallback nor ReCaptchaEnterpriseCallback expose an output field for invisible mode.

That said, the store for one value concern is fair. Happy to fold captchaMode into the existing journey/config.store (removes the separate file, no consumer API change) or move captcha.store.ts from core/ root into core/journey/ so its scope feels more proportional. Let me know which direction you'd prefer. Thank you!

Copy link
Copy Markdown
Contributor

@cerebrl cerebrl May 7, 2026

Choose a reason for hiding this comment

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

Hey Gabriel, thanks for the thoughtful reply. I agree with all your points, but want to add that the buildCallbackMetadata is not exclusively for deriving data. It does do that, BUT its original intention is to accumulate a data structure that comes from non-callback sources to help inform how the callback should be rendered/managed. It doesn't have to exclusively be derived from callbacks themselves.

This broader intention is why derived is a property of the object, not the object itself. There's nothing stopping us from adding a different property, say initConfig or initOptions that could contain this mode: 'invisible' key-value pair. So, in other words:

// Pseudocode
export function buildCallbackMetadata(
  step: FRStep,
  checkValidation: (callback: FRCallback) => boolean,
  stageJson?: Record<string, unknown> | null,
  initializationOptions?: Record<string, unknown> | null, <-- it just needs 
) {
  const callbackCount: Record<string, number> = {};

  return step?.callbacks.map((callback, idx) => {
    let initOptions;

    // ...

    if (callback.type === <captcha callback>) {
      initOptions = initializationOptions.captcha;
    }

    return {
      derived: {
        // ...
      },
      ...(initOptions && { initOptions }), <-- new property
      idx,
      // Only use the `platform` prop if there's metadata to add
      ...(stageCbMetadata && {
        platform: {
          ...stageCbMetadata,
        },
      }),
    };
  });
}

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.

Got it, thanks for clarifying. I was reading buildCallbackMetadata as a mapping of callback metadata coming from AM, not as local metadata config. It makes perfect sense to me now, agree it's the right place for captcha init settings.

That said, captcha.store.ts has been deleted and replaced it with the initOptions pattern from your pseudocode. buildCallbackMetadata now accepts an initializationOptions param, for ReCaptchaCallback and ReCaptchaEnterpriseCallback, it attaches initOptions = initializationOptions.captcha to the returned metadata object. Both callback components receive callbackMetadata as a prop and read
mode from callbackMetadata?.initOptions?.mode. The captcha config option on configuration() / WidgetConfigOptions is preserved — it's passed into initializeJourney() as { captcha } and threaded through to buildCallbackMetadata.

Copy link
Copy Markdown
Contributor

@vatsalparikh vatsalparikh left a comment

Choose a reason for hiding this comment

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

Overall, looking good now, the codebase looks good.

Justin already mentioned comments about using callback metadata (or similar) instead of captcha store and putting window in a separate file so those utility files should become leaner, and any effectful code should be under effects file for both enterprise and normal recaptcha.

I tested recaptcha enterprise with login journey. Gabriel helped configuring a new recatpcha enterprise, and I was able to solve the recaptcha but login failed. It's possible that the new config was missing something.

Comment thread core/journey/callbacks/recaptcha/recaptcha.mock.ts Outdated
Comment on lines +11 to +23
import type { ReCaptchaEnterpriseCallback } from '@forgerock/javascript-sdk';
import type { z } from 'zod';
import { onMount } from 'svelte';
import { journeyStore } from '$journey/journey.store';

import type { SelfSubmitFunction, StepMetadata } from '$journey/journey.interfaces';
import type { styleSchema } from '$core/style.store';
import type { Maybe } from '$core/interfaces';
import { renderCaptcha } from '$journey/stages/_utilities/recaptcha.utilities';
import { executeEnterpriseCaptcha, detectEnterpriseProvider } from './recaptcha-enterprise.utilities';
import { captchaStore } from '$core/captcha.store';
import Alert from '$components/primitives/alert/alert.svelte';
import { interpolate } from '$core/_utilities/i18n.utilities';
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.

It should apply to new or existing files just the same. Dunno why that did not work for you. I reshuffled a few imports in core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte and it put them back in the import order.

Comment thread packages/login-widget/package.json
…erprise 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
Copy link
Copy Markdown
Contributor

@ryanbas21 ryanbas21 left a comment

Choose a reason for hiding this comment

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

First pass, this will take me a few reads because its ReCaptcha.

For CAPTCHA-enabled journeys: use enterprise.js, not api.js —
new Google keys require the Enterprise namespace.
-->
<script src="https://www.google.com/recaptcha/enterprise.js" async defer></script>
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.

Don't we need the siteKey in here? or is this changed now?

*/
export function resolveGrecaptcha(): ReCaptchaV2.ReCaptcha {
const grecaptcha = window.grecaptcha as ReCaptchaV2.ReCaptcha & {
enterprise?: ReCaptchaV2.ReCaptcha;
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.

is ReCaptchaV2 just a global type we have (I think it is but I have forgotten).

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?

const journeyParam = $page.url.searchParams.get('journey');
const suspendedIdParam = $page.url.searchParams.get('suspendedId');
const captchaModeParam = $page.url.searchParams.get('captchaMode') as
| 'normal'
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 think maybe we should use the actual names of recaptcha here, normal is understandable but I think it may be more clear to use invisible and Not a Robot then we have a third type which is Score based

If we use normal and the world changes to Score based we are out of sync with the standard and we break the api.

Copy link
Copy Markdown
Contributor

@cerebrl cerebrl left a comment

Choose a reason for hiding this comment

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

I have a few comments around the continued use of the captcha store and if it's still needed. The other captcha comments I'll leave to Ryan as I'm not familiar enough with the spec to weigh in.


journeyStore.subscribe((value) => {
recaptchaAction = value?.recaptchaAction ?? '';
});
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 see you added the recaptchaAction to the callbackMetadata, but I don't see the use of it in this component. My desire is to use the value that comes from the metadata, rather than relying on this store.

Comment on lines +629 to +632

export const recaptchaActionStore = derived(journeyStore, ($store) => ({
recaptchaAction: $store.recaptchaAction ?? '',
}));
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.

Is this still needed now that we have the value woven into the callbackMetadata?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants