From ed57153159118cfda692832d7db94035bb45a161 Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Sat, 14 Mar 2026 13:04:22 -0700 Subject: [PATCH 1/3] feat(journey-app): delete webauthn devices using device client --- .../components/webauthn-devices.ts | 77 +++++ e2e/journey-app/components/webauthn.ts | 40 +++ e2e/journey-app/main.ts | 120 +++++-- e2e/journey-app/package.json | 3 +- e2e/journey-app/server-configs.ts | 12 +- .../services/delete-webauthn-devices.ts | 306 ++++++++++++++++++ e2e/journey-app/style.css | 1 + e2e/journey-app/tsconfig.app.json | 6 +- .../src/webauthn-devices.test.ts | 149 +++++++++ packages/device-client/package.json | 14 +- pnpm-lock.yaml | 3 + 11 files changed, 695 insertions(+), 36 deletions(-) create mode 100644 e2e/journey-app/components/webauthn-devices.ts create mode 100644 e2e/journey-app/components/webauthn.ts create mode 100644 e2e/journey-app/services/delete-webauthn-devices.ts create mode 100644 e2e/journey-suites/src/webauthn-devices.test.ts diff --git a/e2e/journey-app/components/webauthn-devices.ts b/e2e/journey-app/components/webauthn-devices.ts new file mode 100644 index 0000000000..91029ffb3d --- /dev/null +++ b/e2e/journey-app/components/webauthn-devices.ts @@ -0,0 +1,77 @@ +/* + * 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, + storeDevicesBeforeSession: () => Promise, + deleteDevicesInSession: () => Promise, + deleteAllDevices: () => Promise, +): void { + const getDevicesButton = document.createElement('button'); + getDevicesButton.type = 'button'; + getDevicesButton.id = 'getDevicesButton'; + getDevicesButton.innerText = 'Get Registered Devices'; + + const deleteDevicesButton = document.createElement('button'); + deleteDevicesButton.type = 'button'; + deleteDevicesButton.id = 'deleteDevicesButton'; + deleteDevicesButton.innerText = 'Delete Devices From This Session'; + + const deleteAllDevicesButton = document.createElement('button'); + deleteAllDevicesButton.type = 'button'; + deleteAllDevicesButton.id = 'deleteAllDevicesButton'; + deleteAllDevicesButton.innerText = 'Delete All Registered Devices'; + + const deviceStatus = document.createElement('pre'); + deviceStatus.id = 'deviceStatus'; + deviceStatus.style.minHeight = '1.5em'; + + journeyEl.appendChild(getDevicesButton); + journeyEl.appendChild(deleteDevicesButton); + journeyEl.appendChild(deleteAllDevicesButton); + journeyEl.appendChild(deviceStatus); + + async function setDeviceStatus( + progressStatus: string, + action: () => Promise, + errorPrefix: string, + ): Promise { + try { + deviceStatus.innerText = progressStatus; + + const successMessage = await action(); + deviceStatus.innerText = successMessage; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deviceStatus.innerText = `${errorPrefix}: ${message}`; + } + } + + getDevicesButton.addEventListener('click', async () => { + await setDeviceStatus( + 'Retrieving existing WebAuthn devices...', + storeDevicesBeforeSession, + 'Get existing devices failed', + ); + }); + + deleteDevicesButton.addEventListener('click', async () => { + await setDeviceStatus( + 'Deleting WebAuthn devices in this session...', + deleteDevicesInSession, + 'Delete failed', + ); + }); + + deleteAllDevicesButton.addEventListener('click', async () => { + await setDeviceStatus( + 'Deleting all registered WebAuthn devices...', + deleteAllDevices, + 'Delete failed', + ); + }); +} diff --git a/e2e/journey-app/components/webauthn.ts b/e2e/journey-app/components/webauthn.ts new file mode 100644 index 0000000000..0586947e4a --- /dev/null +++ b/e2e/journey-app/components/webauthn.ts @@ -0,0 +1,40 @@ +/* + * 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; + } else if (WebAuthnStepType.Registration === webAuthnStepType) { + console.log('trying registration'); + await WebAuthn.register(step); + return true; + } else { + return false; + } + } catch { + return false; + } + } + + return handleWebAuthn(); +} diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index b78f814df3..f636d9afda 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,24 @@ 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 type { + JourneyClient, + JourneyClientConfig, + RequestMiddleware, +} from '@forgerock/journey-client/types'; import { renderCallbacks } from './callback-map.js'; +import { renderDeleteDevicesSection } from './components/webauthn-devices.js'; import { renderQRCodeStep } from './components/qr-code.js'; import { renderRecoveryCodesStep } from './components/recovery-codes.js'; +import { + deleteAllDevices, + deleteDevicesInSession, + storeDevicesBeforeSession, +} from './services/delete-webauthn-devices.js'; +import { webauthnComponent } from './components/webauthn.js'; import { serverConfigs } from './server-configs.js'; const qs = window.location.search; @@ -61,7 +73,12 @@ if (searchParams.get('middleware') === 'true') { let journeyClient: JourneyClient; try { - journeyClient = await journey({ config: config, requestMiddleware }); + const journeyConfig: JourneyClientConfig = { + serverConfig: { + wellknown: config.serverConfig.wellknown, + }, + }; + journeyClient = await journey({ config: journeyConfig, requestMiddleware }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Failed to initialize journey client:', message); @@ -70,34 +87,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 +106,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 +120,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); + const isWebAuthn = + webAuthnStep === WebAuthnStepType.Authentication || + webAuthnStep === WebAuthnStepType.Registration; + if (isWebAuthn) { + const webauthnSucceeded = await webauthnComponent(journeyEl, step, 0); + if (webauthnSucceeded) { + 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 +152,57 @@ 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, + () => storeDevicesBeforeSession(config), + () => deleteDevicesInSession(config), + () => deleteAllDevices(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/server-configs.ts b/e2e/journey-app/server-configs.ts index 1e388f32be..3024d66280 100644 --- a/e2e/journey-app/server-configs.ts +++ b/e2e/journey-app/server-configs.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import type { JourneyClientConfig } from '@forgerock/journey-client/types'; +import type { OidcConfig } from '@forgerock/oidc-client/types'; /** * Server configurations for E2E tests. @@ -12,16 +12,24 @@ import type { JourneyClientConfig } from '@forgerock/journey-client/types'; * All configuration (baseUrl, authenticate/sessions paths) is automatically * derived from the well-known response via `convertWellknown()`. */ -export const serverConfigs: Record = { +export const serverConfigs: Record = { basic: { + clientId: 'WebOAuthClient', + redirectUri: '', + scope: 'openid profile email', serverConfig: { wellknown: 'http://localhost:9443/am/oauth2/realms/root/.well-known/openid-configuration', }, + responseType: 'code', }, tenant: { + clientId: 'WebOAuthClient', + redirectUri: '', + scope: 'openid profile email', serverConfig: { wellknown: 'https://openam-sdks.forgeblocks.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration', }, + responseType: 'code', }, }; diff --git a/e2e/journey-app/services/delete-webauthn-devices.ts b/e2e/journey-app/services/delete-webauthn-devices.ts new file mode 100644 index 0000000000..8cd0aa1865 --- /dev/null +++ b/e2e/journey-app/services/delete-webauthn-devices.ts @@ -0,0 +1,306 @@ +/* + * 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 } from '@forgerock/device-client'; +import { oidc } from '@forgerock/oidc-client'; + +import type { WebAuthnDevice, DeviceClient } from '@forgerock/device-client/types'; +import type { + GenericError, + OauthTokens, + OidcClient, + OidcConfig, + UserInfoResponse, +} from '@forgerock/oidc-client/types'; + +const WEBAUTHN_DEVICES_KEY = 'journey-app:webauthn-device-uuids'; + +/** + * Reads the stored WebAuthn device UUIDs from `localStorage`. + * + * @returns A `Set` of UUID strings when present; otherwise `null`. + * @throws When the stored value exists but is not a JSON array. + */ +function getStoredDevices(): Set | 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'); + } + + return new Set( + parsedDevices.filter((value): value is string => typeof value === 'string' && value.length > 0), + ); +} + +/** + * Creates a redirect URI for OIDC based on the current page origin and path. + * + * Note: This intentionally excludes query parameters so temporary values like + * `code` and `state` can be removed cleanly after token exchange. + * + * @returns The redirect URI string (origin + pathname). + */ +function getRedirectUri() { + const currentUrl = new URL(window.location.href); + return `${currentUrl.origin}${currentUrl.pathname}`; +} + +/** + * Derive the realm value used by device-client endpoints from a well-known URL. + * + * @param wellknown The OIDC well-known URL. + * @returns The derived realm path to use with device-client (defaults to `root`). + */ +function getRealmPathFromWellknown(wellknown: string): string { + const pathname = new URL(wellknown).pathname; + const match = pathname.match(/\/realms\/([^/]+)\/\.well-known\/openid-configuration\/?$/); + return match?.[1] ?? 'root'; +} + +/** + * 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}`; +} + +/** + * Type guard to detect error-shaped responses returned by SDK helpers. + * + * @param value The unknown value to inspect. + * @returns `true` when the object contains an `error` property. + */ +function hasError(value: unknown): value is { error: string } { + return Boolean(value && typeof value === 'object' && 'error' in value); +} + +/** + * Retrieves usable OIDC tokens for the current browser session. + * + * This will: + * - exchange an authorization code (`code` + `state`) when present in the URL + * - otherwise retrieve/renew tokens via the OIDC client + * - redirect the browser when the token API returns a `redirectUrl` + * + * @param oidcClient An initialized OIDC client instance. + * @param config OIDC configuration used to initiate the authorization flow. + * @returns Tokens on success; otherwise an `{ error }` object. + */ +async function getOidcTokens( + oidcClient: OidcClient, + config: OidcConfig, +): Promise { + if (hasError(oidcClient)) { + return { error: oidcClient.error }; + } + + const searchParams = new URLSearchParams(window.location.search); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + if (code && state) { + const exchanged = await oidcClient.token.exchange(code, state); + if (hasError(exchanged)) { + return { error: exchanged.error }; + } + + const cleanedUrl = new URL(window.location.href); + cleanedUrl.searchParams.delete('code'); + cleanedUrl.searchParams.delete('state'); + window.history.replaceState({}, document.title, cleanedUrl.toString()); + + return exchanged; + } + + const tokens = await oidcClient.token.get({ + backgroundRenew: true, + authorizeOptions: { + clientId: config.clientId, + redirectUri: getRedirectUri(), + scope: config.scope, + responseType: config.responseType ?? 'code', + responseMode: 'query', + }, + }); + + if (hasError(tokens)) { + if ('redirectUrl' in tokens && typeof tokens.redirectUrl === 'string') { + window.location.assign(tokens.redirectUrl); + } + return { error: tokens.error }; + } + + return tokens; +} + +/** + * Retrieves the UUID (`sub`) for the currently authenticated user. + * + * @param oidcClient An initialized OIDC client instance. + * @returns The user UUID string on success; otherwise an `{ error }` object. + */ +async function getCurrentUserUuid(oidcClient: OidcClient): Promise { + if (hasError(oidcClient)) { + return { error: oidcClient.error }; + } + + const userInfo = (await oidcClient.user.info()) as GenericError | UserInfoResponse; + + if (hasError(userInfo)) { + return { error: userInfo.error }; + } + + return userInfo.sub; +} + +/** + * Fetches the current user's WebAuthn devices using the device-client SDK. + * + * @param config OIDC configuration used to initialize the OIDC client. + * @returns The user UUID, resolved realm, a configured device-client instance, and the devices list. + * @throws When token retrieval fails or device retrieval returns an error shape. + */ +async function getWebAuthnDevicesForCurrentUser(config: OidcConfig): Promise<{ + userId: string; + realm: string; + webAuthnClient: DeviceClient; + devices: WebAuthnDevice[]; +}> { + const oidcConfig = { ...config, redirectUri: getRedirectUri() }; + const oidcClient = await oidc({ config: oidcConfig }); + const tokens = await getOidcTokens(oidcClient, config); + + if (hasError(tokens)) { + throw new Error(`OIDC token retrieval failed: ${String(tokens.error)}`); + } + + const userId = await getCurrentUserUuid(oidcClient); + if (typeof userId !== 'string') { + throw new Error(`Failed to retrieve user UUID: ${String(userId.error)}`); + } + + const wellknown = config.serverConfig.wellknown; + const realm = getRealmPathFromWellknown(wellknown); + const baseUrl = getBaseUrlFromWellknown(wellknown); + const webAuthnClient = deviceClient({ + realmPath: realm, + serverConfig: { + baseUrl, + }, + }); + const devices = await webAuthnClient.webAuthn.get({ + userId, + }); + + if (!Array.isArray(devices)) { + throw new Error(`Failed to retrieve devices: ${String(devices.error)}`); + } + + return { userId, realm, webAuthnClient, devices: devices as WebAuthnDevice[] }; +} + +/** + * Stores the current set of registered WebAuthn device UUIDs in `localStorage`. + * + * If devices have already been stored, this is a no-op and returns the existing count. + * + * @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 { + const storedDevices = getStoredDevices(); + if (storedDevices) { + return `Devices before session: ${storedDevices.size} registered WebAuthn device(s).`; + } + + const { devices } = await getWebAuthnDevicesForCurrentUser(config); + const uuids = devices.map((device) => device.uuid).filter((uuid) => Boolean(uuid)); + window.localStorage.setItem(WEBAUTHN_DEVICES_KEY, JSON.stringify(uuids)); + return `Devices before session: ${uuids.length} registered WebAuthn device(s).`; +} + +/** + * Deletes only the WebAuthn devices that were registered during the current session. + * + * This compares the current device list against the snapshot stored by + * `storeDevicesBeforeSession` and deletes any newly added devices. + * + * @param config OIDC configuration used to retrieve and delete WebAuthn devices. + * @returns A human-readable status message for UI display. + * @throws When the delete endpoint returns an error shape. + */ +export async function deleteDevicesInSession(config: OidcConfig): Promise { + const storedDevices = getStoredDevices(); + if (!storedDevices) { + return 'No devices found. Click Get Registered Devices first.'; + } + + const { userId, webAuthnClient, devices } = await getWebAuthnDevicesForCurrentUser(config); + const devicesToDelete = devices.filter((device) => !storedDevices.has(device.uuid)); + + if (devicesToDelete.length === 0) { + return `No devices found in this session for user ${userId}.`; + } + + for (const device of devicesToDelete) { + const response = await webAuthnClient.webAuthn.delete({ + userId, + device, + }); + + if (response && hasError(response)) { + throw new Error(`Failed deleting device ${device.uuid}: ${String(response.error)}`); + } + } + + return `Deleted ${devicesToDelete.length} WebAuthn device(s) for user ${userId}.`; +} + +/** + * Deletes all registered WebAuthn devices for the current user. + * + * This always clears the stored snapshot in `localStorage` once deletions complete. + * + * @param config OIDC configuration used to retrieve and delete WebAuthn devices. + * @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 { + const { userId, webAuthnClient, devices } = await getWebAuthnDevicesForCurrentUser(config); + + if (devices.length === 0) { + window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY); + return `No registered WebAuthn devices found for user ${userId}.`; + } + + for (const device of devices as WebAuthnDevice[]) { + const response = await webAuthnClient.webAuthn.delete({ + userId, + device, + }); + + if (response && hasError(response)) { + throw new Error(`Failed deleting device ${device.uuid}: ${String(response.error)}`); + } + } + + window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY); + return `Deleted ${devices.length} registered WebAuthn device(s) 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-devices.test.ts b/e2e/journey-suites/src/webauthn-devices.test.ts new file mode 100644 index 0000000000..6d537fcaf1 --- /dev/null +++ b/e2e/journey-suites/src/webauthn-devices.test.ts @@ -0,0 +1,149 @@ +/* + * 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, Page } from '@playwright/test'; +import type { CDPSession } from '@playwright/test'; + +import { asyncEvents } from './utils/async-events.js'; +import { username, password } from './utils/demo-user.js'; + +test.use({ browserName: 'chromium' }); + +test.describe('WebAuthn registration delete devices', () => { + let cdp: CDPSession | undefined; + let authenticatorId: string | undefined; + + async function login(page: Page, journey = 'Login'): Promise { + const { clickButton, navigate } = asyncEvents(page); + + await navigate(`/?clientId=tenant&journey=${journey}`); + 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(); + } + + async function logout(page: Page): Promise { + const { clickButton } = asyncEvents(page); + await clickButton('Logout', '/sessions'); + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); + } + + async function getDevicesBeforeSession(page: Page): Promise { + const getButton = page.getByRole('button', { name: 'Get Registered Devices' }); + await expect(getButton).toBeVisible(); + await getButton.click(); + await expect(page.locator('#deviceStatus')).toContainText('Devices before session:'); + } + + async function deleteDevicesInSession(page: Page): Promise { + await login(page); + + const deleteButton = page.getByRole('button', { name: 'Delete Devices From This Session' }); + await expect(deleteButton).toBeVisible(); + await deleteButton.click(); + + await expect(page.locator('#deviceStatus')).toContainText( + /Deleted|No devices found in this session|No devices found/, + ); + + await logout(page); + } + + async function completeAuthenticationJourney(page: Page): Promise { + const { clickButton, navigate } = asyncEvents(page); + + await navigate('/?clientId=tenant&journey=TEST_WebAuthnAuthentication_UsernamePassword'); + await expect(page.getByLabel('User Name')).toBeVisible(); + await page.getByLabel('User Name').fill(username); + await clickButton('Submit', '/authenticate'); + + await expect(page.getByLabel('Password')).toBeVisible(); + await page.getByLabel('Password').fill(password); + await clickButton('Submit', '/authenticate'); + + await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); + } + + 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; + + await login(page); + await getDevicesBeforeSession(page); + await logout(page); + }); + + 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 }); + } + + await cdp.send('WebAuthn.disable'); + }); + + async function completeRegistrationJourney(page): Promise { + await login(page, 'TEST_WebAuthn-Registration'); + } + + test('should register multiple devices, authenticate and delete devices', async ({ page }) => { + await completeRegistrationJourney(page); + await logout(page); + + await completeRegistrationJourney(page); + await logout(page); + + await completeAuthenticationJourney(page); + + const deleteButton = page.getByRole('button', { name: 'Delete Devices From This Session' }); + await expect(deleteButton).toBeVisible(); + await deleteButton.click(); + await expect(page.locator('#deviceStatus')).toContainText( + 'Deleted 2 WebAuthn device(s) for user', + ); + + await logout(page); + }); + + test('should delete all registered devices', async ({ page }) => { + await completeRegistrationJourney(page); + + const deleteAllButton = page.getByRole('button', { name: 'Delete All Registered Devices' }); + await expect(deleteAllButton).toBeVisible(); + await deleteAllButton.click(); + + await expect(page.locator('#deviceStatus')).toContainText('Deleted'); + await expect(page.locator('#deviceStatus')).toContainText('registered WebAuthn device(s)'); + }); +}); 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 From f2e068759585709459e4f5d5453db05ce089ee9b Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Fri, 20 Mar 2026 07:53:36 -0700 Subject: [PATCH 2/3] feat(journey-app): reduce implementation complexity --- e2e/journey-app/components/delete-device.ts | 33 ++ .../components/webauthn-devices.ts | 77 ----- e2e/journey-app/components/webauthn.ts | 60 +++- e2e/journey-app/main.ts | 63 ++-- e2e/journey-app/server-configs.ts | 12 +- .../services/delete-webauthn-devices.ts | 309 ++++-------------- .../src/webauthn-devices.test.ts | 161 ++++----- 7 files changed, 249 insertions(+), 466 deletions(-) create mode 100644 e2e/journey-app/components/delete-device.ts delete mode 100644 e2e/journey-app/components/webauthn-devices.ts 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-devices.ts b/e2e/journey-app/components/webauthn-devices.ts deleted file mode 100644 index 91029ffb3d..0000000000 --- a/e2e/journey-app/components/webauthn-devices.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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, - storeDevicesBeforeSession: () => Promise, - deleteDevicesInSession: () => Promise, - deleteAllDevices: () => Promise, -): void { - const getDevicesButton = document.createElement('button'); - getDevicesButton.type = 'button'; - getDevicesButton.id = 'getDevicesButton'; - getDevicesButton.innerText = 'Get Registered Devices'; - - const deleteDevicesButton = document.createElement('button'); - deleteDevicesButton.type = 'button'; - deleteDevicesButton.id = 'deleteDevicesButton'; - deleteDevicesButton.innerText = 'Delete Devices From This Session'; - - const deleteAllDevicesButton = document.createElement('button'); - deleteAllDevicesButton.type = 'button'; - deleteAllDevicesButton.id = 'deleteAllDevicesButton'; - deleteAllDevicesButton.innerText = 'Delete All Registered Devices'; - - const deviceStatus = document.createElement('pre'); - deviceStatus.id = 'deviceStatus'; - deviceStatus.style.minHeight = '1.5em'; - - journeyEl.appendChild(getDevicesButton); - journeyEl.appendChild(deleteDevicesButton); - journeyEl.appendChild(deleteAllDevicesButton); - journeyEl.appendChild(deviceStatus); - - async function setDeviceStatus( - progressStatus: string, - action: () => Promise, - errorPrefix: string, - ): Promise { - try { - deviceStatus.innerText = progressStatus; - - const successMessage = await action(); - deviceStatus.innerText = successMessage; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - deviceStatus.innerText = `${errorPrefix}: ${message}`; - } - } - - getDevicesButton.addEventListener('click', async () => { - await setDeviceStatus( - 'Retrieving existing WebAuthn devices...', - storeDevicesBeforeSession, - 'Get existing devices failed', - ); - }); - - deleteDevicesButton.addEventListener('click', async () => { - await setDeviceStatus( - 'Deleting WebAuthn devices in this session...', - deleteDevicesInSession, - 'Delete failed', - ); - }); - - deleteAllDevicesButton.addEventListener('click', async () => { - await setDeviceStatus( - 'Deleting all registered WebAuthn devices...', - deleteAllDevices, - 'Delete failed', - ); - }); -} diff --git a/e2e/journey-app/components/webauthn.ts b/e2e/journey-app/components/webauthn.ts index 0586947e4a..3376ba67cc 100644 --- a/e2e/journey-app/components/webauthn.ts +++ b/e2e/journey-app/components/webauthn.ts @@ -8,6 +8,45 @@ import { JourneyStep } from '@forgerock/journey-client/types'; import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'; +export function extractRegistrationCredentialId(outcomeValue: string): string | null { + // This app consumes the hidden `webAuthnOutcome` callback populated by journey-client. + // See packages/journey-client/src/lib/webauthn/webauthn.ts: + // - register(): JSON-wrapped outcome when `supportsJsonResponse` is enabled + // - register(): plain legacy outcome string otherwise + let legacyData: string | null = outcomeValue; + + // Newer journey-client responses may wrap the legacy string as: + // { authenticatorAttachment, legacyData } + // We only need the legacy payload here; the attachment is not used by journey-app. + try { + const parsed = JSON.parse(outcomeValue) as unknown; + if (parsed && typeof parsed === 'object' && 'legacyData' in parsed) { + const candidate = (parsed as Record).legacyData; + legacyData = typeof candidate === 'string' ? candidate : null; + } + } catch { + // Not JSON; fall back to plain legacy outcome string. + } + + if (!legacyData) { + return null; + } + + // journey-client registration outcome format is: + // clientDataJSON::attestationObject::credentialId[::deviceName] + // The app only needs the third segment so delete-webauthn-devices can target + // the same registered credential later. + // See e2e/journey-app/main.ts and e2e/journey-app/services/delete-webauthn-devices.ts. + const parts = legacyData.split('::'); + const credentialId = parts[2]; + return credentialId && credentialId.length > 0 ? credentialId : null; +} + +export type WebAuthnHandleResult = { + success: boolean; + credentialId: string | null; +}; + export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep, idx: number) { const container = document.createElement('div'); container.id = `webauthn-container-${idx}`; @@ -18,21 +57,28 @@ export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep, const webAuthnStepType = WebAuthn.getWebAuthnStepType(step); - async function handleWebAuthn(): Promise { + async function handleWebAuthn(): Promise { try { if (webAuthnStepType === WebAuthnStepType.Authentication) { console.log('trying authentication'); await WebAuthn.authenticate(step); - return true; - } else if (WebAuthnStepType.Registration === webAuthnStepType) { + return { success: true, credentialId: null }; + } + + if (webAuthnStepType === WebAuthnStepType.Registration) { console.log('trying registration'); await WebAuthn.register(step); - return true; - } else { - return false; + + const { hiddenCallback } = WebAuthn.getCallbacks(step); + const rawOutcome = String(hiddenCallback?.getInputValue() ?? ''); + const credentialId = extractRegistrationCredentialId(rawOutcome); + console.log('[WebAuthn] registration credentialId:', credentialId); + return { success: true, credentialId }; } + + return { success: false, credentialId: null }; } catch { - return false; + return { success: false, credentialId: null }; } } diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index f636d9afda..820b904e5e 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -9,27 +9,21 @@ import './style.css'; import { journey } from '@forgerock/journey-client'; import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'; -import type { - JourneyClient, - JourneyClientConfig, - RequestMiddleware, -} from '@forgerock/journey-client/types'; +import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types'; import { renderCallbacks } from './callback-map.js'; -import { renderDeleteDevicesSection } from './components/webauthn-devices.js'; +import { renderDeleteDevicesSection } from './components/delete-device.js'; import { renderQRCodeStep } from './components/qr-code.js'; import { renderRecoveryCodesStep } from './components/recovery-codes.js'; -import { - deleteAllDevices, - deleteDevicesInSession, - storeDevicesBeforeSession, -} from './services/delete-webauthn-devices.js'; +import { deleteWebAuthnDevice } from './services/delete-webauthn-devices.js'; import { webauthnComponent } from './components/webauthn.js'; import { serverConfigs } from './server-configs.js'; const qs = window.location.search; const searchParams = new URLSearchParams(qs); +const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId'; + const config = serverConfigs[searchParams.get('clientId') || 'basic']; const journeyName = searchParams.get('journey') ?? 'UsernamePassword'; @@ -71,14 +65,27 @@ if (searchParams.get('middleware') === 'true') { const formEl = document.getElementById('form') as HTMLFormElement; const journeyEl = document.getElementById('journey') as HTMLDivElement; + const getCredentialIdFromUrl = (): string | null => { + const params = new URLSearchParams(window.location.search); + const value = params.get(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM); + return value && value.length > 0 ? value : null; + }; + + const setCredentialIdInUrl = (credentialId: string | null): void => { + const url = new URL(window.location.href); + if (credentialId) { + url.searchParams.set(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM, credentialId); + } else { + url.searchParams.delete(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM); + } + window.history.replaceState({}, document.title, url.toString()); + }; + + let registrationCredentialId: string | null = getCredentialIdFromUrl(); + let journeyClient: JourneyClient; try { - const journeyConfig: JourneyClientConfig = { - serverConfig: { - wellknown: config.serverConfig.wellknown, - }, - }; - journeyClient = await journey({ config: journeyConfig, requestMiddleware }); + journeyClient = await journey({ config, requestMiddleware }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Failed to initialize journey client:', message); @@ -123,12 +130,17 @@ if (searchParams.get('middleware') === 'true') { // 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); - const isWebAuthn = + if ( webAuthnStep === WebAuthnStepType.Authentication || - webAuthnStep === WebAuthnStepType.Registration; - if (isWebAuthn) { - const webauthnSucceeded = await webauthnComponent(journeyEl, step, 0); - if (webauthnSucceeded) { + webAuthnStep === WebAuthnStepType.Registration + ) { + const webAuthnResponse = await webauthnComponent(journeyEl, step, 0); + if (webAuthnResponse.success) { + if (webAuthnResponse.credentialId) { + registrationCredentialId = webAuthnResponse.credentialId; + setCredentialIdInUrl(registrationCredentialId); + console.log('[WebAuthn] stored registration credentialId:', registrationCredentialId); + } submitForm(); return; } else { @@ -168,11 +180,8 @@ if (searchParams.get('middleware') === 'true') { completeHeader.innerText = 'Complete'; journeyEl.appendChild(completeHeader); - renderDeleteDevicesSection( - journeyEl, - () => storeDevicesBeforeSession(config), - () => deleteDevicesInSession(config), - () => deleteAllDevices(config), + renderDeleteDevicesSection(journeyEl, () => + deleteWebAuthnDevice(config, registrationCredentialId), ); const sessionLabelEl = document.createElement('span'); diff --git a/e2e/journey-app/server-configs.ts b/e2e/journey-app/server-configs.ts index 3024d66280..1e388f32be 100644 --- a/e2e/journey-app/server-configs.ts +++ b/e2e/journey-app/server-configs.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import type { OidcConfig } from '@forgerock/oidc-client/types'; +import type { JourneyClientConfig } from '@forgerock/journey-client/types'; /** * Server configurations for E2E tests. @@ -12,24 +12,16 @@ import type { OidcConfig } from '@forgerock/oidc-client/types'; * All configuration (baseUrl, authenticate/sessions paths) is automatically * derived from the well-known response via `convertWellknown()`. */ -export const serverConfigs: Record = { +export const serverConfigs: Record = { basic: { - clientId: 'WebOAuthClient', - redirectUri: '', - scope: 'openid profile email', serverConfig: { wellknown: 'http://localhost:9443/am/oauth2/realms/root/.well-known/openid-configuration', }, - responseType: 'code', }, tenant: { - clientId: 'WebOAuthClient', - redirectUri: '', - scope: 'openid profile email', serverConfig: { wellknown: 'https://openam-sdks.forgeblocks.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration', }, - responseType: 'code', }, }; diff --git a/e2e/journey-app/services/delete-webauthn-devices.ts b/e2e/journey-app/services/delete-webauthn-devices.ts index 8cd0aa1865..a35a855217 100644 --- a/e2e/journey-app/services/delete-webauthn-devices.ts +++ b/e2e/journey-app/services/delete-webauthn-devices.ts @@ -6,65 +6,8 @@ */ import { deviceClient } from '@forgerock/device-client'; -import { oidc } from '@forgerock/oidc-client'; - -import type { WebAuthnDevice, DeviceClient } from '@forgerock/device-client/types'; -import type { - GenericError, - OauthTokens, - OidcClient, - OidcConfig, - UserInfoResponse, -} from '@forgerock/oidc-client/types'; - -const WEBAUTHN_DEVICES_KEY = 'journey-app:webauthn-device-uuids'; - -/** - * Reads the stored WebAuthn device UUIDs from `localStorage`. - * - * @returns A `Set` of UUID strings when present; otherwise `null`. - * @throws When the stored value exists but is not a JSON array. - */ -function getStoredDevices(): Set | 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'); - } - - return new Set( - parsedDevices.filter((value): value is string => typeof value === 'string' && value.length > 0), - ); -} - -/** - * Creates a redirect URI for OIDC based on the current page origin and path. - * - * Note: This intentionally excludes query parameters so temporary values like - * `code` and `state` can be removed cleanly after token exchange. - * - * @returns The redirect URI string (origin + pathname). - */ -function getRedirectUri() { - const currentUrl = new URL(window.location.href); - return `${currentUrl.origin}${currentUrl.pathname}`; -} - -/** - * Derive the realm value used by device-client endpoints from a well-known URL. - * - * @param wellknown The OIDC well-known URL. - * @returns The derived realm path to use with device-client (defaults to `root`). - */ -function getRealmPathFromWellknown(wellknown: string): string { - const pathname = new URL(wellknown).pathname; - const match = pathname.match(/\/realms\/([^/]+)\/\.well-known\/openid-configuration\/?$/); - return match?.[1] ?? 'root'; -} +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. @@ -82,225 +25,107 @@ function getBaseUrlFromWellknown(wellknown: string): string { } /** - * Type guard to detect error-shaped responses returned by SDK helpers. - * - * @param value The unknown value to inspect. - * @returns `true` when the object contains an `error` property. - */ -function hasError(value: unknown): value is { error: string } { - return Boolean(value && typeof value === 'object' && 'error' in value); -} - -/** - * Retrieves usable OIDC tokens for the current browser session. + * Derives the realm URL path from an OIDC well-known URL. * - * This will: - * - exchange an authorization code (`code` + `state`) when present in the URL - * - otherwise retrieve/renew tokens via the OIDC client - * - redirect the browser when the token API returns a `redirectUrl` - * - * @param oidcClient An initialized OIDC client instance. - * @param config OIDC configuration used to initiate the authorization flow. - * @returns Tokens on success; otherwise an `{ error }` object. + * Example: `/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration` + * becomes `realms/root/realms/alpha`. */ -async function getOidcTokens( - oidcClient: OidcClient, - config: OidcConfig, -): Promise { - if (hasError(oidcClient)) { - return { error: oidcClient.error }; - } - - const searchParams = new URLSearchParams(window.location.search); - const code = searchParams.get('code'); - const state = searchParams.get('state'); - - if (code && state) { - const exchanged = await oidcClient.token.exchange(code, state); - if (hasError(exchanged)) { - return { error: exchanged.error }; - } - - const cleanedUrl = new URL(window.location.href); - cleanedUrl.searchParams.delete('code'); - cleanedUrl.searchParams.delete('state'); - window.history.replaceState({}, document.title, cleanedUrl.toString()); - - return exchanged; +function getRealmUrlPathFromWellknown(wellknown: string): string { + const parsed = new URL(wellknown); + const [, afterOauth] = parsed.pathname.split('/oauth2/'); + if (!afterOauth) { + return 'realms/root'; } - const tokens = await oidcClient.token.get({ - backgroundRenew: true, - authorizeOptions: { - clientId: config.clientId, - redirectUri: getRedirectUri(), - scope: config.scope, - responseType: config.responseType ?? 'code', - responseMode: 'query', - }, - }); + const suffix = '/.well-known/openid-configuration'; + const realmUrlPath = afterOauth.endsWith(suffix) + ? afterOauth.slice(0, -suffix.length) + : afterOauth.replace(/\/.well-known\/openid-configuration\/?$/, ''); - if (hasError(tokens)) { - if ('redirectUrl' in tokens && typeof tokens.redirectUrl === 'string') { - window.location.assign(tokens.redirectUrl); - } - return { error: tokens.error }; - } - - return tokens; + return realmUrlPath.replace(/^\/+/, '').replace(/\/+$/, '') || 'realms/root'; } /** - * Retrieves the UUID (`sub`) for the currently authenticated user. + * Retrieves the AM user id from the session cookie using `idFromSession`. * - * @param oidcClient An initialized OIDC client instance. - * @returns The user UUID string on success; otherwise an `{ error }` object. + * Note: This relies on the browser sending the session cookie; callers should use + * `credentials: 'include'` and ensure AM CORS allows credentialed requests. */ -async function getCurrentUserUuid(oidcClient: OidcClient): Promise { - if (hasError(oidcClient)) { - return { error: oidcClient.error }; - } +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 userInfo = (await oidcClient.user.info()) as GenericError | UserInfoResponse; + const data = await response.json(); - if (hasError(userInfo)) { - return { error: userInfo.error }; - } + if (!data || typeof data !== 'object') { + return null; + } - return userInfo.sub; + const id = (data as Record).id; + return typeof id === 'string' && id.length > 0 ? id : null; + } catch { + return null; + } } /** - * Fetches the current user's WebAuthn devices using the device-client SDK. + * Deletes a single WebAuthn device by matching its `credentialId`. * - * @param config OIDC configuration used to initialize the OIDC client. - * @returns The user UUID, resolved realm, a configured device-client instance, and the devices list. - * @throws When token retrieval fails or device retrieval returns an error shape. + * This queries devices via device-client and deletes the matching device. */ -async function getWebAuthnDevicesForCurrentUser(config: OidcConfig): Promise<{ - userId: string; - realm: string; - webAuthnClient: DeviceClient; - devices: WebAuthnDevice[]; -}> { - const oidcConfig = { ...config, redirectUri: getRedirectUri() }; - const oidcClient = await oidc({ config: oidcConfig }); - const tokens = await getOidcTokens(oidcClient, config); - - if (hasError(tokens)) { - throw new Error(`OIDC token retrieval failed: ${String(tokens.error)}`); - } - - const userId = await getCurrentUserUuid(oidcClient); - if (typeof userId !== 'string') { - throw new Error(`Failed to retrieve user UUID: ${String(userId.error)}`); +export async function deleteWebAuthnDevice( + config: JourneyClientConfig, + credentialId: string | null, +): Promise { + if (!credentialId) { + return 'No credential id found. Register a WebAuthn device first.'; } const wellknown = config.serverConfig.wellknown; - const realm = getRealmPathFromWellknown(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 webAuthnClient = deviceClient({ realmPath: realm, serverConfig: { baseUrl, }, }); - const devices = await webAuthnClient.webAuthn.get({ - userId, - }); + const devices = await webAuthnClient.webAuthn.get({ userId }); if (!Array.isArray(devices)) { throw new Error(`Failed to retrieve devices: ${String(devices.error)}`); } - return { userId, realm, webAuthnClient, devices: devices as WebAuthnDevice[] }; -} - -/** - * Stores the current set of registered WebAuthn device UUIDs in `localStorage`. - * - * If devices have already been stored, this is a no-op and returns the existing count. - * - * @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 { - const storedDevices = getStoredDevices(); - if (storedDevices) { - return `Devices before session: ${storedDevices.size} registered WebAuthn device(s).`; + const device = (devices as WebAuthnDevice[]).find((d) => d.credentialId === credentialId); + if (!device) { + return `No WebAuthn device found matching credential id: ${credentialId}`; } - const { devices } = await getWebAuthnDevicesForCurrentUser(config); - const uuids = devices.map((device) => device.uuid).filter((uuid) => Boolean(uuid)); - window.localStorage.setItem(WEBAUTHN_DEVICES_KEY, JSON.stringify(uuids)); - return `Devices before session: ${uuids.length} registered WebAuthn device(s).`; -} - -/** - * Deletes only the WebAuthn devices that were registered during the current session. - * - * This compares the current device list against the snapshot stored by - * `storeDevicesBeforeSession` and deletes any newly added devices. - * - * @param config OIDC configuration used to retrieve and delete WebAuthn devices. - * @returns A human-readable status message for UI display. - * @throws When the delete endpoint returns an error shape. - */ -export async function deleteDevicesInSession(config: OidcConfig): Promise { - const storedDevices = getStoredDevices(); - if (!storedDevices) { - return 'No devices found. Click Get Registered Devices first.'; - } - - const { userId, webAuthnClient, devices } = await getWebAuthnDevicesForCurrentUser(config); - const devicesToDelete = devices.filter((device) => !storedDevices.has(device.uuid)); - - if (devicesToDelete.length === 0) { - return `No devices found in this session for user ${userId}.`; - } - - for (const device of devicesToDelete) { - const response = await webAuthnClient.webAuthn.delete({ - userId, - device, - }); - - if (response && hasError(response)) { - throw new Error(`Failed deleting device ${device.uuid}: ${String(response.error)}`); - } - } - - return `Deleted ${devicesToDelete.length} WebAuthn device(s) for user ${userId}.`; -} - -/** - * Deletes all registered WebAuthn devices for the current user. - * - * This always clears the stored snapshot in `localStorage` once deletions complete. - * - * @param config OIDC configuration used to retrieve and delete WebAuthn devices. - * @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 { - const { userId, webAuthnClient, devices } = await getWebAuthnDevicesForCurrentUser(config); - - if (devices.length === 0) { - window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY); - return `No registered WebAuthn devices found for user ${userId}.`; - } - - for (const device of devices as WebAuthnDevice[]) { - const response = await webAuthnClient.webAuthn.delete({ - userId, - device, - }); + const response = await webAuthnClient.webAuthn.delete({ + userId, + device, + }); - if (response && hasError(response)) { - throw new Error(`Failed deleting device ${device.uuid}: ${String(response.error)}`); - } + 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)}`); } - window.localStorage.removeItem(WEBAUTHN_DEVICES_KEY); - return `Deleted ${devices.length} registered WebAuthn device(s) for user ${userId}.`; + return `Deleted WebAuthn device ${device.uuid} with credential id ${credentialId} for user ${userId}.`; } diff --git a/e2e/journey-suites/src/webauthn-devices.test.ts b/e2e/journey-suites/src/webauthn-devices.test.ts index 6d537fcaf1..a9a9e9776e 100644 --- a/e2e/journey-suites/src/webauthn-devices.test.ts +++ b/e2e/journey-suites/src/webauthn-devices.test.ts @@ -5,76 +5,24 @@ * of the MIT license. See the LICENSE file for details. */ -import { expect, test, Page } from '@playwright/test'; +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'; test.use({ browserName: 'chromium' }); -test.describe('WebAuthn registration delete devices', () => { +function toBase64Url(value: string): string { + return value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); +} + +test.describe('WebAuthn register, authenticate, and delete device', () => { let cdp: CDPSession | undefined; let authenticatorId: string | undefined; - async function login(page: Page, journey = 'Login'): Promise { - const { clickButton, navigate } = asyncEvents(page); - - await navigate(`/?clientId=tenant&journey=${journey}`); - 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(); - } - - async function logout(page: Page): Promise { - const { clickButton } = asyncEvents(page); - await clickButton('Logout', '/sessions'); - await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); - } - - async function getDevicesBeforeSession(page: Page): Promise { - const getButton = page.getByRole('button', { name: 'Get Registered Devices' }); - await expect(getButton).toBeVisible(); - await getButton.click(); - await expect(page.locator('#deviceStatus')).toContainText('Devices before session:'); - } - - async function deleteDevicesInSession(page: Page): Promise { - await login(page); - - const deleteButton = page.getByRole('button', { name: 'Delete Devices From This Session' }); - await expect(deleteButton).toBeVisible(); - await deleteButton.click(); - - await expect(page.locator('#deviceStatus')).toContainText( - /Deleted|No devices found in this session|No devices found/, - ); - - await logout(page); - } - - async function completeAuthenticationJourney(page: Page): Promise { - const { clickButton, navigate } = asyncEvents(page); - - await navigate('/?clientId=tenant&journey=TEST_WebAuthnAuthentication_UsernamePassword'); - await expect(page.getByLabel('User Name')).toBeVisible(); - await page.getByLabel('User Name').fill(username); - await clickButton('Submit', '/authenticate'); - - await expect(page.getByLabel('Password')).toBeVisible(); - await page.getByLabel('Password').fill(password); - await clickButton('Submit', '/authenticate'); - - await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); - } - 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', @@ -85,65 +33,72 @@ test.describe('WebAuthn registration delete devices', () => { automaticPresenceSimulation: true, }, }); - authenticatorId = response.authenticatorId; - - await login(page); - await getDevicesBeforeSession(page); - await logout(page); }); - test.afterEach(async ({ page }) => { - await page.unroute('**/*'); - - try { - await deleteDevicesInSession(page); - } catch (error) { - console.error('Delete failed:', error); - } - - if (!cdp) { - return; - } - - if (authenticatorId) { + test.afterEach(async () => { + if (cdp && authenticatorId) { await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); + await cdp.send('WebAuthn.disable'); } - - await cdp.send('WebAuthn.disable'); }); - async function completeRegistrationJourney(page): Promise { - await login(page, 'TEST_WebAuthn-Registration'); - } + test('should register, authenticate, and delete a device', async ({ page }) => { + if (!cdp || !authenticatorId) { + throw new Error('Virtual authenticator was not initialized'); + } - test('should register multiple devices, authenticate and delete devices', async ({ page }) => { - await completeRegistrationJourney(page); - await logout(page); + const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + expect(initialCredentials).toHaveLength(0); - await completeRegistrationJourney(page); - await logout(page); + // login with username and password and register a device + const { clickButton, navigate } = asyncEvents(page); + 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(); - await completeAuthenticationJourney(page); + // capture and assert virtual authenticator credentialId + 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'); + } - const deleteButton = page.getByRole('button', { name: 'Delete Devices From This Session' }); - await expect(deleteButton).toBeVisible(); - await deleteButton.click(); - await expect(page.locator('#deviceStatus')).toContainText( - 'Deleted 2 WebAuthn device(s) for user', - ); + // assert registered credentialId in query param matches virtual authenticator credentialId + const registrationUrl = new URL(page.url()); + const registrationUrlValues = Array.from(registrationUrl.searchParams.values()); + expect(registrationUrlValues).toContain(toBase64Url(virtualCredentialId)); - await logout(page); - }); + // logout + await clickButton('Logout', '/sessions'); + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); - test('should delete all registered devices', async ({ page }) => { - await completeRegistrationJourney(page); + // capture credentialId from registrationUrl query param + const authenticationUrl = new URL(registrationUrl.toString()); + authenticationUrl.searchParams.set('journey', 'TEST_WebAuthnAuthentication'); - const deleteAllButton = page.getByRole('button', { name: 'Delete All Registered Devices' }); - await expect(deleteAllButton).toBeVisible(); - await deleteAllButton.click(); + // authenticate with registered webauthn device + await navigate(authenticationUrl.toString()); + 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 expect(page.locator('#deviceStatus')).toContainText('Deleted'); - await expect(page.locator('#deviceStatus')).toContainText('registered WebAuthn device(s)'); + // delete registered webauthn device + 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 ${toBase64Url(virtualCredentialId)} for user`, + ); }); }); From 840313c0f5a658607c11f4dccb6aff40afaa67f4 Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Sat, 21 Mar 2026 12:49:55 -0700 Subject: [PATCH 3/3] feat(journey-app): delete webauthn device from AM using device client --- e2e/journey-app/components/webauthn-step.ts | 41 +++++++ e2e/journey-app/components/webauthn.ts | 86 ------------- e2e/journey-app/main.ts | 39 +----- ...n-devices.ts => delete-webauthn-device.ts} | 22 ++-- e2e/journey-suites/src/WEBAUTHN_TESTING.md | 73 +++++++++++ .../src/webauthn-device.test.ts | 115 ++++++++++++++++++ .../src/webauthn-devices.test.ts | 104 ---------------- 7 files changed, 246 insertions(+), 234 deletions(-) create mode 100644 e2e/journey-app/components/webauthn-step.ts delete mode 100644 e2e/journey-app/components/webauthn.ts rename e2e/journey-app/services/{delete-webauthn-devices.ts => delete-webauthn-device.ts} (87%) create mode 100644 e2e/journey-suites/src/WEBAUTHN_TESTING.md create mode 100644 e2e/journey-suites/src/webauthn-device.test.ts delete mode 100644 e2e/journey-suites/src/webauthn-devices.test.ts 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/components/webauthn.ts b/e2e/journey-app/components/webauthn.ts deleted file mode 100644 index 3376ba67cc..0000000000 --- a/e2e/journey-app/components/webauthn.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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 extractRegistrationCredentialId(outcomeValue: string): string | null { - // This app consumes the hidden `webAuthnOutcome` callback populated by journey-client. - // See packages/journey-client/src/lib/webauthn/webauthn.ts: - // - register(): JSON-wrapped outcome when `supportsJsonResponse` is enabled - // - register(): plain legacy outcome string otherwise - let legacyData: string | null = outcomeValue; - - // Newer journey-client responses may wrap the legacy string as: - // { authenticatorAttachment, legacyData } - // We only need the legacy payload here; the attachment is not used by journey-app. - try { - const parsed = JSON.parse(outcomeValue) as unknown; - if (parsed && typeof parsed === 'object' && 'legacyData' in parsed) { - const candidate = (parsed as Record).legacyData; - legacyData = typeof candidate === 'string' ? candidate : null; - } - } catch { - // Not JSON; fall back to plain legacy outcome string. - } - - if (!legacyData) { - return null; - } - - // journey-client registration outcome format is: - // clientDataJSON::attestationObject::credentialId[::deviceName] - // The app only needs the third segment so delete-webauthn-devices can target - // the same registered credential later. - // See e2e/journey-app/main.ts and e2e/journey-app/services/delete-webauthn-devices.ts. - const parts = legacyData.split('::'); - const credentialId = parts[2]; - return credentialId && credentialId.length > 0 ? credentialId : null; -} - -export type WebAuthnHandleResult = { - success: boolean; - credentialId: string | null; -}; - -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 { success: true, credentialId: null }; - } - - if (webAuthnStepType === WebAuthnStepType.Registration) { - console.log('trying registration'); - await WebAuthn.register(step); - - const { hiddenCallback } = WebAuthn.getCallbacks(step); - const rawOutcome = String(hiddenCallback?.getInputValue() ?? ''); - const credentialId = extractRegistrationCredentialId(rawOutcome); - console.log('[WebAuthn] registration credentialId:', credentialId); - return { success: true, credentialId }; - } - - return { success: false, credentialId: null }; - } catch { - return { success: false, credentialId: null }; - } - } - - return handleWebAuthn(); -} diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index 820b904e5e..35aa41262a 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -15,15 +15,13 @@ 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-devices.js'; -import { webauthnComponent } from './components/webauthn.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; const searchParams = new URLSearchParams(qs); -const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId'; - const config = serverConfigs[searchParams.get('clientId') || 'basic']; const journeyName = searchParams.get('journey') ?? 'UsernamePassword'; @@ -65,27 +63,9 @@ if (searchParams.get('middleware') === 'true') { const formEl = document.getElementById('form') as HTMLFormElement; const journeyEl = document.getElementById('journey') as HTMLDivElement; - const getCredentialIdFromUrl = (): string | null => { - const params = new URLSearchParams(window.location.search); - const value = params.get(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM); - return value && value.length > 0 ? value : null; - }; - - const setCredentialIdInUrl = (credentialId: string | null): void => { - const url = new URL(window.location.href); - if (credentialId) { - url.searchParams.set(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM, credentialId); - } else { - url.searchParams.delete(WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM); - } - window.history.replaceState({}, document.title, url.toString()); - }; - - let registrationCredentialId: string | null = getCredentialIdFromUrl(); - let journeyClient: JourneyClient; try { - journeyClient = await journey({ config, requestMiddleware }); + journeyClient = await journey({ config: config, requestMiddleware }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Failed to initialize journey client:', message); @@ -134,13 +114,8 @@ if (searchParams.get('middleware') === 'true') { webAuthnStep === WebAuthnStepType.Authentication || webAuthnStep === WebAuthnStepType.Registration ) { - const webAuthnResponse = await webauthnComponent(journeyEl, step, 0); - if (webAuthnResponse.success) { - if (webAuthnResponse.credentialId) { - registrationCredentialId = webAuthnResponse.credentialId; - setCredentialIdInUrl(registrationCredentialId); - console.log('[WebAuthn] stored registration credentialId:', registrationCredentialId); - } + const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0); + if (webAuthnSuccess) { submitForm(); return; } else { @@ -180,9 +155,7 @@ if (searchParams.get('middleware') === 'true') { completeHeader.innerText = 'Complete'; journeyEl.appendChild(completeHeader); - renderDeleteDevicesSection(journeyEl, () => - deleteWebAuthnDevice(config, registrationCredentialId), - ); + renderDeleteDevicesSection(journeyEl, () => deleteWebAuthnDevice(config)); const sessionLabelEl = document.createElement('span'); sessionLabelEl.id = 'sessionLabel'; diff --git a/e2e/journey-app/services/delete-webauthn-devices.ts b/e2e/journey-app/services/delete-webauthn-device.ts similarity index 87% rename from e2e/journey-app/services/delete-webauthn-devices.ts rename to e2e/journey-app/services/delete-webauthn-device.ts index a35a855217..e8c5e0827b 100644 --- a/e2e/journey-app/services/delete-webauthn-devices.ts +++ b/e2e/journey-app/services/delete-webauthn-device.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { deviceClient } from '@forgerock/device-client'; +import { deviceClient as createDeviceClient } from '@forgerock/device-client'; import type { WebAuthnDevice } from '@forgerock/device-client/types'; import { JourneyClientConfig } from '@forgerock/journey-client/types'; @@ -82,10 +82,12 @@ async function getUserIdFromSession(baseUrl: string, realmUrlPath: string): Prom * * This queries devices via device-client and deletes the matching device. */ -export async function deleteWebAuthnDevice( - config: JourneyClientConfig, - credentialId: string | null, -): Promise { +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.'; } @@ -100,14 +102,12 @@ export async function deleteWebAuthnDevice( } const realm = realmUrlPath.replace(/^realms\//, '') || 'root'; - const webAuthnClient = deviceClient({ + const deviceClient = createDeviceClient({ realmPath: realm, - serverConfig: { - baseUrl, - }, + serverConfig: { baseUrl }, }); - const devices = await webAuthnClient.webAuthn.get({ userId }); + const devices = await deviceClient.webAuthn.get({ userId }); if (!Array.isArray(devices)) { throw new Error(`Failed to retrieve devices: ${String(devices.error)}`); } @@ -117,7 +117,7 @@ export async function deleteWebAuthnDevice( return `No WebAuthn device found matching credential id: ${credentialId}`; } - const response = await webAuthnClient.webAuthn.delete({ + const response = await deviceClient.webAuthn.delete({ userId, device, }); 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/e2e/journey-suites/src/webauthn-devices.test.ts b/e2e/journey-suites/src/webauthn-devices.test.ts deleted file mode 100644 index a9a9e9776e..0000000000 --- a/e2e/journey-suites/src/webauthn-devices.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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'; - -test.use({ browserName: 'chromium' }); - -function toBase64Url(value: string): string { - return value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); -} - -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 { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { - authenticatorId, - }); - expect(initialCredentials).toHaveLength(0); - - // login with username and password and register a device - const { clickButton, navigate } = asyncEvents(page); - 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(); - - // capture and assert virtual authenticator credentialId - 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'); - } - - // assert registered credentialId in query param matches virtual authenticator credentialId - const registrationUrl = new URL(page.url()); - const registrationUrlValues = Array.from(registrationUrl.searchParams.values()); - expect(registrationUrlValues).toContain(toBase64Url(virtualCredentialId)); - - // logout - await clickButton('Logout', '/sessions'); - await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); - - // capture credentialId from registrationUrl query param - const authenticationUrl = new URL(registrationUrl.toString()); - authenticationUrl.searchParams.set('journey', 'TEST_WebAuthnAuthentication'); - - // authenticate with registered webauthn device - await navigate(authenticationUrl.toString()); - 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(); - - // delete registered webauthn device - 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 ${toBase64Url(virtualCredentialId)} for user`, - ); - }); -});