Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions e2e/journey-app/components/delete-device.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/

export function renderDeleteDevicesSection(
journeyEl: HTMLDivElement,
deleteWebAuthnDevice: () => Promise<string>,
): void {
const deleteWebAuthnDeviceButton = document.createElement('button');
deleteWebAuthnDeviceButton.type = 'button';
deleteWebAuthnDeviceButton.id = 'deleteWebAuthnDeviceButton';
deleteWebAuthnDeviceButton.innerText = 'Delete Webauthn Device';

const deviceStatus = document.createElement('pre');
deviceStatus.id = 'deviceStatus';
deviceStatus.style.minHeight = '1.5em';

journeyEl.appendChild(deleteWebAuthnDeviceButton);
journeyEl.appendChild(deviceStatus);

deleteWebAuthnDeviceButton.addEventListener('click', async () => {
try {
deviceStatus.innerText = 'Deleting WebAuthn device...';
deviceStatus.innerText = await deleteWebAuthnDevice();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deviceStatus.innerText = `Delete failed: ${message}`;
}
});
}
41 changes: 41 additions & 0 deletions e2e/journey-app/components/webauthn-step.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/

import { JourneyStep } from '@forgerock/journey-client/types';
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';

export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep, idx: number) {
const container = document.createElement('div');
container.id = `webauthn-container-${idx}`;
const info = document.createElement('p');
info.innerText = 'Please complete the WebAuthn challenge using your authenticator.';
container.appendChild(info);
journeyEl.appendChild(container);

const webAuthnStepType = WebAuthn.getWebAuthnStepType(step);

async function handleWebAuthn(): Promise<boolean> {
try {
if (webAuthnStepType === WebAuthnStepType.Authentication) {
console.log('trying authentication');
await WebAuthn.authenticate(step);
return true;
}

if (webAuthnStepType === WebAuthnStepType.Registration) {
console.log('trying registration');
await WebAuthn.register(step);
return true;
}
return false;
} catch {
return false;
}
}

return handleWebAuthn();
}
98 changes: 69 additions & 29 deletions e2e/journey-app/main.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
/*
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
import './style.css';

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

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

import { renderCallbacks } from './callback-map.js';
import { renderDeleteDevicesSection } from './components/delete-device.js';
import { renderQRCodeStep } from './components/qr-code.js';
import { renderRecoveryCodesStep } from './components/recovery-codes.js';
import { deleteWebAuthnDevice } from './services/delete-webauthn-device.js';
import { webauthnComponent } from './components/webauthn-step.js';
import { serverConfigs } from './server-configs.js';

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

function renderComplete() {
if (step?.type !== 'LoginSuccess') {
throw new Error('Expected step to be defined and of type LoginSuccess');
}

const session = step.getSessionToken();

console.log(`Session Token: ${session || 'none'}`);

journeyEl.innerHTML = `
<h2 id="completeHeader">Complete</h2>
<span id="sessionLabel">Session:</span>
<pre id="sessionToken" id="sessionToken">${session}</pre>
<button type="button" id="logoutButton">Logout</button>
`;

const loginBtn = document.getElementById('logoutButton') as HTMLButtonElement;
loginBtn.addEventListener('click', async () => {
await journeyClient.terminate();

console.log('Logout successful');

step = await journeyClient.start({ journey: journeyName });

renderForm();
});
}

function renderError() {
if (step?.type !== 'LoginFailure') {
throw new Error('Expected step to be defined and of type LoginFailure');
Expand All @@ -117,6 +93,7 @@ if (searchParams.get('middleware') === 'true') {
// Represents the main render function for app
async function renderForm() {
journeyEl.innerHTML = '';
errorEl.textContent = '';

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

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

// Handle WebAuthn steps first so we can hide the Submit button while processing,
// auto-submit on success, and show an error on failure.
const webAuthnStep = WebAuthn.getWebAuthnStepType(step);
if (
webAuthnStep === WebAuthnStepType.Authentication ||
webAuthnStep === WebAuthnStepType.Registration
) {
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
if (webAuthnSuccess) {
submitForm();
return;
} else {
errorEl.textContent =
'WebAuthn failed or was cancelled. Please try again or use a different method.';
}
}

const stepRendered =
renderQRCodeStep(journeyEl, step) || renderRecoveryCodesStep(journeyEl, step);

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

function renderComplete() {
if (step?.type !== 'LoginSuccess') {
throw new Error('Expected step to be defined and of type LoginSuccess');
}

const session = step.getSessionToken();

console.log(`Session Token: ${session || 'none'}`);

journeyEl.replaceChildren();

const completeHeader = document.createElement('h2');
completeHeader.id = 'completeHeader';
completeHeader.innerText = 'Complete';
journeyEl.appendChild(completeHeader);

renderDeleteDevicesSection(journeyEl, () => deleteWebAuthnDevice(config));

const sessionLabelEl = document.createElement('span');
sessionLabelEl.id = 'sessionLabel';
sessionLabelEl.innerText = 'Session:';

const sessionTokenEl = document.createElement('pre');
sessionTokenEl.id = 'sessionToken';
sessionTokenEl.textContent = session || 'none';

const logoutBtn = document.createElement('button');
logoutBtn.type = 'button';
logoutBtn.id = 'logoutButton';
logoutBtn.innerText = 'Logout';

journeyEl.appendChild(sessionLabelEl);
journeyEl.appendChild(sessionTokenEl);
journeyEl.appendChild(logoutBtn);

logoutBtn.addEventListener('click', async () => {
await journeyClient.terminate();

console.log('Logout successful');

step = await journeyClient.start({ journey: journeyName });

renderForm();
});
}

formEl.addEventListener('submit', async (event) => {
event.preventDefault();

Expand Down
3 changes: 2 additions & 1 deletion e2e/journey-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"@forgerock/journey-client": "workspace:*",
"@forgerock/oidc-client": "workspace:*",
"@forgerock/protect": "workspace:*",
"@forgerock/sdk-logger": "workspace:*"
"@forgerock/sdk-logger": "workspace:*",
"@forgerock/device-client": "workspace:*"
},
"nx": {
"tags": ["scope:e2e"]
Expand Down
131 changes: 131 additions & 0 deletions e2e/journey-app/services/delete-webauthn-device.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/

import { deviceClient as createDeviceClient } from '@forgerock/device-client';
import type { WebAuthnDevice } from '@forgerock/device-client/types';
import { JourneyClientConfig } from '@forgerock/journey-client/types';

/**
* Derives the AM base URL from an OIDC well-known URL.
*
* Example: `https://example.com/am/oauth2/alpha/.well-known/openid-configuration`
* becomes `https://example.com/am`.
*
* @param wellknown The OIDC well-known URL.
* @returns The base URL for AM (origin + path prefix before `/oauth2/`).
*/
function getBaseUrlFromWellknown(wellknown: string): string {
const parsed = new URL(wellknown);
const [pathWithoutOauth] = parsed.pathname.split('/oauth2/');
return `${parsed.origin}${pathWithoutOauth}`;
}

