From 266f7f0cbfd85361b1e372df6c7a3e8c868b26bc Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Sat, 14 Mar 2026 13:04:22 -0700 Subject: [PATCH 1/2] 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 15059087a7..f8f238092b 100644 --- a/packages/device-client/package.json +++ b/packages/device-client/package.json @@ -32,6 +32,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 135d01b710..df99cb484c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -324,6 +324,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 c0597a614cc12e52d464ae3139dffb411eb8b564 Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Fri, 20 Mar 2026 07:53:36 -0700 Subject: [PATCH 2/2] 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`, + ); }); });