Skip to content

Commit 98fb76d

Browse files
committed
feat(journey-app): delete webauthn device from AM using device client
1 parent f2e0687 commit 98fb76d

7 files changed

Lines changed: 236 additions & 227 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
8+
import { JourneyStep } from '@forgerock/journey-client/types';
9+
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';
10+
11+
export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep, idx: number) {
12+
const container = document.createElement('div');
13+
container.id = `webauthn-container-${idx}`;
14+
const info = document.createElement('p');
15+
info.innerText = 'Please complete the WebAuthn challenge using your authenticator.';
16+
container.appendChild(info);
17+
journeyEl.appendChild(container);
18+
19+
const webAuthnStepType = WebAuthn.getWebAuthnStepType(step);
20+
21+
async function handleWebAuthn(): Promise<boolean> {
22+
try {
23+
if (webAuthnStepType === WebAuthnStepType.Authentication) {
24+
console.log('trying authentication');
25+
await WebAuthn.authenticate(step);
26+
return true;
27+
}
28+
29+
if (webAuthnStepType === WebAuthnStepType.Registration) {
30+
console.log('trying registration');
31+
await WebAuthn.register(step);
32+
return true;
33+
}
34+
return false;
35+
} catch {
36+
return false;
37+
}
38+
}
39+
40+
return handleWebAuthn();
41+
}

e2e/journey-app/components/webauthn.ts

Lines changed: 0 additions & 86 deletions
This file was deleted.

e2e/journey-app/main.ts

Lines changed: 6 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,13 @@ import { renderCallbacks } from './callback-map.js';
1515
import { renderDeleteDevicesSection } from './components/delete-device.js';
1616
import { renderQRCodeStep } from './components/qr-code.js';
1717
import { renderRecoveryCodesStep } from './components/recovery-codes.js';
18-
import { deleteWebAuthnDevice } from './services/delete-webauthn-devices.js';
19-
import { webauthnComponent } from './components/webauthn.js';
18+
import { deleteWebAuthnDevice } from './services/delete-webauthn-device.js';
19+
import { webauthnComponent } from './components/webauthn-step.js';
2020
import { serverConfigs } from './server-configs.js';
2121

2222
const qs = window.location.search;
2323
const searchParams = new URLSearchParams(qs);
2424

25-
const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId';
26-
2725
const config = serverConfigs[searchParams.get('clientId') || 'basic'];
2826