/**
* Derives the realm URL path from an OIDC well-known URL.
*
* Example: `/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration`
* becomes `realms/root/realms/alpha`.
*/
function getRealmUrlPathFromWellknown(wellknown: string): string {
const parsed = new URL(wellknown);
const [, afterOauth] = parsed.pathname.split('/oauth2/');
if (!afterOauth) {
return 'realms/root';
}

const suffix = '/.well-known/openid-configuration';
const realmUrlPath = afterOauth.endsWith(suffix)
? afterOauth.slice(0, -suffix.length)
: afterOauth.replace(/\/.well-known\/openid-configuration\/?$/, '');

return realmUrlPath.replace(/^\/+/, '').replace(/\/+$/, '') || 'realms/root';
}

/**
* Retrieves the AM user id from the session cookie using `idFromSession`.
*
* Note: This relies on the browser sending the session cookie; callers should use
* `credentials: 'include'` and ensure AM CORS allows credentialed requests.
*/
async function getUserIdFromSession(baseUrl: string, realmUrlPath: string): Promise<string | null> {
const url = `${baseUrl}/json/${realmUrlPath}/users?_action=idFromSession`;

try {
const response = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept-API-Version': 'protocol=2.1,resource=3.0',
},
});

const data = await response.json();

if (!data || typeof data !== 'object') {
return null;
}

const id = (data as Record<string, unknown>).id;
return typeof id === 'string' && id.length > 0 ? id : null;
} catch {
return null;
}
}

/**
* Deletes a single WebAuthn device by matching its `credentialId`.
*
* This queries devices via device-client and deletes the matching device.
*/
export async function deleteWebAuthnDevice(config: JourneyClientConfig): Promise<string> {
const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId';

const params = new URLSearchParams(window.location.search);
const credentialId = params.get(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM);

if (!credentialId) {
return 'No credential id found. Register a WebAuthn device first.';
}

const wellknown = config.serverConfig.wellknown;
const baseUrl = getBaseUrlFromWellknown(wellknown);
const realmUrlPath = getRealmUrlPathFromWellknown(wellknown);
const userId = await getUserIdFromSession(baseUrl, realmUrlPath);

if (!userId) {
throw new Error('Failed to retrieve user id from session. Are you logged in?');
}

const realm = realmUrlPath.replace(/^realms\//, '') || 'root';
const deviceClient = createDeviceClient({
realmPath: realm,
serverConfig: { baseUrl },
});

const devices = await deviceClient.webAuthn.get({ userId });
if (!Array.isArray(devices)) {
throw new Error(`Failed to retrieve devices: ${String(devices.error)}`);
}

const device = (devices as WebAuthnDevice[]).find((d) => d.credentialId === credentialId);
if (!device) {
return `No WebAuthn device found matching credential id: ${credentialId}`;
}

const response = await deviceClient.webAuthn.delete({
userId,
device,
});

if (response && typeof response === 'object' && 'error' in response) {
const error = (response as { error?: unknown }).error;
throw new Error(`Failed deleting device ${device.uuid}: ${String(error)}`);
}

return `Deleted WebAuthn device ${device.uuid} with credential id ${credentialId} for user ${userId}.`;
}
1 change: 1 addition & 0 deletions e2e/journey-app/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pre {
margin: 1em 0;
padding: 1em;
background-color: #1a1a1a;
color: #f3f4f6;
border-radius: 8px;
overflow-x: auto;
}
Expand Down
6 changes: 5 additions & 1 deletion e2e/journey-app/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@
"./helper.ts",
"./server-configs.ts",
"./callback-map.ts",
"components/**/*.ts"
"components/**/*.ts",
"services/**/*.ts"
],
"references": [
{
"path": "../../packages/sdk-effects/logger/tsconfig.lib.json"
},
{
"path": "../../packages/device-client/tsconfig.lib.json"
},
{
"path": "../../packages/oidc-client/tsconfig.lib.json"
},
Expand Down
Loading
Loading