Skip to content

feat(journey-app): delete webauthn devices using device client#549

Open
vatsalparikh wants to merge 1 commit intomainfrom
sdks-4504
Open

feat(journey-app): delete webauthn devices using device client#549
vatsalparikh wants to merge 1 commit intomainfrom
sdks-4504

Conversation

@vatsalparikh
Copy link

@vatsalparikh vatsalparikh commented Mar 17, 2026

JIRA Ticket

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

Description

This PR adds functionality to journey-app to be able to delete devices during a CI run. A playwright test is added to demo how devices can be retrieved and deleted. The functionality supports the ability to preserve devices not part of the current CI run.

For better context:
get and delete method calls to device-client expect a uuid of the user, and so the uuid is first retrieved using the OIDC flow from oidc-client and then device-client methods can be called.

To summarize, I created three buttons, get, delete for session, and delete all. I am calling these buttons from the playwright test. I am also using local storage for storing devices registered before the current session. Because get devices needs to be called before webauthn flow so we can capture webauthn registrations, I am doing a normal login flow to retrieve existing registered devices, then doing the webauthn flow to register, authenticate, then delete devices from the current session.

Summary by CodeRabbit

  • New Features

    • WebAuthn device management UI: view registered devices, delete devices for current session, or delete all devices
    • Improved WebAuthn flow: dedicated handling for registration and authentication steps with immediate submission for WebAuthn steps
    • Restored completion screen: session token display and Logout that restarts the journey
  • Tests

    • New end-to-end tests covering WebAuthn registration, authentication, and device deletion scenarios
  • Style

    • Improved contrast for preformatted status text in the app UI

@changeset-bot

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

@nx-cloud

This comment was marked as resolved.

@vatsalparikh vatsalparikh marked this pull request as ready for review March 17, 2026 19:37
nx-cloud[bot]

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as resolved.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 17, 2026

Open in StackBlitz

@forgerock/davinci-client

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/davinci-client@549

@forgerock/device-client

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/device-client@549

@forgerock/journey-client

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/journey-client@549

@forgerock/oidc-client

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/oidc-client@549

@forgerock/protect

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/protect@549

@forgerock/sdk-types

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/sdk-types@549

@forgerock/sdk-utilities

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/sdk-utilities@549

@forgerock/iframe-manager

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/iframe-manager@549

@forgerock/sdk-logger

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/sdk-logger@549

@forgerock/sdk-oidc

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/sdk-oidc@549

@forgerock/sdk-request-middleware

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/sdk-request-middleware@549

@forgerock/storage

pnpm add https://pkg.pr.new/ForgeRock/ping-javascript-sdk/@forgerock/storage@549

commit: 266f7f0

@github-actions
Copy link
Contributor

github-actions bot commented Mar 17, 2026

Deployed 8544313 to https://ForgeRock.github.io/ping-javascript-sdk/pr-549/85443130dc1b12473cae1f65257fd7350f3a3bda branch gh-pages in ForgeRock/ping-javascript-sdk

@github-actions
Copy link
Contributor

github-actions bot commented Mar 17, 2026

📦 Bundle Size Analysis

📦 Bundle Size Analysis

🚨 Significant Changes

🔺 @forgerock/device-client - 9.7 KB (+0.5 KB, +5.8%)
🔻 @forgerock/device-client - 0.0 KB (-9.2 KB, -100.0%)

🆕 New Packages

🆕 @forgerock/journey-client - 87.8 KB (new)
🆕 @forgerock/journey-client - 0.0 KB (new)

➖ No Changes

@forgerock/sdk-logger - 1.6 KB
@forgerock/sdk-request-middleware - 4.5 KB
@forgerock/iframe-manager - 2.4 KB
@forgerock/sdk-oidc - 4.8 KB
@forgerock/storage - 1.5 KB
@forgerock/sdk-types - 7.9 KB
@forgerock/protect - 150.1 KB
@forgerock/davinci-client - 41.3 KB
@forgerock/sdk-utilities - 11.2 KB
@forgerock/oidc-client - 24.9 KB


14 packages analyzed • Baseline from latest main build

Legend

🆕 New package
🔺 Size increased
🔻 Size decreased
➖ No change

ℹ️ How bundle sizes are calculated
  • Current Size: Total gzipped size of all files in the package's dist directory
  • Baseline: Comparison against the latest build from the main branch
  • Files included: All build outputs except source maps and TypeScript build cache
  • Exclusions: .map, .tsbuildinfo, and .d.ts.map files

🔄 Updated automatically on each push to this PR

@codecov-commenter
Copy link

codecov-commenter commented Mar 17, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 14.77%. Comparing base (4c17ba5) to head (266f7f0).
⚠️ Report is 34 commits behind head on main.

