Skip to content

Commit 69cd247

Browse files
committed
feat(captcha): add invisible reCAPTCHA v2, hCaptcha and reCaptcha Enterprise support
1 parent 0c981c9 commit 69cd247

83 files changed

Lines changed: 6162 additions & 694 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@forgerock/login-framework-cli': minor
3+
---
4+
5+
Add `ping-lf` CLI tool (`tools/cli/`) for bootstrapping and maintaining Login Framework projects.
6+
7+
Three commands:
8+
9+
- `init <directory>` — fetches a GitHub release and scaffolds a new project directory with all necessary configuration, dependencies, and an empty `experimental/custom/` structure ready for `pnpm install`.
10+
- `generate callback|stage <Name>` — scaffolds a new custom callback or stage component from templates, naming files correctly (PascalCase → kebab-case) and regenerating the component registry.
11+
- `update [--version <ver>]` — fetches the target framework release and overwrites core files while preserving `experimental/custom/`, then regenerates the registry and updates `.generator-version`.
12+
13+
Installable via `npm install -g @forgerock/login-framework-cli`.

.github/workflows/ci.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
node-version: 22.x
4141
cache: 'pnpm'
4242
- run: pnpm install --frozen-lockfile
43-
- run: node scripts/generateCustomRegistry.mjs
43+
- run: pnpm run generate:registry
4444
shell: bash
4545

4646
# Cache Playwright browser binaries between runs (~200MB for Chromium)
@@ -130,10 +130,11 @@ jobs:
130130
node-version: 22.x
131131
cache: 'pnpm'
132132
- run: pnpm install --frozen-lockfile
133-
- run: node scripts/generateCustomRegistry.mjs
133+
- run: pnpm run generate:registry
134134
shell: bash
135135
- run: pnpm run check:lint
136136
- run: pnpm run test
137+
- run: pnpm --filter @forgerock/login-framework-cli run test
137138
- name: Install Playwright for Storybook tests
138139
run: pnpm --filter @forgerock/login-widget exec playwright install --with-deps chromium
139140
- name: Build Storybook
@@ -156,7 +157,7 @@ jobs:
156157
node-version: 22.x
157158
cache: 'pnpm'
158159
- run: pnpm install --frozen-lockfile
159-
- run: node scripts/generateCustomRegistry.mjs
160+
- run: pnpm run generate:registry
160161
shell: bash
161162
- uses: chromaui/action@latest
162163
with:

.github/workflows/release.yml

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,21 +50,12 @@ jobs:
5050

5151
- uses: actions/setup-node@v4
5252
with:
53-
node-version: 22.x
53+
node-version: 24.x
5454
cache: 'pnpm'
5555
# No registry-url — OIDC trusted publishing handles auth without NODE_AUTH_TOKEN.
5656
# Setting registry-url creates an .npmrc with _authToken=${NODE_AUTH_TOKEN},
5757
# which prevents OIDC fallback even when the env var is unset.
5858

59-
# Node 22.x ships npm 10.x; OIDC trusted publishing requires npm >= 11.6.
60-
# Use corepack to install the required npm version instead of self-upgrading
61-
# npm, which can fail on runners with corrupted pre-installed npm.
62-
- name: Install npm for OIDC trusted publishing
63-
run: |
64-
corepack enable npm
65-
corepack install -g npm@latest
66-
npm --version
67-
6859
- run: pnpm install --frozen-lockfile
6960

7061
- name: Build release artifacts
@@ -161,15 +152,9 @@ jobs:
161152

162153
- uses: actions/setup-node@v4
163154
with:
164-
node-version: 22.x
155+
node-version: 24.x
165156
cache: 'pnpm'
166157

167-
- name: Install npm for OIDC trusted publishing
168-
run: |
169-
corepack enable npm
170-
corepack install -g npm@latest
171-
npm --version
172-
173158
- run: pnpm install --frozen-lockfile
174159

175160
- name: Build release artifacts

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ apps/login-app/build/
1111
packages/login-widget/dist/
1212
packages/login-widget/svelte-package/
1313

