diff --git a/e2e/journey-app/components/delete-device.ts b/e2e/journey-app/components/delete-device.ts new file mode 100644 index 0000000000..4a23861ae3 --- /dev/null +++ b/e2e/journey-app/components/delete-device.ts @@ -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, +): 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}`; + } + }); +} diff --git a/e2e/journey-app/components/webauthn-step.ts b/e2e/journey-app/components/webauthn-step.ts new file mode 100644 index 0000000000..ce5035e8a4 --- /dev/null +++ b/e2e/journey-app/components/webauthn-step.ts @@ -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 { + 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(); +} diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index b78f814df3..35aa41262a 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -1,5 +1,5 @@ /* - * 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. @@ -7,12 +7,16 @@ 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; @@ -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 = ` -

Complete

- Session: -
${session}
- - `; - - 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'); @@ -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'); @@ -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); @@ -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(); diff --git a/e2e/journey-app/package.json b/e2e/journey-app/package.json index 40a825314b..7cc9e67485 100644 --- a/e2e/journey-app/package.json +++ b/e2e/journey-app/package.json @@ -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"] diff --git a/e2e/journey-app/services/delete-webauthn-device.ts b/e2e/journey-app/services/delete-webauthn-device.ts new file mode 100644 index 0000000000..e8c5e0827b --- /dev/null +++ b/e2e/journey-app/services/delete-webauthn-device.ts @@ -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 { + 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).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 { + 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}.`; +} diff --git a/e2e/journey-app/style.css b/e2e/journey-app/style.css index db3f236578..e32d10ac16 100644 --- a/e2e/journey-app/style.css +++ b/e2e/journey-app/style.css @@ -54,6 +54,7 @@ pre { margin: 1em 0; padding: 1em; background-color: #1a1a1a; + color: #f3f4f6; border-radius: 8px; overflow-x: auto; } diff --git a/e2e/journey-app/tsconfig.app.json b/e2e/journey-app/tsconfig.app.json index 5d19cb58cd..417f5a64c2 100644 --- a/e2e/journey-app/tsconfig.app.json +++ b/e2e/journey-app/tsconfig.app.json @@ -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" }, diff --git a/e2e/journey-suites/src/WEBAUTHN_TESTING.md b/e2e/journey-suites/src/WEBAUTHN_TESTING.md new file mode 100644 index 0000000000..391c0423eb --- /dev/null +++ b/e2e/journey-suites/src/WEBAUTHN_TESTING.md @@ -0,0 +1,73 @@ +# WebAuthn E2E Testing Pattern + +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 + +## What The Test Does + +1. A virtual authenticator can register a WebAuthn credential during a journey. +2. The registered credential can be used to authenticate in a later journey. +3. The credential id captured from the browser can be passed into the journey app. +4. The journey app can use that credential id to delete the matching registered device. + +## Virtual Authenticator Setup In The Test + +1. Chromium is required for CDP WebAuthn support. +2. The virtual authenticator is configured as a platform authenticator. +3. Resident key and user verification are enabled. +4. Presence and verification are automatically simulated for repeatable automation. + +## Journey Prereqs + +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. + +## Test Flow + +The test is organized with `test.step(...)` so each phase shows what artifact it produces for the next phase. + +### 1. Register a WebAuthn device and capture the device credential id + +The test starts with an empty virtual authenticator and then drives the registration journey: + +1. Navigate to `TEST_WebAuthn-Registration`. +2. Fill username and password. +3. Submit the form. +4. Wait for a successful post-login state. +5. Read credentials from the virtual authenticator through CDP. + +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. + +### 2. Pass the registered credential id into the journey-app integration + +The next step updates the current URL with the `webauthnCredentialId` query parameter and switches the journey to `TEST_WebAuthnAuthentication`. + +This is the integration between the test and the journey app: + +1. The browser creates the credential. +2. The test captures the credential id. +3. The journey app later reads that credential id from the query param when deleting a device. + +### 3. Authenticate with the registered WebAuthn device + +The test logs out, navigates to the authentication journey, and signs in again to prove that the newly registered WebAuthn credential is valid. + +Authentication depends on the registered WebAuthn credential being present in the browser's virtual authenticator. It does not depend on the `webauthnCredentialId` query parameter. + +### 4. Delete the registered device through the journey-app integration + +After authentication succeeds, the test clicks the delete button rendered by the journey app and waits for the status message. + +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. + +## App Integration Points + +1. The app accepts the credential id. +2. The app resolves the signed-in user. +3. The app finds and deletes the matching device using device-client API. +4. What success UI the app renders after deletion. + +## Testing Pattern + +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. +2. Credential ids are passed around with query params, which makes it easy to replicate tests without any dependency on external storage. +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. +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. diff --git a/e2e/journey-suites/src/webauthn-device.test.ts b/e2e/journey-suites/src/webauthn-device.test.ts new file mode 100644 index 0000000000..b079287685 --- /dev/null +++ b/e2e/journey-suites/src/webauthn-device.test.ts @@ -0,0 +1,115 @@ +/* + * 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 { expect, test } from '@playwright/test'; +import type { CDPSession } from '@playwright/test'; +import { asyncEvents } from './utils/async-events.js'; +import { username, password } from './utils/demo-user.js'; + +const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId'; + +test.use({ browserName: 'chromium' }); + +test.describe('WebAuthn register, authenticate, and delete device', () => { + let cdp: CDPSession | undefined; + let authenticatorId: string | undefined; + + test.beforeEach(async ({ context, page }) => { + cdp = await context.newCDPSession(page); + await cdp.send('WebAuthn.enable'); + const response = await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + authenticatorId = response.authenticatorId; + }); + + test.afterEach(async () => { + if (cdp && authenticatorId) { + await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); + await cdp.send('WebAuthn.disable'); + } + }); + + test('should register, authenticate, and delete a device', async ({ page }) => { + if (!cdp || !authenticatorId) { + throw new Error('Virtual authenticator was not initialized'); + } + + const { clickButton, navigate } = asyncEvents(page); + + const registeredCredentialId = + await test.step('Register a WebAuthn device and capture the device credential id', async () => { + // we start with an assertion that no credentials exist in the virtual authenticator + const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + expect(initialCredentials).toHaveLength(0); + + await navigate('/?clientId=tenant&journey=TEST_WebAuthn-Registration'); + await expect(page.getByLabel('User Name')).toBeVisible(); + await page.getByLabel('User Name').fill(username); + await page.getByLabel('Password').fill(password); + await clickButton('Submit', '/authenticate'); + await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); + + // after registration, we can assert that a credential has in fact been generated + // since the length of credentials array increased by 1 + const { credentials: recordedCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + expect(recordedCredentials).toHaveLength(1); + + const virtualCredentialId = recordedCredentials[0]?.credentialId; + expect(virtualCredentialId).toBeTruthy(); + if (!virtualCredentialId) { + throw new Error('Registered WebAuthn credential id was not captured'); + } + + // convert credential id to base64Url + return virtualCredentialId.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); + }); + + const authenticationUrl = + await test.step('Pass the registered credential id into the journey-app integration', async () => { + const url = new URL(page.url()); + url.searchParams.set(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM, registeredCredentialId); + url.searchParams.set('journey', 'TEST_WebAuthnAuthentication'); + + // this assertion might look redundant but it's good to assert that the query param + // was indeed set correctly and that it matches the registered credential id + expect(Array.from(url.searchParams.values())).toContain(registeredCredentialId); + + return url.toString(); + }); + + await test.step('Authenticate with the registered WebAuthn device', async () => { + await clickButton('Logout', '/sessions'); + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); + + await navigate(authenticationUrl); + await expect(page.getByLabel('User Name')).toBeVisible(); + await page.getByLabel('User Name').fill(username); + await clickButton('Submit', '/authenticate'); + await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); + }); + + await test.step('Delete the registered WebAuthn device through the journey-app integration', async () => { + await page.getByRole('button', { name: 'Delete Webauthn Device' }).click(); + + const deviceStatus = page.locator('#deviceStatus'); + await expect(deviceStatus).toContainText('Deleted WebAuthn device'); + await expect(deviceStatus).toContainText(`credential id ${registeredCredentialId} for user`); + }); + }); +}); diff --git a/packages/device-client/package.json b/packages/device-client/package.json index bff45203fa..fd03e2132b 100644 --- a/packages/device-client/package.json +++ b/packages/device-client/package.json @@ -31,6 +31,18 @@ "msw": "catalog:" }, "nx": { - "tags": ["scope:package"] + "tags": ["scope:package"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "packages/device-client/dist", + "main": "packages/device-client/src/index.ts", + "tsConfig": "packages/device-client/tsconfig.lib.json", + "format": ["esm"] + } + } + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f7f3fa707..788a4afc5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: e2e/journey-app: dependencies: + '@forgerock/device-client': + specifier: workspace:* + version: link:../../packages/device-client '@forgerock/journey-client': specifier: workspace:* version: link:../../packages/journey-client