❌ Your project status has failed because the head coverage (14.77%) is below the target coverage (40.00%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #549      +/-   ##
==========================================
- Coverage   18.84%   14.77%   -4.08%     
==========================================
  Files         142      153      +11     
  Lines       27770    26262    -1508     
  Branches      990     1056      +66     
==========================================
- Hits         5232     3879    -1353     
+ Misses      22538    22383     -155     

see 49 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@e2e/journey-app/components/webauthn.ts`:
- Around line 21-34: The catch block in handleWebAuthn currently swallows errors
from WebAuthn.authenticate() and WebAuthn.register(), so change handleWebAuthn
(inside webauthnComponent) to return an explicit success/failure signal (e.g., a
boolean or thrown error) instead of always resolving: on success return true, on
any failure either rethrow the caught error or return false (do not just
console.error), and update the caller in main.ts to await handleWebAuthn() and
only call submitForm() when the signal indicates success; reference the
handleWebAuthn function, WebAuthn.authenticate / WebAuthn.register calls,
webAuthnStepType/WebAuthnStepType, webauthnComponent(), and submitForm() when
making these changes.

In `@e2e/journey-app/main.ts`:
- Around line 154-160: The code currently logs the session token and injects it
via journeyEl.innerHTML, creating an injection sink and duplicate id on the
<pre> tag; instead stop logging the session, build the success view using DOM
APIs: create the h2 (completeHeader), span (sessionLabel), pre (sessionToken)
and button (logoutButton) nodes, set the session string into the pre via
textContent (not innerHTML), append them to journeyEl, and ensure only one
id="sessionToken" exists and the logout button gets its event listener as
before.

In `@e2e/journey-app/services/delete-webauthn-devices.ts`:
- Around line 20-35: WEBAUTHN_DEVICES_KEY is global and causes cross-user/realm
leakage; change getStoredDevices() and all store/delete flows (including
deleteDevicesInSession() and deleteAllDevices()) to derive the localStorage key
per-user+realm by calling getStoredDevicesKey(realm, userId) after
getWebAuthnDevicesForCurrentUser() returns its realm and userId, then read/write
that namespaced key instead of the shared WEBAUTHN_DEVICES_KEY so each
user+realm has an isolated baseline.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 96d920ce-e219-48ef-b591-df282a4d2e73

📥 Commits

Reviewing files that changed from the base of the PR and between b57971c and 44eca06.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • e2e/journey-app/components/webauthn-devices.ts
  • e2e/journey-app/components/webauthn.ts
  • e2e/journey-app/main.ts
  • e2e/journey-app/package.json
  • e2e/journey-app/server-configs.ts
  • e2e/journey-app/services/delete-webauthn-devices.ts
  • e2e/journey-app/style.css
  • e2e/journey-app/tsconfig.app.json
  • e2e/journey-suites/src/recovery-codes.test.ts
  • e2e/journey-suites/src/webauthn-devices.test.ts
  • packages/device-client/package.json
💤 Files with no reviewable changes (1)
  • e2e/journey-suites/src/recovery-codes.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • e2e/journey-app/tsconfig.app.json
  • e2e/journey-app/style.css
  • e2e/journey-app/components/webauthn-devices.ts
  • packages/device-client/package.json

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
e2e/journey-app/main.ts (1)

99-103: ⚠️ Potential issue | 🟡 Minor

Error message rendered via innerHTML is also an injection risk.

Similar to the session token, the error message is interpolated into HTML without sanitization. If the server returns a crafted error message, it could execute scripts.

🛡️ Proposed fix using textContent
     if (errorEl) {
-      errorEl.innerHTML = `
-        <pre id="errorMessage">${error}</pre>
-        `;
+      const errorPre = document.createElement('pre');
+      errorPre.id = 'errorMessage';
+      errorPre.textContent = error ?? '';
+      errorEl.replaceChildren(errorPre);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/main.ts` around lines 99 - 103, The code assigns unsanitized
error text into innerHTML on the errorEl element (see errorEl and errorMessage),
creating an XSS risk; change this to create or select a <pre> element and set
its textContent (or use createTextNode) instead of using innerHTML so the error
string is rendered as plain text; update the block that currently assigns
errorEl.innerHTML to instead populate a safe element via textContent and
preserve any intended formatting without injecting HTML.
🧹 Nitpick comments (2)
e2e/journey-suites/src/webauthn-devices.test.ts (2)

96-114: Consider wrapping CDP cleanup in try-catch to ensure cleanup completes.

If cdp.send('WebAuthn.removeVirtualAuthenticator', ...) throws, cdp.send('WebAuthn.disable') won't execute. While rare, a failure during cleanup could leave the virtual authenticator attached for subsequent tests.

🧹 Proposed defensive cleanup
   test.afterEach(async ({ page }) => {
     await page.unroute('**/*');
 
     try {
       await deleteDevicesInSession(page);
     } catch (error) {
       console.error('Delete failed:', error);
     }
 
     if (!cdp) {
       return;
     }
 
-    if (authenticatorId) {
-      await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
+    try {
+      if (authenticatorId) {
+        await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
+      }
+    } catch (error) {
+      console.error('Failed to remove virtual authenticator:', error);
     }
 
-    await cdp.send('WebAuthn.disable');
+    try {
+      await cdp.send('WebAuthn.disable');
+    } catch (error) {
+      console.error('Failed to disable WebAuthn:', error);
+    }
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-suites/src/webauthn-devices.test.ts` around lines 96 - 114, Wrap
the CDP cleanup calls in test.afterEach so removal errors don't prevent
disabling: when cdp and authenticatorId are present, call
cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }) inside a
try-catch (log the error) and always call cdp.send('WebAuthn.disable') in a
finally or subsequent safe block so disable runs even if
removeVirtualAuthenticator throws; reference the existing test.afterEach,
authenticatorId, and cdp.send(...) calls to locate where to add the
try/catch/finally.

20-36: Add TypeScript type annotations for helper function parameters.

The page parameter lacks type annotations in login, logout, getDevicesBeforeSession, deleteDevicesInSession, and completeAuthenticationJourney. Adding the Page type improves IDE support and catches type errors.

✨ Proposed fix
+import type { Page, CDPSession } from '@playwright/test';
+
-  async function login(page, journey = 'Login'): Promise<void> {
+  async function login(page: Page, journey = 'Login'): Promise<void> {

Apply similarly to logout, getDevicesBeforeSession, deleteDevicesInSession, completeAuthenticationJourney, and completeRegistrationJourney.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-suites/src/webauthn-devices.test.ts` around lines 20 - 36, Add
explicit TypeScript Page parameter types to all helper functions that accept a
Playwright page: update function signatures for login, logout,
getDevicesBeforeSession, deleteDevicesInSession, completeAuthenticationJourney,
and completeRegistrationJourney to type the first parameter as Page (importing
Page from '@playwright/test' or 'playwright' as used in the test suite) so
IDE/typechecker can validate usages; ensure the import for Page is added or
consolidated at the top of the file if missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@e2e/journey-app/main.ts`:
- Around line 126-134: The WebAuthn branch currently calls submitForm()
unconditionally after webauthnComponent(...), which can submit despite internal
WebAuthn failures; change the flow in the WebAuthn.getWebAuthnStepType /
isWebAuthn branch to await a success result from webauthnComponent(journeyEl,
step, 0) (or have it return a boolean or throw on failure), then only call
submitForm() when that result indicates success, and otherwise abort/return and
surface or handle the error (e.g., show an error UI or log) so submitForm() is
not invoked on WebAuthn failure.

In `@e2e/journey-app/services/delete-webauthn-devices.ts`:
- Around line 28-31: Wrap the JSON.parse call for retrievedDevices in a
try/catch to guard against malformed JSON, so parse errors are caught and a
clear, controlled error is thrown (or handled) before the Array.isArray check;
specifically, update the logic around the parsedDevices assignment in
delete-webauthn-devices.ts to try parsing retrievedDevices, catch any exception
(referencing retrievedDevices and parsedDevices), and rethrow or return a
descriptive error like "Invalid JSON in localStorage for WebAuthn devices"
including the caught error message, then proceed to the existing Array.isArray
validation.
- Around line 43-47: getRealmFromWellknown currently returns only the final
realm segment which breaks nested realm URLs; update it to return the full
nested realm path (e.g. "root/realms/alpha") instead of just "alpha" so API
calls like `/json/realms/${realm}/users/...` produce correct URLs. Replace the
regex that captures a single segment with one that captures the entire path
between the first `/realms/` and `/.well-known` (or simply call and return the
utility getRealmUrlPath() from packages/sdk-utilities), and ensure any consumers
of getRealmFromWellknown keep using the returned string directly for
constructing `/json/realms/${realm}/...` endpoints.

---

Outside diff comments:
In `@e2e/journey-app/main.ts`:
- Around line 99-103: The code assigns unsanitized error text into innerHTML on
the errorEl element (see errorEl and errorMessage), creating an XSS risk; change
this to create or select a <pre> element and set its textContent (or use
createTextNode) instead of using innerHTML so the error string is rendered as
plain text; update the block that currently assigns errorEl.innerHTML to instead
populate a safe element via textContent and preserve any intended formatting
without injecting HTML.

---

Nitpick comments:
In `@e2e/journey-suites/src/webauthn-devices.test.ts`:
- Around line 96-114: Wrap the CDP cleanup calls in test.afterEach so removal
errors don't prevent disabling: when cdp and authenticatorId are present, call
cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }) inside a
try-catch (log the error) and always call cdp.send('WebAuthn.disable') in a
finally or subsequent safe block so disable runs even if
removeVirtualAuthenticator throws; reference the existing test.afterEach,
authenticatorId, and cdp.send(...) calls to locate where to add the
try/catch/finally.
- Around line 20-36: Add explicit TypeScript Page parameter types to all helper
functions that accept a Playwright page: update function signatures for login,
logout, getDevicesBeforeSession, deleteDevicesInSession,
completeAuthenticationJourney, and completeRegistrationJourney to type the first
parameter as Page (importing Page from '@playwright/test' or 'playwright' as
used in the test suite) so IDE/typechecker can validate usages; ensure the
import for Page is added or consolidated at the top of the file if missing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 75d15fa2-4e74-46c3-97e5-e1e1b622ca9c

📥 Commits

Reviewing files that changed from the base of the PR and between 44eca06 and 01248e6.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • e2e/journey-app/components/webauthn-devices.ts
  • e2e/journey-app/components/webauthn.ts
  • e2e/journey-app/main.ts
  • e2e/journey-app/package.json
  • e2e/journey-app/server-configs.ts
  • e2e/journey-app/services/delete-webauthn-devices.ts
  • e2e/journey-app/style.css
  • e2e/journey-app/tsconfig.app.json
  • e2e/journey-suites/src/recovery-codes.test.ts
  • e2e/journey-suites/src/webauthn-devices.test.ts
  • packages/device-client/package.json
💤 Files with no reviewable changes (1)
  • e2e/journey-suites/src/recovery-codes.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • e2e/journey-app/server-configs.ts
  • packages/device-client/package.json
  • e2e/journey-app/package.json
  • e2e/journey-app/components/webauthn-devices.ts

Comment on lines +43 to +47
function getRealmFromWellknown(wellknown: string): string {
const pathname = new URL(wellknown).pathname;
const match = pathname.match(/\/realms\/([^/]+)\/\.well-known\/openid-configuration\/?$/);
return match?.[1] ?? 'root';
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how deviceClient uses the realm parameter in API calls
ast-grep --pattern $'deviceClient({
  realmPath: $_,
  $$$
})'

# Check the API endpoint construction in device-client
rg -nA5 'getWebAuthnDevices|deleteWebAuthnDeviceName' packages/device-client/src

Repository: ForgeRock/ping-javascript-sdk

Length of output: 2826


🏁 Script executed:

#!/bin/bash
# Find where the wellknown URL is used and what format it has
rg -n 'well-known|wellknown' e2e/journey-app/services/delete-webauthn-devices.ts -B3 -A3

# Check if there are tests for realm extraction
fd -e test.ts -e test.js -e spec.ts | xargs rg -l 'getRealmFromWellknown|realm' 2>/dev/null | head -5

# Look for any realm path handling patterns in the codebase
rg -n 'realmPath|realm.*path' packages/device-client/src -A2 -B2

Repository: ForgeRock/ping-javascript-sdk

Length of output: 10671


🏁 Script executed:

#!/bin/bash
# Look for test cases or usage of getRealmFromWellknown
rg -n 'getRealmFromWellknown' -B5 -A5

# Check if there are test files for realm extraction
fd -e test.ts -e spec.ts | xargs rg -l 'getRealmFromWellknown' 2>/dev/null

# Look for actual wellknown URL examples in the codebase
rg -n '\.well-known' --type ts -B2 -A2 | head -50

# Check how the wellknown URL is typically constructed/passed
rg -n 'wellknown.*=' e2e/journey-app -B3 -A3

Repository: ForgeRock/ping-javascript-sdk

Length of output: 7409


🏁 Script executed:

#!/bin/bash
# Look for where wellknown URLs are constructed or fetched
rg -n 'serverConfig\.wellknown|wellknown.*=' packages/journey-client packages/oidc-client -B3 -A3

# Check the oidc-client initialization to see what wellknown URL format it expects
rg -n 'wellknown' packages/oidc-client/src -B2 -A2 | head -60

# Look for any realm path handling in well-known discovery
rg -n 'realms/' packages/sdk-utilities packages/oidc-client -B2 -A2

Repository: ForgeRock/ping-javascript-sdk

Length of output: 20810


🏁 Script executed:

#!/bin/bash
# Search for any wellknown URL construction or usage that includes /realms/
rg -n '\.well-known' packages/ e2e/ --type ts -B5 -A5 | grep -E 'realms|well-known' | head -40

# Look for tests of getRealmFromWellknown to see what URL formats are tested
fd -e test.ts -e spec.ts | xargs rg -l 'getRealmFromWellknown' 2>/dev/null

# Check if there are any unit tests for this specific function
rg -n 'getRealmFromWellknown' e2e/journey-app/services -B10 -A10

Repository: ForgeRock/ping-javascript-sdk

Length of output: 9499


🏁 Script executed:

#!/bin/bash
# Look for test files that might test getRealmFromWellknown
fd -e test.ts -e spec.ts e2e/journey-app | xargs grep -l 'getRealmFromWellknown' 2>/dev/null

# Check if the function is exported and has tests elsewhere
rg -n 'export.*getRealmFromWellknown|test.*getRealmFromWellknown' -A5

# Look at the actual test data for nested realms
rg -n 'realms/root/realms/alpha' -B3 -A3

Repository: ForgeRock/ping-javascript-sdk

Length of output: 7890


🏁 Script executed:

#!/bin/bash
# Test the regex behavior with different URL patterns
python3 << 'EOF'
import re

def getRealmFromWellknown(wellknown: str) -> str:
    from urllib.parse import urlparse
    pathname = urlparse(wellknown).pathname
    match = re.search(r'\/realms\/([^/]+)\/\.well-known\/openid-configuration\/?$', pathname)
    return match.group(1) if match else 'root'

# Test cases
test_urls = [
    'https://test.com/.well-known/openid-configuration',
    'https://test.com/am/oauth2/alpha/.well-known/openid-configuration',
    'https://test.com/am/oauth2/realms/root/.well-known/openid-configuration',
    'https://test.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration',
    'https://test.com/am/oauth2/realms/root/realms/alpha/realms/beta/.well-known/openid-configuration',
]

print("Testing getRealmFromWellknown:")
for url in test_urls:
    result = getRealmFromWellknown(url)
    print(f"  {url}")
    print(f"    → realm: {result}\n")
EOF

Repository: ForgeRock/ping-javascript-sdk

Length of output: 310


🏁 Script executed:

#!/bin/bash
python3 << 'EOF'
import re
from urllib.parse import urlparse

def getRealmFromWellknown(wellknown: str) -> str:
    pathname = urlparse(wellknown).path
    match = re.search(r'\/realms\/([^/]+)\/\.well-known\/openid-configuration\/?$', pathname)
    return match.group(1) if match else 'root'

# Test cases
test_urls = [
    'https://test.com/.well-known/openid-configuration',
    'https://test.com/am/oauth2/alpha/.well-known/openid-configuration',
    'https://test.com/am/oauth2/realms/root/.well-known/openid-configuration',
    'https://test.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration',
    'https://test.com/am/oauth2/realms/root/realms/alpha/realms/beta/.well-known/openid-configuration',
]

print("Testing getRealmFromWellknown:")
for url in test_urls:
    result = getRealmFromWellknown(url)
    print(f"  {url}")
    print(f"    → realm: {result}\n")
EOF

Repository: ForgeRock/ping-javascript-sdk

Length of output: 579


🏁 Script executed:

#!/bin/bash
# Check how the extracted realm is used in the device-client
rg -n 'getRealmUrlPath\(' packages/device-client/src -B3 -A3

# Verify the pattern in the actual device operations
rg -n 'realmPath.*=' packages/device-client/src/lib/services/index.ts | head -20

Repository: ForgeRock/ping-javascript-sdk

Length of output: 956


The regex pattern only captures the final realm segment and fails for nested realms.

For nested realm well-known URLs like https://example.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration, the regex correctly extracts alpha, but the device-client endpoints require the full nested path. The extracted realm is passed directly to API calls like /json/realms/${realm}/users/..., which produces incorrect URLs (/json/realms/alpha/users/... instead of /json/realms/root/realms/alpha/users/...).

The bound device endpoints work around this by hardcoding /json/realms/root/realms/${realm}/, but WebAuthn, OATH, and push device endpoints don't, causing API failures for nested realms. Consider using a utility like getRealmUrlPath() from packages/sdk-utilities to properly construct nested realm paths, or extract the full realm path from the well-known URL instead of just the final segment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/services/delete-webauthn-devices.ts` around lines 43 - 47,
getRealmFromWellknown currently returns only the final realm segment which
breaks nested realm URLs; update it to return the full nested realm path (e.g.
"root/realms/alpha") instead of just "alpha" so API calls like
`/json/realms/${realm}/users/...` produce correct URLs. Replace the regex that
captures a single segment with one that captures the entire path between the
first `/realms/` and `/.well-known` (or simply call and return the utility
getRealmUrlPath() from packages/sdk-utilities), and ensure any consumers of
getRealmFromWellknown keep using the returned string directly for constructing
`/json/realms/${realm}/...` endpoints.

nx-cloud[bot]

This comment was marked as outdated.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
e2e/journey-app/services/delete-webauthn-devices.ts (1)

28-42: ⚠️ Potential issue | 🟡 Minor

Wrap JSON.parse in try/catch to handle corrupted localStorage.

If localStorage contains malformed JSON, JSON.parse throws before the array validation. This could crash the device management flow.

🛡️ Proposed fix
 function getStoredDevices(): Set<string> | null {
   const retrievedDevices = window.localStorage.getItem(WEBAUTHN_DEVICES_KEY);
   if (!retrievedDevices) {
     return null;
   }

-  const parsedDevices = JSON.parse(retrievedDevices) as unknown;
-  if (!Array.isArray(parsedDevices)) {
-    throw new Error('Invalid data in localStorage');
+  let parsedDevices: unknown;
+  try {
+    parsedDevices = JSON.parse(retrievedDevices);
+  } catch {
+    window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY);
+    return null;
   }
+  if (!Array.isArray(parsedDevices)) {
+    window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY);
+    return null;
+  }

   return new Set(

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/services/delete-webauthn-devices.ts` around lines 28 - 42,
getStoredDevices currently calls JSON.parse directly and can throw on malformed
localStorage; wrap the JSON.parse(...) call in a try/catch inside
getStoredDevices to handle corrupted JSON gracefully (catch the error,
optionally remove the bad key via
window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY) or just return null) and
then proceed with the existing Array.isArray(parsedDevices) validation; keep
throwing only for non-array shapes but do not let JSON.parse exceptions bubble
up and crash the flow.
🧹 Nitpick comments (3)
e2e/journey-app/services/delete-webauthn-devices.ts (1)

180-217: Config mutation has side effects.

Line 186 mutates the passed config object (config.redirectUri = getRedirectUri()). Since config comes from the shared serverConfigs object in server-configs.ts, this mutation persists across calls.

For the current E2E test usage this is likely fine, but consider cloning the config or passing redirectUri separately to avoid surprising callers.

♻️ Suggested approach
 async function getWebAuthnDevicesForCurrentUser(config: OidcConfig): Promise<{
   ...
 }> {
-  config.redirectUri = getRedirectUri();
-  const oidcClient = await oidc({ config });
+  const oidcConfig = { ...config, redirectUri: getRedirectUri() };
+  const oidcClient = await oidc({ config: oidcConfig });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/services/delete-webauthn-devices.ts` around lines 180 - 217,
The function getWebAuthnDevicesForCurrentUser currently mutates the passed
config by assigning config.redirectUri = getRedirectUri(), which causes side
effects on the shared serverConfigs; instead, avoid mutating the input by
creating a shallow copy or new config object (e.g., const localConfig = {
...config, redirectUri: getRedirectUri() }) and use that localConfig when
calling oidc({ config }) and any subsequent calls, or pass redirectUri as a
separate argument into oidc/getOidcTokens; update all references in
getWebAuthnDevicesForCurrentUser (oidc, getOidcTokens, etc.) to use the
non-mutated localConfig.
e2e/journey-suites/src/webauthn-devices.test.ts (1)

20-36: Add explicit Page type annotations for helper functions.

The helper functions use implicit any for the page parameter. Adding explicit types improves maintainability and IDE support.

♻️ Suggested fix
+import type { Page, CDPSession } from '@playwright/test';
-import type { CDPSession } from '@playwright/test';

-  async function login(page, journey = 'Login'): Promise<void> {
+  async function login(page: Page, journey = 'Login'): Promise<void> {

-  async function logout(page): Promise<void> {
+  async function logout(page: Page): Promise<void> {

Apply similarly to getDevicesBeforeSession, deleteDevicesInSession, completeAuthenticationJourney, and completeRegistrationJourney.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-suites/src/webauthn-devices.test.ts` around lines 20 - 36, The
helper functions currently accept an untyped page parameter (implicit any); add
an explicit Playwright Page type to each function signature (e.g., change
login(page) to login(page: Page)) and import the Page type from
'@playwright/test' at the top of the file; apply the same change to logout,
getDevicesBeforeSession, deleteDevicesInSession, completeAuthenticationJourney,
and completeRegistrationJourney so IDEs and the typechecker have proper types
for page.
e2e/journey-app/server-configs.ts (1)

15-34: Empty redirectUri relies on runtime mutation.

The redirectUri: '' placeholders satisfy the OidcConfig type but would cause OIDC failures if passed directly to token endpoints. The current code works because delete-webauthn-devices.ts (line 186) mutates config.redirectUri = getRedirectUri() before use.

Consider either:

  1. Setting a valid default (e.g., window.location.origin) here, or
  2. Documenting that callers must set redirectUri before OIDC operations

This is acceptable for a test app, but the implicit contract is fragile.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/server-configs.ts` around lines 15 - 34, serverConfigs
currently sets redirectUri to an empty string on each OidcConfig which relies on
a runtime mutation (config.redirectUri = getRedirectUri()) and is fragile;
update the serverConfigs object so each entry sets a sensible default
redirectUri (e.g., window.location.origin or the existing getRedirectUri()
helper) instead of '' OR add a clear comment/docstring on serverConfigs and
OidcConfig indicating callers must set redirectUri before any OIDC calls; focus
your change on the serverConfigs constant and reference symbols: serverConfigs,
OidcConfig, redirectUri, and getRedirectUri.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@e2e/journey-app/services/delete-webauthn-devices.ts`:
- Around line 63-67: The getRealmPathFromWellknown function currently only
captures the last realm segment; update it to extract the entire path between
the first "/realms/" and "/.well-known/" so nested realms are preserved (e.g.,
"root/realms/alpha"). Replace the existing regex with one that uses a non-greedy
capture from the first "/realms/" to "/.well-known/" (or compute startIndex =
pathname.indexOf('/realms/') and endIndex = pathname.indexOf('/.well-known/')
and substring between them) and return that capture (match[1]) or 'root' if not
found; ensure you update getRealmPathFromWellknown to return the full nested
realm path used by getWebAuthnDevices, updateWebAuthnDeviceName, and
deleteWebAuthnDeviceName.

---

Duplicate comments:
In `@e2e/journey-app/services/delete-webauthn-devices.ts`:
- Around line 28-42: getStoredDevices currently calls JSON.parse directly and
can throw on malformed localStorage; wrap the JSON.parse(...) call in a
try/catch inside getStoredDevices to handle corrupted JSON gracefully (catch the
error, optionally remove the bad key via
window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY) or just return null) and
then proceed with the existing Array.isArray(parsedDevices) validation; keep
throwing only for non-array shapes but do not let JSON.parse exceptions bubble
up and crash the flow.

---

Nitpick comments:
In `@e2e/journey-app/server-configs.ts`:
- Around line 15-34: serverConfigs currently sets redirectUri to an empty string
on each OidcConfig which relies on a runtime mutation (config.redirectUri =
getRedirectUri()) and is fragile; update the serverConfigs object so each entry
sets a sensible default redirectUri (e.g., window.location.origin or the
existing getRedirectUri() helper) instead of '' OR add a clear comment/docstring
on serverConfigs and OidcConfig indicating callers must set redirectUri before
any OIDC calls; focus your change on the serverConfigs constant and reference
symbols: serverConfigs, OidcConfig, redirectUri, and getRedirectUri.

In `@e2e/journey-app/services/delete-webauthn-devices.ts`:
- Around line 180-217: The function getWebAuthnDevicesForCurrentUser currently
mutates the passed config by assigning config.redirectUri = getRedirectUri(),
which causes side effects on the shared serverConfigs; instead, avoid mutating
the input by creating a shallow copy or new config object (e.g., const
localConfig = { ...config, redirectUri: getRedirectUri() }) and use that
localConfig when calling oidc({ config }) and any subsequent calls, or pass
redirectUri as a separate argument into oidc/getOidcTokens; update all
references in getWebAuthnDevicesForCurrentUser (oidc, getOidcTokens, etc.) to
use the non-mutated localConfig.

In `@e2e/journey-suites/src/webauthn-devices.test.ts`:
- Around line 20-36: The helper functions currently accept an untyped page
parameter (implicit any); add an explicit Playwright Page type to each function
signature (e.g., change login(page) to login(page: Page)) and import the Page
type from '@playwright/test' at the top of the file; apply the same change to
logout, getDevicesBeforeSession, deleteDevicesInSession,
completeAuthenticationJourney, and completeRegistrationJourney so IDEs and the
typechecker have proper types for page.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7148b14d-1f6c-49ea-bf54-13553a777275

📥 Commits

Reviewing files that changed from the base of the PR and between 01248e6 and 893e2ae.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • e2e/journey-app/components/webauthn-devices.ts
  • e2e/journey-app/components/webauthn.ts
  • e2e/journey-app/main.ts
  • e2e/journey-app/package.json
  • e2e/journey-app/server-configs.ts
  • e2e/journey-app/services/delete-webauthn-devices.ts
  • e2e/journey-app/style.css
  • e2e/journey-app/tsconfig.app.json
  • e2e/journey-suites/src/recovery-codes.test.ts
  • e2e/journey-suites/src/webauthn-devices.test.ts
  • packages/device-client/package.json
💤 Files with no reviewable changes (1)
  • e2e/journey-suites/src/recovery-codes.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • e2e/journey-app/components/webauthn-devices.ts
  • e2e/journey-app/tsconfig.app.json
  • e2e/journey-app/package.json

Comment on lines +63 to +67
function getRealmPathFromWellknown(wellknown: string): string {
const pathname = new URL(wellknown).pathname;
const match = pathname.match(/\/realms\/([^/]+)\/\.well-known\/openid-configuration\/?$/);
return match?.[1] ?? 'root';
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how device-client constructs API URLs with the realm parameter
rg -n 'realmPath|/realms/' packages/device-client/src --type ts -B2 -A5

# Check if any test configs use nested realms
rg -n 'realms/root/realms' e2e/ --type ts

Repository: ForgeRock/ping-javascript-sdk

Length of output: 24052


🏁 Script executed:

# Find where getRealmPathFromWellknown is called
rg -n 'getRealmPathFromWellknown' e2e/ --type ts -B3 -A3

# Check the full context of delete-webauthn-devices.ts around lines 63-67
cat -n e2e/journey-app/services/delete-webauthn-devices.ts | head -80

Repository: ForgeRock/ping-javascript-sdk

Length of output: 4635


🏁 Script executed:

# Check the entire delete-webauthn-devices.ts to see the actual usage
wc -l e2e/journey-app/services/delete-webauthn-devices.ts
tail -n +190 e2e/journey-app/services/delete-webauthn-devices.ts | head -30

# Search for any comments or tests about nested realms
rg -n 'nested.*realm|root.*realms' . --type ts -i

# Check if webauthn operations actually happen in the e2e tests
rg -n 'deleteWebAuthnDeviceName|deleteWebAuthnDevice' e2e/ --type ts -B2 -A2

Repository: ForgeRock/ping-javascript-sdk

Length of output: 5798


🏁 Script executed:

# Check if webauthn delete operations are actually called/tested in e2e
rg -n 'webAuthn.delete\|deleteWebAuthnDevice' e2e/ --type ts -B3 -A3

# Find the actual call to deleteWebAuthnDevices function
rg -n 'deleteWebAuthnDevices' e2e/ --type ts -B2 -A5

# Check the device-client export to understand full API
cat -n packages/device-client/src/lib/device.store.ts | grep -A30 "webAuthn"

Repository: ForgeRock/ping-javascript-sdk

Length of output: 1608


🏁 Script executed:

# Search more carefully for deleteWebAuthnDevices usage
rg -n 'deleteWebAuthnDevices' . --type ts

# Check the actual test files that import from delete-webauthn-devices
fd -e test.ts -path "*/e2e/*" | head -10

Repository: ForgeRock/ping-javascript-sdk

Length of output: 241


🏁 Script executed:

# Search more carefully for deleteWebAuthnDevices usage
rg -n 'deleteWebAuthnDevices' . --type ts

# Check the actual test files that import from delete-webauthn-devices
rg -l 'delete-webauthn-devices' e2e/ --type ts

Repository: ForgeRock/ping-javascript-sdk

Length of output: 96


🏁 Script executed:

# Check the main.ts to see how deleteWebAuthnDevices is used
rg -n 'deleteWebAuthnDevices\|getRealmPathFromWellknown' e2e/journey-app/main.ts -B5 -A10

# Also check the server-configs to see all the test configurations
cat -n e2e/journey-app/server-configs.ts | head -50

Repository: ForgeRock/ping-javascript-sdk

Length of output: 1384


🏁 Script executed:

# Check how main.ts uses the deleteWebAuthnDevices function
cat -n e2e/journey-app/main.ts | head -100

Repository: ForgeRock/ping-javascript-sdk

Length of output: 4215


🏁 Script executed:

# Search for calls to deleteAllDevices, deleteDevicesInSession, storeDevicesBeforeSession
rg -n 'deleteAllDevices|deleteDevicesInSession|storeDevicesBeforeSession' e2e/journey-app/main.ts -B2 -A5

# Also check the entire delete-webauthn-devices.ts file to see what it exports and the regex behavior
cat -n e2e/journey-app/services/delete-webauthn-devices.ts | sed -n '63,100p'

Repository: ForgeRock/ping-javascript-sdk

Length of output: 2484


🏁 Script executed:

# Check if there's an AM URL utility that handles realm path construction
rg -n 'buildAmUrl|constructUrl' packages/sdk-utilities/src --type ts -B2 -A5

# Look at the am-url utility that was referenced in test results
cat -n packages/sdk-utilities/src/lib/url/am-url.ts | head -100

Repository: ForgeRock/ping-javascript-sdk

Length of output: 149


🏁 Script executed:

# Find the am-url file
fd -name '*am-url*' packages/

# Also check what am-url exports
rg -l 'am-url' packages/sdk-utilities/src --type ts

Repository: ForgeRock/ping-javascript-sdk

Length of output: 501


🏁 Script executed:

# Check the am-url utils implementation
cat -n packages/sdk-utilities/src/lib/url/am-url.utils.ts | head -80

# Also check what the test expects for nested realms
cat -n packages/sdk-utilities/src/lib/url/am-url.test.ts | sed -n '15,60p'

Repository: ForgeRock/ping-javascript-sdk

Length of output: 4506


🏁 Script executed:

# Check if device-client uses the am-url utilities
rg -n 'getRealmUrlPath\|getRealmUrlPath' packages/device-client/src --type ts

# Let's look at the full device services again to see the endpoint patterns
cat -n packages/device-client/src/lib/services/index.ts | sed -n '86,110p'

Repository: ForgeRock/ping-javascript-sdk

Length of output: 1339


🏁 Script executed:

# Check if there's documentation about the device API endpoint requirements
rg -n 'devices/2fa/webauthn\|users.*devices' packages/device-client/src --type ts -B2 -A2 | head -40

# Verify the current test configuration uses the nested realm URL
cat -n e2e/journey-app/server-configs.ts

Repository: ForgeRock/ping-javascript-sdk

Length of output: 1384


🏁 Script executed:

# Check if there's any integration test or documentation about nested realm handling
rg -n 'nested.*realm\|alpha.*realm' packages/device-client --type ts -B2 -A2

# Let's verify what the actual endpoint format should be by checking AM documentation comments
rg -n 'json/realms' packages/device-client/src --type ts -B3 -A1 | head -50

Repository: ForgeRock/ping-javascript-sdk

Length of output: 4636


The regex captures only the final realm segment, which breaks nested realm support for WebAuthn endpoints.

For nested realm URLs like https://example.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration, the regex returns "alpha" instead of "root/realms/alpha". While binding device endpoints work around this with a hardcoded /realms/root/realms/ prefix, WebAuthn endpoints (getWebAuthnDevices, updateWebAuthnDeviceName, deleteWebAuthnDeviceName) construct /json/realms/${realm}/users/... and would generate incorrect URLs like /json/realms/alpha/users/... instead of /json/realms/root/realms/alpha/users/....

Extract the full path between the first /realms/ and /.well-known/ to properly support nested realm hierarchies.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/services/delete-webauthn-devices.ts` around lines 63 - 67,
The getRealmPathFromWellknown function currently only captures the last realm
segment; update it to extract the entire path between the first "/realms/" and
"/.well-known/" so nested realms are preserved (e.g., "root/realms/alpha").
Replace the existing regex with one that uses a non-greedy capture from the
first "/realms/" to "/.well-known/" (or compute startIndex =
pathname.indexOf('/realms/') and endIndex = pathname.indexOf('/.well-known/')
and substring between them) and return that capture (match[1]) or 'root' if not
found; ensure you update getRealmPathFromWellknown to return the full nested
realm path used by getWebAuthnDevices, updateWebAuthnDeviceName, and
deleteWebAuthnDeviceName.

Copy link
Contributor

@nx-cloud nx-cloud bot left a comment

Choose a reason for hiding this comment

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

Important

At least one additional CI pipeline execution has run since the conclusion below was written and it may no longer be applicable.

Nx Cloud is proposing a fix for your failed CI:

We updated the four failing e2e tests to reflect the PR's intentional removal of the Complete heading from the journey app's completion screen. The getByText('Complete') assertion is replaced with getByRole('button', { name: 'Logout' }), which is the stable element present in the new completion screen. In no-session.test.ts, the console-log-based session token assertion is replaced with a DOM check on #sessionToken, since the PR now renders the token directly in the element rather than logging it.

Tip

We verified this fix by re-running @forgerock/journey-suites:e2e-ci--src/registration.test.ts.

Suggested Fix changes
diff --git a/e2e/journey-suites/src/login.test.ts b/e2e/journey-suites/src/login.test.ts
index 796381ea0..403dc6a77 100644
--- a/e2e/journey-suites/src/login.test.ts
+++ b/e2e/journey-suites/src/login.test.ts
@@ -26,7 +26,7 @@ test('Test happy paths on test page', async ({ page }) => {
   await page.getByLabel('Password').fill(password);
   await clickButton('Submit', '/authenticate');
 
-  await expect(page.getByText('Complete')).toBeVisible();
+  await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
 
   // Perform logout
   await clickButton('Logout', '/authenticate');
diff --git a/e2e/journey-suites/src/no-session.test.ts b/e2e/journey-suites/src/no-session.test.ts
index 679e6e9cb..b17c1241a 100644
--- a/e2e/journey-suites/src/no-session.test.ts
+++ b/e2e/journey-suites/src/no-session.test.ts
@@ -28,8 +28,8 @@ test('Test happy paths on test page', async ({ page }) => {
   await page.getByLabel('Password').fill(password);
   await clickButton('Submit', '/authenticate');
 
-  await expect(page.getByText('Complete')).toBeVisible();
+  await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
 
   // Test assertions
-  await expect(messageArray.includes('Session Token: none')).toBe(true);
+  await expect(page.locator('#sessionToken')).toHaveText('none');
 });
diff --git a/e2e/journey-suites/src/qr-code.test.ts b/e2e/journey-suites/src/qr-code.test.ts
index b18ebd2d4..54bd9b2e3 100644
--- a/e2e/journey-suites/src/qr-code.test.ts
+++ b/e2e/journey-suites/src/qr-code.test.ts
@@ -41,7 +41,7 @@ test('Test QR Code journey flow using QRCode module', async ({ page }) => {
 
   await clickButton('Submit', '/authenticate');
 
-  await expect(page.getByText('Complete')).toBeVisible();
+  await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
 
   await clickButton('Logout', '/sessions');
 
diff --git a/e2e/journey-suites/src/registration.test.ts b/e2e/journey-suites/src/registration.test.ts
index 6bf6725ad..c6ffeff6a 100644
--- a/e2e/journey-suites/src/registration.test.ts
+++ b/e2e/journey-suites/src/registration.test.ts
@@ -58,7 +58,7 @@ test('Test happy paths on test page', async ({ page }) => {
 
   await clickButton('Submit', '/authenticate');
 
-  await expect(page.getByText('Complete')).toBeVisible();
+  await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
 
   // Perform logout
   await clickButton('Logout', '/authenticate');

Apply fix via Nx Cloud  Reject fix via Nx Cloud


Or Apply changes locally with:

npx nx-cloud apply-locally wLil-kCVU

Apply fix locally with your editor ↗   View interactive diff ↗



🎓 Learn more about Self-Healing CI on nx.dev

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (3)
e2e/journey-app/services/delete-webauthn-devices.ts (3)

20-42: ⚠️ Potential issue | 🟠 Major

Namespace the stored device baseline by user and realm.

WEBAUTHN_DEVICES_KEY is shared across all logins in the browser. A snapshot captured for one user/tenant is then reused by deleteDevicesInSession(), and deleteAllDevices() clears that same shared key. This makes the "preserve pre-existing devices" behavior unsafe when the app switches users or clientIds in the same browser.

Consider deriving a namespaced key after obtaining the user/realm:

const WEBAUTHN_DEVICES_KEY_PREFIX = 'journey-app:webauthn-device-uuids';

function getStoredDevicesKey(realm: string, userId: string): string {
  return `${WEBAUTHN_DEVICES_KEY_PREFIX}:${realm}:${userId}`;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/services/delete-webauthn-devices.ts` around lines 20 - 42,
The stored devices key is global and must be namespaced per user/realm; change
WEBAUTHN_DEVICES_KEY to a prefix constant (WEBAUTHN_DEVICES_KEY_PREFIX) and add
a helper getStoredDevicesKey(realm: string, userId: string) that returns
`${WEBAUTHN_DEVICES_KEY_PREFIX}:${realm}:${userId}`; update getStoredDevices to
accept realm and userId and use getStoredDevicesKey(...) when calling
localStorage.getItem, and update all callers (deleteDevicesInSession,
deleteAllDevices, and any others) to pass realm and userId so each user's
devices are isolated in localStorage.

63-67: ⚠️ Potential issue | 🔴 Critical

The regex captures only the final realm segment, breaking nested realm support.

For nested realm URLs like https://example.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration, the regex returns "alpha" instead of "root/realms/alpha". The device-client WebAuthn endpoints construct /json/realms/${realm}/users/..., producing incorrect URLs for nested realms.

Extract the full path between the first /realms/ and /.well-known/ to properly support nested realm hierarchies.

🔧 Suggested fix
 function getRealmPathFromWellknown(wellknown: string): string {
   const pathname = new URL(wellknown).pathname;
-  const match = pathname.match(/\/realms\/([^/]+)\/\.well-known\/openid-configuration\/?$/);
-  return match?.[1] ?? 'root';
+  const realmsIndex = pathname.indexOf('/realms/');
+  const wellknownIndex = pathname.indexOf('/.well-known/');
+  if (realmsIndex === -1 || wellknownIndex === -1 || realmsIndex >= wellknownIndex) {
+    return 'root';
+  }
+  // Extract everything between first /realms/ and /.well-known/, e.g., "root/realms/alpha"
+  return pathname.substring(realmsIndex + '/realms/'.length, wellknownIndex);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/services/delete-webauthn-devices.ts` around lines 63 - 67,
The getRealmPathFromWellknown function currently only captures the final realm
segment; update its extraction to grab the entire path between the first
"/realms/" and "/.well-known/openid-configuration" so nested realms are
preserved (e.g., "root/realms/alpha"). Locate getRealmPathFromWellknown and
replace the current regex-based extraction with one that captures everything
after the first "/realms/" up to "/.well-known/" (for example using a regex like
/\/realms\/(.+?)\/\.well-known\/openid-configuration\/?$/ or by finding the
first "/realms/" index and slicing to the start of "/.well-known/"), and keep
the existing fallback to 'root' when no match is found.

34-37: ⚠️ Potential issue | 🟡 Minor

JSON.parse can throw on malformed localStorage data.

If the stored value is corrupted or not valid JSON, JSON.parse will throw an unhandled exception before the array validation check.

🛡️ Proposed fix to handle parse errors
   const retrievedDevices = window.localStorage.getItem(WEBAUTHN_DEVICES_KEY);
   if (!retrievedDevices) {
     return null;
   }

-  const parsedDevices = JSON.parse(retrievedDevices) as unknown;
-  if (!Array.isArray(parsedDevices)) {
-    throw new Error('Invalid data in localStorage');
+  let parsedDevices: unknown;
+  try {
+    parsedDevices = JSON.parse(retrievedDevices);
+  } catch {
+    window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY);
+    return null;
   }
+  if (!Array.isArray(parsedDevices)) {
+    window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY);
+    return null;
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/services/delete-webauthn-devices.ts` around lines 34 - 37,
The code calls JSON.parse(retrievedDevices) which can throw for malformed JSON;
wrap the parse in a try/catch around the parsedDevices creation (using the
retrievedDevices identifier) and on parse error either throw a new Error with a
clear message including the original error/invalid value or handle it safely
(e.g. treat as empty array) before the Array.isArray check; ensure you still
validate parsedDevices with Array.isArray after a successful parse so the
existing error path ('Invalid data in localStorage') remains reachable.
🧹 Nitpick comments (2)
e2e/journey-app/services/delete-webauthn-devices.ts (1)

186-186: Mutating the passed config object may cause side effects.

Line 186 directly mutates config.redirectUri, which could affect callers if the same config instance is reused. Consider creating a shallow copy instead.

🔧 Suggested fix
 async function getWebAuthnDevicesForCurrentUser(config: OidcConfig): Promise<{
   userId: string;
   realm: string;
   webAuthnClient: DeviceClient;
   devices: WebAuthnDevice[];
 }> {
-  config.redirectUri = getRedirectUri();
-  const oidcClient = await oidc({ config });
+  const oidcConfig = { ...config, redirectUri: getRedirectUri() };
+  const oidcClient = await oidc({ config: oidcConfig });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/services/delete-webauthn-devices.ts` at line 186, The code
mutates the incoming config object by assigning config.redirectUri =
getRedirectUri(), which can cause caller-side effects; instead create a shallow
copy and set redirectUri on that copy (e.g., use object spread or Object.assign)
and use the new copy for subsequent operations so the original config remains
unchanged; refer to the local variable config and the function getRedirectUri()
when making this change.
e2e/journey-suites/src/webauthn-devices.test.ts (1)

20-36: Add type annotations to function parameters for type safety.

The helper functions login, logout, and others use untyped page parameters. Adding explicit types improves maintainability and catches errors at compile time.

🔧 Suggested improvement
+import type { Page } from '@playwright/test';
+
-  async function login(page, journey = 'Login'): Promise<void> {
+  async function login(page: Page, journey = 'Login'): Promise<void> {
     const { clickButton, navigate } = asyncEvents(page);
     // ...
   }

-  async function logout(page): Promise<void> {
+  async function logout(page: Page): Promise<void> {
     const { clickButton } = asyncEvents(page);
     // ...
   }

Apply similarly to getDevicesBeforeSession, deleteDevicesInSession, completeAuthenticationJourney, and completeRegistrationJourney.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-suites/src/webauthn-devices.test.ts` around lines 20 - 36, The
functions login and logout (and helpers getDevicesBeforeSession,
deleteDevicesInSession, completeAuthenticationJourney,
completeRegistrationJourney) accept an untyped page parameter—add explicit
Playwright types to improve type safety: import Page from '@playwright/test' (or
use the Project's Page type) and annotate each function signature with page:
Page (keeping existing Promise<void> return types), and update any
usages/parameters accordingly so the compiler can validate page methods like
getByLabel/getByRole and asyncEvents(page).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@e2e/journey-app/services/delete-webauthn-devices.ts`:
- Around line 20-42: The stored devices key is global and must be namespaced per
user/realm; change WEBAUTHN_DEVICES_KEY to a prefix constant
(WEBAUTHN_DEVICES_KEY_PREFIX) and add a helper getStoredDevicesKey(realm:
string, userId: string) that returns
`${WEBAUTHN_DEVICES_KEY_PREFIX}:${realm}:${userId}`; update getStoredDevices to
accept realm and userId and use getStoredDevicesKey(...) when calling
localStorage.getItem, and update all callers (deleteDevicesInSession,
deleteAllDevices, and any others) to pass realm and userId so each user's
devices are isolated in localStorage.
- Around line 63-67: The getRealmPathFromWellknown function currently only
captures the final realm segment; update its extraction to grab the entire path
between the first "/realms/" and "/.well-known/openid-configuration" so nested
realms are preserved (e.g., "root/realms/alpha"). Locate
getRealmPathFromWellknown and replace the current regex-based extraction with
one that captures everything after the first "/realms/" up to "/.well-known/"
(for example using a regex like
/\/realms\/(.+?)\/\.well-known\/openid-configuration\/?$/ or by finding the
first "/realms/" index and slicing to the start of "/.well-known/"), and keep
the existing fallback to 'root' when no match is found.
- Around line 34-37: The code calls JSON.parse(retrievedDevices) which can throw
for malformed JSON; wrap the parse in a try/catch around the parsedDevices
creation (using the retrievedDevices identifier) and on parse error either throw
a new Error with a clear message including the original error/invalid value or
handle it safely (e.g. treat as empty array) before the Array.isArray check;
ensure you still validate parsedDevices with Array.isArray after a successful
parse so the existing error path ('Invalid data in localStorage') remains
reachable.

---

Nitpick comments:
In `@e2e/journey-app/services/delete-webauthn-devices.ts`:
- Line 186: The code mutates the incoming config object by assigning
config.redirectUri = getRedirectUri(), which can cause caller-side effects;
instead create a shallow copy and set redirectUri on that copy (e.g., use object
spread or Object.assign) and use the new copy for subsequent operations so the
original config remains unchanged; refer to the local variable config and the
function getRedirectUri() when making this change.

In `@e2e/journey-suites/src/webauthn-devices.test.ts`:
- Around line 20-36: The functions login and logout (and helpers
getDevicesBeforeSession, deleteDevicesInSession, completeAuthenticationJourney,
completeRegistrationJourney) accept an untyped page parameter—add explicit
Playwright types to improve type safety: import Page from '@playwright/test' (or
use the Project's Page type) and annotate each function signature with page:
Page (keeping existing Promise<void> return types), and update any
usages/parameters accordingly so the compiler can validate page methods like
getByLabel/getByRole and asyncEvents(page).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ed16181c-5bc2-4c1a-8ef2-e973288bfd6e

📥 Commits

Reviewing files that changed from the base of the PR and between 893e2ae and 7870b50.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • e2e/journey-app/components/webauthn-devices.ts
  • e2e/journey-app/components/webauthn.ts
  • e2e/journey-app/main.ts
  • e2e/journey-app/package.json
  • e2e/journey-app/server-configs.ts
  • e2e/journey-app/services/delete-webauthn-devices.ts
  • e2e/journey-app/style.css
  • e2e/journey-app/tsconfig.app.json
  • e2e/journey-suites/src/recovery-codes.test.ts
  • e2e/journey-suites/src/webauthn-devices.test.ts
  • packages/device-client/package.json
💤 Files with no reviewable changes (1)
  • e2e/journey-suites/src/recovery-codes.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • e2e/journey-app/style.css
  • e2e/journey-app/components/webauthn-devices.ts

@vatsalparikh vatsalparikh force-pushed the sdks-4504 branch 2 times, most recently from b624f06 to d25db7c Compare March 18, 2026 13:45
Copy link
Collaborator

@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.

Some comments, I think it maybe helpful to do a live review also to walk through this.

I'd also like to talk through creating a pattern for this. We want to create something that everyone can see and implement.

If we have to test webauth, we have a clear pattern for how to register, authenticate, delete the test data from the tenant encapsulated into our flow.

What do you feel the pattern is at this point?

});

test.afterEach(async ({ page }) => {
await page.unroute('**/*');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do we need this call?

* @param config OIDC configuration used to retrieve the current user's devices.
* @returns A human-readable status message for UI display.
*/
export async function storeDevicesBeforeSession(config: OidcConfig): Promise<string> {
Copy link
Collaborator

@ryanbas21 ryanbas21 Mar 18, 2026

Choose a reason for hiding this comment

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

Why do you feel we should use local storage? My concern is that we are using the app instance to store test data, which isn't harmful but i'm not sure we need it.

Technically, don't we have the uuid's that the test creates, could we send it via a query parameter and then call the removal from the app with the parameter?

I don't think we will ever really need to remove more than one device, so technically my thought process is

  • test go to the login page
  • login with username password
  • register virtual authenticaor
  • virtual authenticator is encapsulated in the test at this point
  • we complete the journey
  • go to page with button, append a query param called something like deviceId
  • we click the button, which the function grabs the device id from the url.
  • we verify the device deletion worked via the device-client
  • we remove the virtual authenticator once successful in the test file.

Do you think the local storage solution is better? I'm open to feedback, my concern is local storage really only survives the test I think, so it feels like some added complexity since each browser window is unique in playwright.

* @returns A human-readable status message for UI display.
* @throws When the delete endpoint returns an error shape.
*/
export async function deleteAllDevices(config: OidcConfig): Promise<string> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

What scenario are you imagining needing to delete multiple devices?

await login(page, 'TEST_WebAuthn-Registration');
}

test('should register multiple devices, authenticate and delete devices', async ({ page }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Similar question to one on another file, what scenario would this test be validating? Do we need to delete multiple devices? I think we just care that if we create a device in the browser and save it to the AM tenant, that we can and do remove it.

await logout(page);
});

test('should delete all registered devices', async ({ page }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

similar to above I think.

let cdp: CDPSession | undefined;
let authenticatorId: string | undefined;

async function login(page: Page, journey = 'Login'): Promise<void> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Not a blocker, but maybe we can consider using test.step to indicate the different steps in this test, rather than hoisting functions?

Perhaps a personal taste, but I don't mind repeativeness / verboseness in tests where we have to do a lot of actions. that way each test is very self contained and articulate what it's doing in the encapsulated block.

This kind of does the same thing, but we lose some of the expressiveness when running the tests.

I'd ask you to maybe look into test.step and then see if you prefer it or not, and let me know which way you feel strongest about. This isn't a blocker or a requirement to the code, I just want to gauge opinion on a different approach.

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.

3 participants