14+
# CLI build output
15+
tools/cli/dist/
16+
1417
# Environment
1518
.env
1619
.env.*
@@ -44,3 +47,5 @@ experimental/custom/callbacks/*/
4447
# Misc
4548
.syncthing*
4649
.eslintcache
50+
tools/cli/tmp/
51+
packages/login-widget/tmp/

apps/login-app/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"dev": "vite dev",
77
"build": "vite build",
88
"preview": "vite preview",
9-
"check:svelte": "svelte-check --tsconfig ./tsconfig.json --threshold=warning"
9+
"check:svelte": "svelte-check --tsconfig ./tsconfig.json --threshold=warning",
10+
"test": "vitest run"
1011
},
1112
"dependencies": {
1213
"@forgerock/login-widget": "workspace:*"
@@ -22,14 +23,15 @@
2223
"autoprefixer": "^10.4.17",
2324
"dotenv": "^16.4.1",
2425
"mdsvex": "0.12.6",
25-
"postcss": "^8.5.8",
26+
"postcss": "^8.5.10",
2627
"remark-autolink-headings": "^7.0.1",
2728
"remark-slug": "^7.0.1",
2829
"svelte": "^5.53.7",
2930
"svelte-check": "^4.4.5",
3031
"svelte-preprocess": "^6.0.3",
3132
"tailwindcss": "^3.3.3",
3233
"uuid": "9.0.1",
33-
"vite": "^5.4.21"
34+
"vite": "^5.4.21",
35+
"vitest": "^3.2.4"
3436
}
3537
}

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/lib/server/_utilities.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
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.
77
*
88
**/
99

