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.ts b/e2e/journey-app/components/webauthn.ts new file mode 100644 index 0000000000..3376ba67cc --- /dev/null +++ b/e2e/journey-app/components/webauthn.ts @@ -0,0 +1,86 @@ +/* + * 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 b78f814df3..820b904e5e 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,17 +7,23 @@ import './style.css'; import { journey } from '@forgerock/journey-client'; +import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'; import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types'; import { renderCallbacks } from './callback-map.js'; +import { renderDeleteDevicesSection } from './components/delete-device.js'; import { renderQRCodeStep } from './components/qr-code.js'; import { renderRecoveryCodesStep } from './components/recovery-codes.js'; +import { deleteWebAuthnDevice } from './services/delete-webauthn-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'; @@ -59,9 +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 { - journeyClient = await journey({ config: config, 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); @@ -70,34 +94,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 +113,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 +127,28 @@ if (searchParams.get('middleware') === 'true') { const submitForm = () => formEl.requestSubmit(); + // Handle WebAuthn steps first so we can hide the Submit button while processing, + // auto-submit on success, and show an error on failure. + const webAuthnStep = WebAuthn.getWebAuthnStepType(step); + if ( + webAuthnStep === WebAuthnStepType.Authentication || + webAuthnStep === WebAuthnStepType.Registration + ) { + const 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 { + 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 +164,54 @@ if (searchParams.get('middleware') === 'true') { journeyEl.appendChild(submitBtn); } + function renderComplete() { + if (step?.type !== 'LoginSuccess') { + throw new Error('Expected step to be defined and of type LoginSuccess'); + } + + const session = step.getSessionToken(); + + console.log(`Session Token: ${session || 'none'}`); + + journeyEl.replaceChildren(); + + const completeHeader = document.createElement('h2'); + completeHeader.id = 'completeHeader'; + completeHeader.innerText = 'Complete'; + journeyEl.appendChild(completeHeader); + + renderDeleteDevicesSection(journeyEl, () => + deleteWebAuthnDevice(config, registrationCredentialId), + ); + + const sessionLabelEl = document.createElement('span'); + sessionLabelEl.id = 'sessionLabel'; + sessionLabelEl.innerText = 'Session:'; + + const sessionTokenEl = document.createElement('pre'); + sessionTokenEl.id = 'sessionToken'; + sessionTokenEl.textContent = session || 'none'; + + const logoutBtn = document.createElement('button'); + logoutBtn.type = 'button'; + logoutBtn.id = 'logoutButton'; + logoutBtn.innerText = 'Logout'; + + journeyEl.appendChild(sessionLabelEl); + journeyEl.appendChild(sessionTokenEl); + journeyEl.appendChild(logoutBtn); + + logoutBtn.addEventListener('click', async () => { + await journeyClient.terminate(); + + console.log('Logout successful'); + + step = await journeyClient.start({ journey: journeyName }); + + renderForm(); + }); + } + formEl.addEventListener('submit', async (event) => { event.preventDefault(); diff --git a/e2e/journey-app/package.json b/e2e/journey-app/package.json index 40a825314b..7cc9e67485 100644 --- a/e2e/journey-app/package.json +++ b/e2e/journey-app/package.json @@ -14,7 +14,8 @@ "@forgerock/journey-client": "workspace:*", "@forgerock/oidc-client": "workspace:*", "@forgerock/protect": "workspace:*", - "@forgerock/sdk-logger": "workspace:*" + "@forgerock/sdk-logger": "workspace:*", + "@forgerock/device-client": "workspace:*" }, "nx": { "tags": ["scope:e2e"] diff --git a/e2e/journey-app/services/delete-webauthn-devices.ts b/e2e/journey-app/services/delete-webauthn-devices.ts new file mode 100644 index 0000000000..a35a855217 --- /dev/null +++ b/e2e/journey-app/services/delete-webauthn-devices.ts @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { deviceClient } from '@forgerock/device-client'; +import type { WebAuthnDevice } from '@forgerock/device-client/types'; +import { JourneyClientConfig } from '@forgerock/journey-client/types'; + +/** + * Derives the AM base URL from an OIDC well-known URL. + * + * Example: `https://example.com/am/oauth2/alpha/.well-known/openid-configuration` + * becomes `https://example.com/am`. + * + * @param wellknown The OIDC well-known URL. + * @returns The base URL for AM (origin + path prefix before `/oauth2/`). + */ +function getBaseUrlFromWellknown(wellknown: string): string { + const parsed = new URL(wellknown); + const [pathWithoutOauth] = parsed.pathname.split('/oauth2/'); + return `${parsed.origin}${pathWithoutOauth}`; +} + +/** + * Derives the realm URL path from an OIDC well-known URL. + * + * Example: `/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration` + * becomes `realms/root/realms/alpha`. + */ +function getRealmUrlPathFromWellknown(wellknown: string): string { + const parsed = new URL(wellknown); + const [, afterOauth] = parsed.pathname.split('/oauth2/'); + if (!afterOauth) { + return 'realms/root'; + } + + const suffix = '/.well-known/openid-configuration'; + const realmUrlPath = afterOauth.endsWith(suffix) + ? afterOauth.slice(0, -suffix.length) + : afterOauth.replace(/\/.well-known\/openid-configuration\/?$/, ''); + + return realmUrlPath.replace(/^\/+/, '').replace(/\/+$/, '') || 'realms/root'; +} + +/** + * Retrieves the AM user id from the session cookie using `idFromSession`. + * + * Note: This relies on the browser sending the session cookie; callers should use + * `credentials: 'include'` and ensure AM CORS allows credentialed requests. + */ +async function getUserIdFromSession(baseUrl: string, realmUrlPath: string): Promise { + const url = `${baseUrl}/json/${realmUrlPath}/users?_action=idFromSession`; + + try { + const response = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept-API-Version': 'protocol=2.1,resource=3.0', + }, + }); + + const data = await response.json(); + + if (!data || typeof data !== 'object') { + return null; + } + + const id = (data as Record).id; + return typeof id === 'string' && id.length > 0 ? id : null; + } catch { + return null; + } +} + +/** + * Deletes a single WebAuthn device by matching its `credentialId`. + * + * This queries devices via device-client and deletes the matching device. + */ +export async function deleteWebAuthnDevice( + config: JourneyClientConfig, + credentialId: string | null, +): Promise { + if (!credentialId) { + return 'No credential id found. Register a WebAuthn device first.'; + } + + const wellknown = config.serverConfig.wellknown; + const baseUrl = getBaseUrlFromWellknown(wellknown); + const realmUrlPath = getRealmUrlPathFromWellknown(wellknown); + const userId = await getUserIdFromSession(baseUrl, realmUrlPath); + + if (!userId) { + throw new Error('Failed to retrieve user id from session. Are you logged in?'); + } + + const realm = realmUrlPath.replace(/^realms\//, '') || 'root'; + const 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)}`); + } + + const device = (devices as WebAuthnDevice[]).find((d) => d.credentialId === credentialId); + if (!device) { + return `No WebAuthn device found matching credential id: ${credentialId}`; + } + + const response = await webAuthnClient.webAuthn.delete({ + userId, + device, + }); + + if (response && typeof response === 'object' && 'error' in response) { + const error = (response as { error?: unknown }).error; + throw new Error(`Failed deleting device ${device.uuid}: ${String(error)}`); + } + + return `Deleted WebAuthn device ${device.uuid} with credential id ${credentialId} for user ${userId}.`; +} diff --git a/e2e/journey-app/style.css b/e2e/journey-app/style.css index db3f236578..e32d10ac16 100644 --- a/e2e/journey-app/style.css +++ b/e2e/journey-app/style.css @@ -54,6 +54,7 @@ pre { margin: 1em 0; padding: 1em; background-color: #1a1a1a; + color: #f3f4f6; border-radius: 8px; overflow-x: auto; } diff --git a/e2e/journey-app/tsconfig.app.json b/e2e/journey-app/tsconfig.app.json index 5d19cb58cd..417f5a64c2 100644 --- a/e2e/journey-app/tsconfig.app.json +++ b/e2e/journey-app/tsconfig.app.json @@ -10,12 +10,16 @@ "./helper.ts", "./server-configs.ts", "./callback-map.ts", - "components/**/*.ts" + "components/**/*.ts", + "services/**/*.ts" ], "references": [ { "path": "../../packages/sdk-effects/logger/tsconfig.lib.json" }, + { + "path": "../../packages/device-client/tsconfig.lib.json" + }, { "path": "../../packages/oidc-client/tsconfig.lib.json" }, diff --git a/e2e/journey-suites/src/webauthn-devices.test.ts b/e2e/journey-suites/src/webauthn-devices.test.ts new file mode 100644 index 0000000000..a9a9e9776e --- /dev/null +++ b/e2e/journey-suites/src/webauthn-devices.test.ts @@ -0,0 +1,104 @@ +/* + * 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`, + ); + }); +}); 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