SDKS-2810: Add Invisible reCAPTCHA, hCaptcha, and Enterprise#478
SDKS-2810: Add Invisible reCAPTCHA, hCaptcha, and Enterprise#478SteinGabriel wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: daa76ed The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
There was a problem hiding this comment.
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.
vatsalparikh
left a comment
There was a problem hiding this comment.
Initial review comments
| const recaptchaClass: string = | ||
| callback?.getOutputByName<string>('captchaDivClass', 'h-captcha') ?? 'h-captcha'; | ||
|
|
||
| const captchaProvider: 'hcaptcha' | 'grecaptcha' = |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
cerebrl
left a comment
There was a problem hiding this comment.
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.
| 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'; |
There was a problem hiding this comment.
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 ...
There was a problem hiding this comment.
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.
Just wanted to put it out here so Gabriel doesn't spend time fixing it.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ?? ''; | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Sorry, I wrote this comment before I saw this one: #478 (comment)
There was a problem hiding this comment.
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, { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Agreed. We can tackle this alongside the stage import in recaptcha.svelte callback file in a new ticket.
63bd15f to
c8ec83e
Compare
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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,
},
}),
};
});
}
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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'; |
There was a problem hiding this comment.
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.
…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
ryanbas21
left a comment
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
is ReCaptchaV2 just a global type we have (I think it is but I have forgotten).
| PingOneProtectInitializeCallback, | ||
| PollingWaitCallback, | ||
| ReCaptchaCallback, | ||
| ReCaptchaEnterpriseCallback, |
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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.
cerebrl
left a comment
There was a problem hiding this comment.
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 ?? ''; | ||
| }); |
There was a problem hiding this comment.
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.
|
|
||
| export const recaptchaActionStore = derived(journeyStore, ($store) => ({ | ||
| recaptchaAction: $store.recaptchaAction ?? '', | ||
| })); |
There was a problem hiding this comment.
Is this still needed now that we have the value woven into the callbackMetadata?
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 dedicatedReCaptchaEnterpriseCallbackhandler for AM journeys using the Enterprise CAPTCHA node, and aresolveGrecaptcha()helper that preferswindow.grecaptcha.enterpriseand falls back to classicwindow.grecaptcha— keeping existing consumers with migrated keys working without changes.Changes
core/captcha.store.tscaptcha.mode('normal'|'invisible') — consumer configures viaconfiguration({ captcha: { mode: 'invisible' } })core/journey/callbacks/recaptcha/recaptcha.svelte— extended with invisible mode: renders hidden div, callsrenderCaptchaInvisibleon mount, shows inline<Alert type="error">on failure/expiry, usesresolveGrecaptcha()for Enterprise API compatibilityrecaptcha-enterprise.svelte— new component forReCaptchaEnterpriseCallback; visible checkbox or score-based invisible flow driven by callback data; callssetAction(),setResult(), andsetClientError()on executerecaptcha-enterprise.utilities.ts—executeEnterpriseCaptchaanddetectEnterpriseProviderhelpers extracted for testabilitycore/journey/stages/_utilities/recaptcha.utilities.tsrenderCaptchaInvisible— new helper for invisible rendering for both Google and hCaptcharesolveGrecaptcha()— preferswindow.grecaptcha.enterprise, falls back towindow.grecaptcha; applied to allwindow.grecaptcha.*call sites and guard conditionscore/journey/_utilities/callback-mapper.svelteCallbackType.ReCaptchaEnterpriseCallback→recaptcha-enterprise.svelteTests
captcha.store(initialize(), mode transitions)recaptcha.utilities(visible/invisible render, Enterprise namespace, classic fallback,resolveGrecaptcha)recaptcha-enterprise.utilities(provider detection, token/action/error set, empty action guard)VisibleGoogle,VisibleHCaptcha,InvisibleGoogle,InvisibleError) + 3 Enterprise variants (VisibleEnterprise,InvisibleEnterprise,InvisibleEnterpriseError)route.fulfillHow to test
1. Unit tests
All 30 tests should pass.
2. 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.js4. Verify Enterprise backward compatibility
Load
enterprise.jsin a test page. ConfirmresolveGrecaptcha()resolves towindow.grecaptcha.enterprise. Loadapi.jsinstead — confirm it falls back towindow.grecaptchawithout errors.