10+
interface RewriteCookieParams {
11+
cookie: string;
12+
amDomain: string;
13+
appDomain: string;
14+
}
15+
16+
/**
17+
* @function extractDomainFromUrl - extracts the domain from a given URL string
18+
* @param {unknown} url - The URL to extract the domain from
19+
* @returns {string} The extracted domain
20+
* @throws {Error} If the input is not a string or not a valid URL
21+
*/
1022
export function extractDomainFromUrl(url: unknown) {
1123
if (typeof url !== 'string') {
1224
throw new Error('AM_DOMAIN_PATH is not a string');
@@ -25,16 +37,26 @@ export function extractDomainFromUrl(url: unknown) {
2537
return arr[1];
2638
}
2739

28-
interface RewriteCookieParams {
29-
cookie: string;
30-
amDomain: string;
31-
appDomain: string;
32-
}
33-
40+
/**
41+
* @function rewriteCookieForClient - rewrites a cookie's domain from AM domain to app domain
42+
* @param {object} params - The parameters object
43+
* @param {string} params.cookie - The cookie string
44+
* @param {string} params.amDomain - The AM domain to replace
45+
* @param {string} params.appDomain - The app domain to use
46+
* @returns {string} The rewritten cookie string
47+
*/
3448
export function rewriteCookieForClient({ cookie, amDomain, appDomain }: RewriteCookieParams) {
3549
return cookie.replace(amDomain, appDomain);
3650
}
3751

52+
/**
53+
* @function rewriteCookieForServer - rewrites a cookie's domain from app domain to AM domain
54+
* @param {object} params - The parameters object
55+
* @param {string} params.cookie - The cookie string
56+
* @param {string} params.amDomain - The AM domain to use
57+
* @param {string} params.appDomain - The app domain to replace
58+
* @returns {string} The rewritten cookie string
59+
*/
3860
export function rewriteCookieForServer({ cookie, amDomain, appDomain }: RewriteCookieParams) {
3961
return cookie.replace(appDomain, amDomain);
4062
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Redirects in Login App
2+
3+
This document explains how post-authentication redirect is determined on success and failure, and explains various redirect flows
4+
5+
## Reference:
6+
7+
- https://docs.pingidentity.com/pingoneaic/am-authentication/redirection-url-precedence.html
8+
- https://github.com/ping-rocks/platform-ui/blob/master/packages/platform-login/src/views/Login/index.vue
9+
- https://github.com/ping-rocks/platform-ui/blob/master/packages/platform-shared/src/mixins/LoginMixin/index.vue
10+
11+
## Functionality
12+
13+
- Redirect users to the correct target after login success or failure
14+
- Prevent open-redirect issues by validating URL against `validateGoto` endpoint
15+
- Handle common edge cases: default console paths, SAML endpoints, admin vs end user redirects
16+
17+
## Redirect inputs
18+
19+
Redirect URLs can come from multiple places:
20+
21+
- Query parameters:
22+
- `goto` (success)
23+
- `gotoOnFail` (failure)
24+
- Journey outcome:
25+
- Success URL from the journey step (for example `step.getSuccessUrl()`)
26+
- Failure URL from the journey payload (for example `step.payload.detail.failureUrl`)
27+
- AM defaults (applied by AM when validating):
28+
- User profile success/failure URL attributes
29+
- Realm default success/failure login URL attributes
30+
31+
## Success and Failure redirection flow (`goto` and `gotoOnFail`)
32+
33+
### 1) Initial request (client)
34+
35+
- When the authentication journey completes on the client, a hidden form is submitted to the server to initiate the redirect flow. This form contains the necessary redirect information (such as success/failure state and URLs).
36+
37+
### 2) Storing redirect params (server)
38+
39+
- Read `goto` / `gotoOnFail` from the incoming URL or from the submitted form.
40+
- Store values in a short-lived **HTTP-only** cookie.
41+
42+
### 3) Redirect function performs the final redirect (server)
43+
44+
1. Read and parse the HTTP-only cookie (`goto` / `gotoOnFail`). This cookie is cleared after it’s read to avoid stale redirects.
45+
2. Select a possible `gotoUrl`:
46+
- If `isGotoOnFail=true`: prefer cookie `gotoOnFail`.
47+
- If `isGotoOnFail=false`: prefer cookie `goto`, otherwise use the client-provided URL (typically from the journey success step).
48+
3. Call `validateGoto(authorization, gotoUrl)` in AM. AM may return a `successUrl` even when the input is invalid. It will fall back to the default success URL.
49+
4. If there is no usable `gotoUrl`, compute a default redirect, which redirects to either admin or end user.
50+
5. Final fallback:
51+
52+
- If all redirect logic fails (no valid URL can be determined), the server will redirect to static fallback files:
53+
- `/success-redirect` for success cases
54+
- `/failure-redirect` for failure cases
55+
- These files provide a guaranteed fallback destination for both success and failure scenarios.
56+
57+
## Other flows
58+
59+
### Default path
60+
61+
- detect destinations whose last path segment is `console` (for example `/am/console` or `/auth/console`).
62+
- If `validateGoto` return a non-console URL, use it.
63+
- If `validateGoto` return a console URL:
64+
- Failure flow: return an empty redirect so the client can redirect to the journey `failureUrl` or the global fallback.
65+
- Success flow: if the client provided a non-console URL from the journey, prefer that.
66+
67+
### SAML URLs
68+
69+
If `validateGoto` falls back to a console URL but the original `goto` looks like SAML, return the original `goto`.
70+
For example, when `validateGoto` endpoint returns '/am/console' as successUrl and the corresponding `goto` query param is 'https://default.iam.example.com/am/Consumer/metaAlias/avsp', SAML condition becomes true and the `goto` URL is returned
71+
72+
### Admin vs end user default
73+
74+
When there is no usable `goto` (or redirect selection must fall back), the server computes a default destination:
75+
76+
- Fetch the user record and determine whether the user is an admin (based on roles/groups).
77+
- Admin users go to an admin landing page; non-admin users go to an end user landing page.
78+
79+
### suspendedIdParam
80+
81+
Some journeys like email verification / magic links / text and sms temporarily **suspend** the authentication session.
82+
83+
In these flows:
84+
85+
1. Customer app is where the flow begins.
86+
2. Customer is redirected to authorization server and then to the Login App for authentication.
87+
3. Login app passes the `goto` and `gotoOnFail` params to AM and AM links this parameter with the active auth session in memory. This happens through the SDK options (`StepOptions.query.goto` and `StepOptions.query.gotoOnFail`).
88+
4. AM stores all of these relevant state params in the `suspendedId`, so the magic link sent to the user contains the `goto` param within this `suspendedId` in the URL.
89+
5. AM then restores the goto URL, and AM is able to send the user to this URL upon completion of the journey (turned into the successUrl).

0 commit comments

Comments
 (0)