2927
const journeyName = searchParams.get('journey') ?? 'UsernamePassword';
@@ -65,27 +63,9 @@ if (searchParams.get('middleware') === 'true') {
6563
const formEl = document.getElementById('form') as HTMLFormElement;
6664
const journeyEl = document.getElementById('journey') as HTMLDivElement;
6765

68-
const getCredentialIdFromUrl = (): string | null => {
69-
const params = new URLSearchParams(window.location.search);
70-
const value = params.get(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM);
71-
return value && value.length > 0 ? value : null;
72-
};
73-
74-
const setCredentialIdInUrl = (credentialId: string | null): void => {
75-
const url = new URL(window.location.href);
76-
if (credentialId) {
77-
url.searchParams.set(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM, credentialId);
78-
} else {
79-
url.searchParams.delete(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM);
80-
}
81-
window.history.replaceState({}, document.title, url.toString());
82-
};
83-
84-
let registrationCredentialId: string | null = getCredentialIdFromUrl();
85-
8666
let journeyClient: JourneyClient;
8767
try {
88-
journeyClient = await journey({ config, requestMiddleware });
68+
journeyClient = await journey({ config: config, requestMiddleware });
8969
} catch (error) {
9070
const message = error instanceof Error ? error.message : 'Unknown error';
9171
console.error('Failed to initialize journey client:', message);
@@ -134,13 +114,8 @@ if (searchParams.get('middleware') === 'true') {
134114
webAuthnStep === WebAuthnStepType.Authentication ||
135115
webAuthnStep === WebAuthnStepType.Registration
136116
) {
137-
const webAuthnResponse = await webauthnComponent(journeyEl, step, 0);
138-
if (webAuthnResponse.success) {
139-
if (webAuthnResponse.credentialId) {
140-
registrationCredentialId = webAuthnResponse.credentialId;
141-
setCredentialIdInUrl(registrationCredentialId);
142-
console.log('[WebAuthn] stored registration credentialId:', registrationCredentialId);
143-
}
117+
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
118+
if (webAuthnSuccess) {
144119
submitForm();
145120
return;
146121
} else {
@@ -180,9 +155,7 @@ if (searchParams.get('middleware') === 'true') {
180155
completeHeader.innerText = 'Complete';
181156
journeyEl.appendChild(completeHeader);
182157

183-
renderDeleteDevicesSection(journeyEl, () =>
184-
deleteWebAuthnDevice(config, registrationCredentialId),
185-
);
158+
renderDeleteDevicesSection(journeyEl, () => deleteWebAuthnDevice(config));
186159

187160
const sessionLabelEl = document.createElement('span');
188161
sessionLabelEl.id = 'sessionLabel';

e2e/journey-app/services/delete-webauthn-devices.ts renamed to e2e/journey-app/services/delete-webauthn-device.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,12 @@ async function getUserIdFromSession(baseUrl: string, realmUrlPath: string): Prom
8282
*
8383
* This queries devices via device-client and deletes the matching device.
8484
*/
85-
export async function deleteWebAuthnDevice(
86-
config: JourneyClientConfig,
87-
credentialId: string | null,
88-
): Promise<string> {
85+
export async function deleteWebAuthnDevice(config: JourneyClientConfig): Promise<string> {
86+
const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId';
87+
88+
const params = new URLSearchParams(window.location.search);
89+
const credentialId = params.get(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM);
90+
8991
if (!credentialId) {
9092
return 'No credential id found. Register a WebAuthn device first.';
9193
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# WebAuthn E2E Testing Pattern
2+
3+
This document explains how the WebAuthn device deletion test works in the journey suites, where it integrates with the journey app, and how this pattern can be used by other apps
4+
5+
## What The Test Does
6+
7+
1. A virtual authenticator can register a WebAuthn credential during a journey.
8+
2. The registered credential can be used to authenticate in a later journey.
9+
3. The credential id captured from the browser can be passed into the journey app.
10+
4. The journey app can use that credential id to delete the matching registered device.
11+
12+
## Virtual Authenticator Setup In The Test
13+
14+
1. Chromium is required for CDP WebAuthn support.
15+
2. The virtual authenticator is configured as a platform authenticator.
16+
3. Resident key and user verification are enabled.
17+
4. Presence and verification are automatically simulated for repeatable automation.
18+
19+
## Journey Prereqs
20+
21+
The journeys used here are `TEST_WebAuthn-Registration` and `TEST_WebAuthnAuthentication`. To use the registration journey, a user must already exist. The user logs in, and then regsiteres a platform authenticator. Autthentication journey logs the user in based on their biometrics and does not require a username or password.
22+
23+
## Test Flow
24+
25+
The test is organized with `test.step(...)` so each phase shows what artifact it produces for the next phase.
26+
27+
### 1. Register a WebAuthn device and capture the device credential id
28+
29+
The test starts with an empty virtual authenticator and then drives the registration journey:
30+
31+
1. Navigate to `TEST_WebAuthn-Registration`.
32+
2. Fill username and password.
33+
3. Submit the form.
34+
4. Wait for a successful post-login state.
35+
5. Read credentials from the virtual authenticator through CDP.
36+
37+
The important artifact from this step is the registered credential id. The test converts it to base64url because that is the form used when passing the id through the URL.
38+
39+
### 2. Pass the registered credential id into the journey-app integration
40+
41+
The next step updates the current URL with the `webauthnCredentialId` query parameter and switches the journey to `TEST_WebAuthnAuthentication`.
42+
43+
This is the integration between the test and the journey app:
44+
45+
1. The browser creates the credential.
46+
2. The test captures the credential id.
47+
3. The journey app later reads that credential id from the query param when deleting a device.
48+
49+
### 3. Authenticate with the registered WebAuthn device
50+
51+
The test logs out, navigates to the authentication journey, and signs in again to prove that the newly registered WebAuthn credential is valid.
52+
53+
Authentication depends on the registered WebAuthn credential being present in the browser's virtual authenticator. It does not depend on the `webauthnCredentialId` query parameter.
54+
55+
### 4. Delete the registered device through the journey-app integration
56+
57+
After authentication succeeds, the test clicks the delete button rendered by the journey app and waits for the status message.
58+
59+
The assertion checks that the status message for deleted device contains the same credential id captured from the virtual authenticator. That confirms the deletion flow acted on the same device that the browser originally registered.
60+
61+
## App Integration Points
62+
63+
1. The app accepts the credential id.
64+
2. The app resolves the signed-in user.
65+
3. The app finds and deletes the matching device using device-client API.
66+
4. What success UI the app renders after deletion.
67+
68+
## Testing Pattern
69+
70+
1. The underlying pattern here is credential id based webauthn validation. Virtual authenticator can generate unique credendial ids for each registration, and this helps to easily track the device during deletion.
71+
2. Credential ids are passed around with query params, which makes it easy to replicate tests without any dependency on external storage.
72+
3. The test provides freedom to choose how to resolve the uuid depending on the app, so the app can decide whether to retrieve the uuid through OIDC, session, or another way.
73+
4. The test lets the app decide how to handle app-specific UI, so this pattern is framework agnostic and can be used by any app that supports Playwright, whether it's React, Vue, or Svelte.

0 commit comments

Comments
 (0)