Skip to content

Commit d56432b

Browse files
authored
feat(journey-app): delete webauthn device from AM using device client (#549)
* feat(journey-app): delete webauthn devices using device client * feat(journey-app): reduce implementation complexity * feat(journey-app): delete webauthn device from AM using device client
1 parent e09904f commit d56432b

11 files changed

Lines changed: 486 additions & 32 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
export function renderDeleteDevicesSection(
9+
journeyEl: HTMLDivElement,
10+
deleteWebAuthnDevice: () => Promise<string>,
11+
): void {
12+
const deleteWebAuthnDeviceButton = document.createElement('button');
13+
deleteWebAuthnDeviceButton.type = 'button';
14+
deleteWebAuthnDeviceButton.id = 'deleteWebAuthnDeviceButton';
15+
deleteWebAuthnDeviceButton.innerText = 'Delete Webauthn Device';
16+
17+
const deviceStatus = document.createElement('pre');
18+
deviceStatus.id = 'deviceStatus';
19+
deviceStatus.style.minHeight = '1.5em';
20+
21+
journeyEl.appendChild(deleteWebAuthnDeviceButton);
22+
journeyEl.appendChild(deviceStatus);
23+
24+
deleteWebAuthnDeviceButton.addEventListener('click', async () => {
25+
try {
26+
deviceStatus.innerText = 'Deleting WebAuthn device...';
27+
deviceStatus.innerText = await deleteWebAuthnDevice();
28+
} catch (error) {
29+
const message = error instanceof Error ? error.message : String(error);
30+
deviceStatus.innerText = `Delete failed: ${message}`;
31+
}
32+
});
33+
}
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/main.ts

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
77
import './style.css';
88

99
import { journey } from '@forgerock/journey-client';
10+
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';
1011

1112
import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types';
1213

1314
import { renderCallbacks } from './callback-map.js';
15+
import { renderDeleteDevicesSection } from './components/delete-device.js';
1416
import { renderQRCodeStep } from './components/qr-code.js';
1517
import { renderRecoveryCodesStep } from './components/recovery-codes.js';
18+
import { deleteWebAuthnDevice } from './services/delete-webauthn-device.js';
19+
import { webauthnComponent } from './components/webauthn-step.js';
1620
import { serverConfigs } from './server-configs.js';
1721

1822
const qs = window.location.search;
@@ -70,34 +74,6 @@ if (searchParams.get('middleware') === 'true') {
7074
}
7175
let step = await journeyClient.start({ journey: journeyName });
7276

73-
function renderComplete() {
74-
if (step?.type !== 'LoginSuccess') {
75-
throw new Error('Expected step to be defined and of type LoginSuccess');
76-
}
77-
78-
const session = step.getSessionToken();
79-
80-
console.log(`Session Token: ${session || 'none'}`);
81-
82-
journeyEl.innerHTML = `
83-
<h2 id="completeHeader">Complete</h2>
84-
<span id="sessionLabel">Session:</span>
85-
<pre id="sessionToken" id="sessionToken">${session}</pre>
86-
<button type="button" id="logoutButton">Logout</button>
87-
`;
88-
89-
const loginBtn = document.getElementById('logoutButton') as HTMLButtonElement;
90-
loginBtn.addEventListener('click', async () => {
91-
await journeyClient.terminate();
92-
93-
console.log('Logout successful');
94-
95-
step = await journeyClient.start({ journey: journeyName });
96-
97-
renderForm();
98-
});
99-
}
100-
10177
function renderError() {
10278
if (step?.type !== 'LoginFailure') {
10379
throw new Error('Expected step to be defined and of type LoginFailure');
@@ -117,6 +93,7 @@ if (searchParams.get('middleware') === 'true') {
11793
// Represents the main render function for app
11894
async function renderForm() {
11995
journeyEl.innerHTML = '';
96+
errorEl.textContent = '';
12097

12198
if (step?.type !== 'Step') {
12299
throw new Error('Expected step to be defined and of type Step');
@@ -130,6 +107,23 @@ if (searchParams.get('middleware') === 'true') {
130107

131108
const submitForm = () => formEl.requestSubmit();
132109

110+
// Handle WebAuthn steps first so we can hide the Submit button while processing,
111+
// auto-submit on success, and show an error on failure.
112+
const webAuthnStep = WebAuthn.getWebAuthnStepType(step);
113+
if (
114+
webAuthnStep === WebAuthnStepType.Authentication ||
115+
webAuthnStep === WebAuthnStepType.Registration
116+
) {
117+
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
118+
if (webAuthnSuccess) {
119+
submitForm();
120+
return;
121+
} else {
122+
errorEl.textContent =
123+
'WebAuthn failed or was cancelled. Please try again or use a different method.';
124+
}
125+
}
126+
133127
const stepRendered =
134128
renderQRCodeStep(journeyEl, step) || renderRecoveryCodesStep(journeyEl, step);
135129

@@ -145,6 +139,52 @@ if (searchParams.get('middleware') === 'true') {
145139
journeyEl.appendChild(submitBtn);
146140
}
147141

142+
function renderComplete() {
143+
if (step?.type !== 'LoginSuccess') {
144+
throw new Error('Expected step to be defined and of type LoginSuccess');
145+
}
146+
147+
const session = step.getSessionToken();
148+
149+
console.log(`Session Token: ${session || 'none'}`);
150+
151+
journeyEl.replaceChildren();
152+
153+
const completeHeader = document.createElement('h2');
154+
completeHeader.id = 'completeHeader';
155+
completeHeader.innerText = 'Complete';
156+
journeyEl.appendChild(completeHeader);
157+
158+
renderDeleteDevicesSection(journeyEl, () => deleteWebAuthnDevice(config));
159+
160+
const sessionLabelEl = document.createElement('span');
161+
sessionLabelEl.id = 'sessionLabel';
162+
sessionLabelEl.innerText = 'Session:';
163+
164+
const sessionTokenEl = document.createElement('pre');
165+
sessionTokenEl.id = 'sessionToken';
166+
sessionTokenEl.textContent = session || 'none';
167+
168+
const logoutBtn = document.createElement('button');
169+
logoutBtn.type = 'button';
170+
logoutBtn.id = 'logoutButton';
171+
logoutBtn.innerText = 'Logout';
172+
173+
journeyEl.appendChild(sessionLabelEl);
174+
journeyEl.appendChild(sessionTokenEl);
175+
journeyEl.appendChild(logoutBtn);
176+
177+
logoutBtn.addEventListener('click', async () => {
178+
await journeyClient.terminate();
179+
180+
console.log('Logout successful');
181+
182+
step = await journeyClient.start({ journey: journeyName });
183+
184+
renderForm();
185+
});
186+
}
187+
148188
formEl.addEventListener('submit', async (event) => {
149189
event.preventDefault();
150190

e2e/journey-app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"@forgerock/journey-client": "workspace:*",
1515
"@forgerock/oidc-client": "workspace:*",
1616
"@forgerock/protect": "workspace:*",
17-
"@forgerock/sdk-logger": "workspace:*"
17+
"@forgerock/sdk-logger": "workspace:*",
18+
"@forgerock/device-client": "workspace:*"
1819
},
1920
"nx": {
2021
"tags": ["scope:e2e"]
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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 { deviceClient as createDeviceClient } from '@forgerock/device-client';
9+
import type { WebAuthnDevice } from '@forgerock/device-client/types';
10+
import { JourneyClientConfig } from '@forgerock/journey-client/types';
11+
12+
/**
13+
* Derives the AM base URL from an OIDC well-known URL.
14+
*
15+
* Example: `https://example.com/am/oauth2/alpha/.well-known/openid-configuration`
16+
* becomes `https://example.com/am`.
17+
*
18+
* @param wellknown The OIDC well-known URL.
19+
* @returns The base URL for AM (origin + path prefix before `/oauth2/`).
20+
*/
21+
function getBaseUrlFromWellknown(wellknown: string): string {
22+
const parsed = new URL(wellknown);
23+
const [pathWithoutOauth] = parsed.pathname.split('/oauth2/');
24+
return `${parsed.origin}${pathWithoutOauth}`;
25+
}
26+
27+
/**
28+
* Derives the realm URL path from an OIDC well-known URL.
29+
*
30+
* Example: `/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration`
31+
* becomes `realms/root/realms/alpha`.
32+
*/
33+
function getRealmUrlPathFromWellknown(wellknown: string): string {
34+
const parsed = new URL(wellknown);
35+
const [, afterOauth] = parsed.pathname.split('/oauth2/');
36+
if (!afterOauth) {
37+
return 'realms/root';
38+
}
39+
40+
const suffix = '/.well-known/openid-configuration';
41+
const realmUrlPath = afterOauth.endsWith(suffix)
42+
? afterOauth.slice(0, -suffix.length)
43+
: afterOauth.replace(/\/.well-known\/openid-configuration\/?$/, '');
44+
45+
return realmUrlPath.replace(/^\/+/, '').replace(/\/+$/, '') || 'realms/root';
46+
}
47+
48+
/**
49+
* Retrieves the AM user id from the session cookie using `idFromSession`.
50+
*
51+
* Note: This relies on the browser sending the session cookie; callers should use
52+
* `credentials: 'include'` and ensure AM CORS allows credentialed requests.
53+
*/
54+
async function getUserIdFromSession(baseUrl: string, realmUrlPath: string): Promise<string | null> {
55+
const url = `${baseUrl}/json/${realmUrlPath}/users?_action=idFromSession`;
56+
57+
try {
58+
const response = await fetch(url, {
59+
method: 'POST',
60+
credentials: 'include',
61+
headers: {
62+
'Content-Type': 'application/json',
63+
'Accept-API-Version': 'protocol=2.1,resource=3.0',
64+
},
65+
});
66+
67+
const data = await response.json();
68+
69+
if (!data || typeof data !== 'object') {
70+
return null;
71+
}
72+
73+
const id = (data as Record<string, unknown>).id;
74+
return typeof id === 'string' && id.length > 0 ? id : null;
75+
} catch {
76+
return null;
77+
}
78+
}
79+
80+
/**
81+
* Deletes a single WebAuthn device by matching its `credentialId`.
82+
*
83+
* This queries devices via device-client and deletes the matching device.
84+
*/
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+
91+
if (!credentialId) {
92+
return 'No credential id found. Register a WebAuthn device first.';
93+
}
94+
95+
const wellknown = config.serverConfig.wellknown;
96+
const baseUrl = getBaseUrlFromWellknown(wellknown);
97+
const realmUrlPath = getRealmUrlPathFromWellknown(wellknown);
98+
const userId = await getUserIdFromSession(baseUrl, realmUrlPath);
99+
100+
if (!userId) {
101+
throw new Error('Failed to retrieve user id from session. Are you logged in?');
102+
}
103+
104+
const realm = realmUrlPath.replace(/^realms\//, '') || 'root';
105+
const deviceClient = createDeviceClient({
106+
realmPath: realm,
107+
serverConfig: { baseUrl },
108+
});
109+
110+
const devices = await deviceClient.webAuthn.get({ userId });
111+
if (!Array.isArray(devices)) {
112+
throw new Error(`Failed to retrieve devices: ${String(devices.error)}`);
113+
}
114+
115+
const device = (devices as WebAuthnDevice[]).find((d) => d.credentialId === credentialId);
116+
if (!device) {
117+
return `No WebAuthn device found matching credential id: ${credentialId}`;
118+
}
119+
120+
const response = await deviceClient.webAuthn.delete({
121+
userId,
122+
device,
123+
});
124+
125+
if (response && typeof response === 'object' && 'error' in response) {
126+
const error = (response as { error?: unknown }).error;
127+
throw new Error(`Failed deleting device ${device.uuid}: ${String(error)}`);
128+
}
129+
130+
return `Deleted WebAuthn device ${device.uuid} with credential id ${credentialId} for user ${userId}.`;
131+
}

e2e/journey-app/style.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ pre {
5454
margin: 1em 0;
5555
padding: 1em;
5656
background-color: #1a1a1a;
57+
color: #f3f4f6;
5758
border-radius: 8px;
5859
overflow-x: auto;
5960
}

e2e/journey-app/tsconfig.app.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@
1010
"./helper.ts",
1111
"./server-configs.ts",
1212
"./callback-map.ts",
13-
"components/**/*.ts"
13+
"components/**/*.ts",
14+
"services/**/*.ts"
1415
],
1516
"references": [
1617
{
1718
"path": "../../packages/sdk-effects/logger/tsconfig.lib.json"
1819
},
20+
{
21+
"path": "../../packages/device-client/tsconfig.lib.json"
22+
},
1923
{
2024
"path": "../../packages/oidc-client/tsconfig.lib.json"
2125
},

0 commit comments

Comments
 (0)