From dc6b18f59c36e77c8ad5caa6fcc1327cdebaf64e Mon Sep 17 00:00:00 2001 From: AJ Ancheta <7781450+ancheetah@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:21:59 -0500 Subject: [PATCH 1/2] test(davinci-client): virtual authenticator e2e tests --- e2e/davinci-app/components/fido.ts | 66 +++++++++++ e2e/davinci-app/main.ts | 21 +++- e2e/davinci-suites/playwright.config.ts | 1 + e2e/davinci-suites/src/fido.test.ts | 144 ++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 e2e/davinci-app/components/fido.ts create mode 100644 e2e/davinci-suites/src/fido.test.ts diff --git a/e2e/davinci-app/components/fido.ts b/e2e/davinci-app/components/fido.ts new file mode 100644 index 0000000000..e7ad36c815 --- /dev/null +++ b/e2e/davinci-app/components/fido.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 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 type { + FidoRegistrationCollector, + FidoAuthenticationCollector, + Updater, + FidoClient, +} from '@forgerock/davinci-client/types'; + +export default function fidoComponent( + formEl: HTMLFormElement, + collector: FidoRegistrationCollector | FidoAuthenticationCollector, + updater: Updater, + fidoApi: FidoClient, + submitForm: () => Promise, +) { + if (collector.type === 'FidoRegistrationCollector') { + const button = document.createElement('button'); + button.type = 'button'; + button.value = collector.output.key; + button.innerHTML = 'FIDO Register'; + formEl.appendChild(button); + + button.onclick = async () => { + const credentialOptions = collector.output.config.publicKeyCredentialCreationOptions; + const response = await fidoApi.register(credentialOptions); + console.log('fido.register response:', response); + if ('error' in response) { + console.error(response); + } else { + const error = updater(response); + if (error && 'error' in error) { + console.error(error.error.message); + } else { + await submitForm(); + } + } + }; + } else if (collector.type === 'FidoAuthenticationCollector') { + const button = document.createElement('button'); + button.type = 'button'; + button.value = collector.output.key; + button.innerHTML = 'FIDO Authenticate'; + formEl.appendChild(button); + + button.onclick = async () => { + const credentialOptions = collector.output.config.publicKeyCredentialRequestOptions; + const response = await fidoApi.authenticate(credentialOptions); + console.log('fido.authenticate response:', response); + if ('error' in response) { + console.error(response); + } else { + const error = updater(response); + if (error && 'error' in error) { + console.error(error.error.message); + } else { + await submitForm(); + } + } + }; + } +} diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index fb61ccb556..b3774d3b3b 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -7,7 +7,7 @@ import './style.css'; import { Config, FRUser, TokenManager } from '@forgerock/javascript-sdk'; -import { davinci } from '@forgerock/davinci-client'; +import { davinci, fido } from '@forgerock/davinci-client'; import type { CustomLogger, DaVinciConfig, @@ -31,6 +31,7 @@ import singleValueComponent from './components/single-value.js'; import multiValueComponent from './components/multi-value.js'; import labelComponent from './components/label.js'; import objectValueComponent from './components/object-value.js'; +import fidoComponent from './components/fido.js'; const loggerFn = { error: () => { @@ -81,13 +82,14 @@ const urlParams = new URLSearchParams(window.location.search); (async () => { const davinciClient: DavinciClient = await davinci({ config, logger, requestMiddleware }); - const protectAPI = protect({ envId: '02fb4743-189a-4bc7-9d6c-a919edfe6447' }); + const protectApi = protect({ envId: '02fb4743-189a-4bc7-9d6c-a919edfe6447' }); + const fidoApi = fido(); const continueToken = urlParams.get('continueToken'); const formEl = document.getElementById('form') as HTMLFormElement; let resumed: InternalErrorResponse | NodeStates | undefined; // Initialize Protect - const error = await protectAPI.start(); + const error = await protectApi.start(); if (error?.error) { console.error('Error starting Protect:', error.error); } @@ -251,6 +253,17 @@ const urlParams = new URLSearchParams(window.location.search); ); } else if (collector.type === 'IdpCollector') { socialLoginButtonComponent(formEl, collector, davinciClient.externalIdp()); + } else if ( + collector.type === 'FidoRegistrationCollector' || + collector.type === 'FidoAuthenticationCollector' + ) { + fidoComponent( + formEl, // You can ignore this; it's just for rendering + collector, // This is the plain object of the collector + davinciClient.update(collector), // Returns an update function for this collector + fidoApi, // FIDO module for interacting with WebAuthn API + submitForm, + ); } else if (collector.type === 'FlowCollector') { flowLinkComponent( formEl, // You can ignore this; it's just for rendering @@ -278,7 +291,7 @@ const urlParams = new URLSearchParams(window.location.search); } async function updateProtectCollector(protectCollector: ProtectCollector) { - const data = await protectAPI.getData(); + const data = await protectApi.getData(); if (typeof data !== 'string' && 'error' in data) { console.error(`Failed to retrieve data from PingOne Protect: ${data.error}`); return; diff --git a/e2e/davinci-suites/playwright.config.ts b/e2e/davinci-suites/playwright.config.ts index 65484789d9..4c3393bccd 100644 --- a/e2e/davinci-suites/playwright.config.ts +++ b/e2e/davinci-suites/playwright.config.ts @@ -41,6 +41,7 @@ const config: PlaywrightTestConfig = { cwd: workspaceRoot, }, ].filter(Boolean), + testIgnore: '**/fido.test.ts', }; export default config; diff --git a/e2e/davinci-suites/src/fido.test.ts b/e2e/davinci-suites/src/fido.test.ts new file mode 100644 index 0000000000..ad679110ab --- /dev/null +++ b/e2e/davinci-suites/src/fido.test.ts @@ -0,0 +1,144 @@ +import { test, expect, CDPSession } from '@playwright/test'; +import { asyncEvents } from './utils/async-events.js'; + +const username = 'JSFidoUser@user.com'; +const password = 'FakePassword#123'; +let cdp: CDPSession | undefined; +let authenticatorId: string | undefined; + +test.use({ browserName: 'chromium' }); // ensure CDP/WebAuthn is available + +test.beforeEach(async ({ context, page }) => { + cdp = await context.newCDPSession(page); + await cdp.send('WebAuthn.enable'); + + // A "platform" authenticator (aka internal) with UV+RK enabled is the usual default for passkeys. + const response = await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', // platform authenticator + hasResidentKey: true, // allow discoverable credentials (passkeys) + hasUserVerification: true, // device supports UV + isUserVerified: true, // simulate successful UV (PIN/biometric) + automaticPresenceSimulation: true, // auto "touch"/presence + }, + }); + authenticatorId = response.authenticatorId; +}); + +test.afterEach(async () => { + await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); + await cdp.send('WebAuthn.disable'); +}); + +test.describe('FIDO/WebAuthn Tests', () => { + test('Register and authenticate with webauthn device', async ({ page }) => { + const { navigate } = asyncEvents(page); + + await navigate( + '/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c', + ); + await expect(page).toHaveURL( + 'http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c', + ); + await expect(page.getByText('FIDO2 Test Form')).toBeVisible(); + + await page.getByRole('button', { name: 'USER_LOGIN' }).click(); + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Sign On' }).click(); + + // Register WebAuthn credential + const { credentials: intialCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + await expect(intialCredentials).toHaveLength(0); + + await page.getByRole('button', { name: 'DEVICE_REGISTRATION' }).click(); + await page.getByRole('button', { name: 'Biometrics/Security Key' }).click(); + await page.getByRole('button', { name: 'FIDO Register' }).click(); + + const { credentials: recordedCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + await expect(recordedCredentials).toHaveLength(1); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Verify we're back at home page if successful + await expect(page.getByText('FIDO2 Test Form')).toBeVisible(); + + // Authenticate with the registered WebAuthn credential + const initialSignCount = recordedCredentials[0].signCount; + + await page.getByRole('button', { name: 'DEVICE_AUTHENTICATION' }).click(); + await page.getByRole('button', { name: 'Biometrics/Security Key' }).last().click(); + await page.getByRole('button', { name: 'FIDO Authenticate' }).click(); + + const credentialsAfterAuth = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + await expect(credentialsAfterAuth.credentials).toHaveLength(1); + + // Signature counter should have incremented after successful authentication/assertion + await expect(credentialsAfterAuth.credentials[0].signCount).toBeGreaterThan(initialSignCount); + + // Verify we're back at home page if successful + await expect(page.getByText('FIDO2 Test Form')).toBeVisible(); + }); + + test('Register and authenticate with usernameless', async ({ page }) => { + const { navigate } = asyncEvents(page); + + await navigate( + '/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c', + ); + await expect(page).toHaveURL( + 'http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c', + ); + await expect(page.getByText('FIDO2 Test Form')).toBeVisible(); + + await page.getByRole('button', { name: 'USER_LOGIN' }).click(); + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Sign On' }).click(); + + // Register WebAuthn credential + const { credentials: intialCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + await expect(intialCredentials).toHaveLength(0); + + await page.getByRole('button', { name: 'DEVICE_REGISTRATION' }).click(); + await page.getByRole('button', { name: 'Biometrics/Security Key' }).click(); + await page.getByRole('button', { name: 'FIDO Register' }).click(); + + const { credentials: recordedCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + await expect(recordedCredentials).toHaveLength(1); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Verify we're back at home page if successful + await expect(page.getByText('FIDO2 Test Form')).toBeVisible(); + + // Authenticate with the registered WebAuthn credential + const initialSignCount = recordedCredentials[0].signCount; + + await page.getByRole('button', { name: 'USER_NAMELESS' }).click(); + await expect(page.getByText('FIDO2 Authentication')).toBeVisible(); + await page.getByRole('button', { name: 'FIDO Authenticate' }).click(); + + const credentialsAfterAuth = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + await expect(credentialsAfterAuth.credentials).toHaveLength(1); + + // Signature counter should have incremented after successful authentication/assertion + await expect(credentialsAfterAuth.credentials[0].signCount).toBeGreaterThan(initialSignCount); + + // Verify we're back at home page if successful + await expect(page.getByText('FIDO2 Test Form')).toBeVisible(); + }); +}); From 7834cde01f4537492d38251165c557320ac1fd85 Mon Sep 17 00:00:00 2001 From: AJ Ancheta <7781450+ancheetah@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:46:53 -0500 Subject: [PATCH 2/2] fix(davinci-client): improve fido module error handling --- .changeset/full-bikes-boil.md | 5 +++++ e2e/davinci-app/components/fido.ts | 4 ++-- e2e/davinci-app/main.ts | 4 +--- e2e/davinci-suites/src/fido.test.ts | 12 +++++++----- packages/davinci-client/src/lib/fido/fido.ts | 16 ++++++++++++++++ 5 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 .changeset/full-bikes-boil.md diff --git a/.changeset/full-bikes-boil.md b/.changeset/full-bikes-boil.md new file mode 100644 index 0000000000..79a7db0507 --- /dev/null +++ b/.changeset/full-bikes-boil.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': patch +--- + +Improve FIDO module error handling when no options diff --git a/e2e/davinci-app/components/fido.ts b/e2e/davinci-app/components/fido.ts index e7ad36c815..37fd16cf02 100644 --- a/e2e/davinci-app/components/fido.ts +++ b/e2e/davinci-app/components/fido.ts @@ -4,20 +4,20 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ +import { fido } from '@forgerock/davinci-client'; import type { FidoRegistrationCollector, FidoAuthenticationCollector, Updater, - FidoClient, } from '@forgerock/davinci-client/types'; export default function fidoComponent( formEl: HTMLFormElement, collector: FidoRegistrationCollector | FidoAuthenticationCollector, updater: Updater, - fidoApi: FidoClient, submitForm: () => Promise, ) { + const fidoApi = fido(); if (collector.type === 'FidoRegistrationCollector') { const button = document.createElement('button'); button.type = 'button'; diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index b3774d3b3b..e1048cd37a 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -7,7 +7,7 @@ import './style.css'; import { Config, FRUser, TokenManager } from '@forgerock/javascript-sdk'; -import { davinci, fido } from '@forgerock/davinci-client'; +import { davinci } from '@forgerock/davinci-client'; import type { CustomLogger, DaVinciConfig, @@ -83,7 +83,6 @@ const urlParams = new URLSearchParams(window.location.search); (async () => { const davinciClient: DavinciClient = await davinci({ config, logger, requestMiddleware }); const protectApi = protect({ envId: '02fb4743-189a-4bc7-9d6c-a919edfe6447' }); - const fidoApi = fido(); const continueToken = urlParams.get('continueToken'); const formEl = document.getElementById('form') as HTMLFormElement; let resumed: InternalErrorResponse | NodeStates | undefined; @@ -261,7 +260,6 @@ const urlParams = new URLSearchParams(window.location.search); formEl, // You can ignore this; it's just for rendering collector, // This is the plain object of the collector davinciClient.update(collector), // Returns an update function for this collector - fidoApi, // FIDO module for interacting with WebAuthn API submitForm, ); } else if (collector.type === 'FlowCollector') { diff --git a/e2e/davinci-suites/src/fido.test.ts b/e2e/davinci-suites/src/fido.test.ts index ad679110ab..3252e9a165 100644 --- a/e2e/davinci-suites/src/fido.test.ts +++ b/e2e/davinci-suites/src/fido.test.ts @@ -49,10 +49,10 @@ test.describe('FIDO/WebAuthn Tests', () => { await page.getByRole('button', { name: 'Sign On' }).click(); // Register WebAuthn credential - const { credentials: intialCredentials } = await cdp.send('WebAuthn.getCredentials', { + const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId, }); - await expect(intialCredentials).toHaveLength(0); + await expect(initialCredentials).toHaveLength(0); await page.getByRole('button', { name: 'DEVICE_REGISTRATION' }).click(); await page.getByRole('button', { name: 'Biometrics/Security Key' }).click(); @@ -87,7 +87,9 @@ test.describe('FIDO/WebAuthn Tests', () => { await expect(page.getByText('FIDO2 Test Form')).toBeVisible(); }); - test('Register and authenticate with usernameless', async ({ page }) => { + // Note: This test is currently not working due to a DaVinci issue where the authentication options + // are not included in the response. + test.skip('Register and authenticate with usernameless', async ({ page }) => { const { navigate } = asyncEvents(page); await navigate( @@ -104,10 +106,10 @@ test.describe('FIDO/WebAuthn Tests', () => { await page.getByRole('button', { name: 'Sign On' }).click(); // Register WebAuthn credential - const { credentials: intialCredentials } = await cdp.send('WebAuthn.getCredentials', { + const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId, }); - await expect(intialCredentials).toHaveLength(0); + await expect(initialCredentials).toHaveLength(0); await page.getByRole('button', { name: 'DEVICE_REGISTRATION' }).click(); await page.getByRole('button', { name: 'Biometrics/Security Key' }).click(); diff --git a/packages/davinci-client/src/lib/fido/fido.ts b/packages/davinci-client/src/lib/fido/fido.ts index 3207343622..a48a9f63f9 100644 --- a/packages/davinci-client/src/lib/fido/fido.ts +++ b/packages/davinci-client/src/lib/fido/fido.ts @@ -55,6 +55,14 @@ export function fido(): FidoClient { register: async function register( options: FidoRegistrationOptions, ): Promise { + if (!options) { + return { + error: 'registration_error', + message: 'FIDO registration failed: No options available', + type: 'fido_error', + } as GenericError; + } + const createCredentialµ = Micro.sync(() => transformRegistrationOptions(options)).pipe( Micro.flatMap((publicKeyCredentialCreationOptions) => Micro.tryPromise({ @@ -108,6 +116,14 @@ export function fido(): FidoClient { authenticate: async function authenticate( options: FidoAuthenticationOptions, ): Promise { + if (!options) { + return { + error: 'authentication_error', + message: 'FIDO authentication failed: No options available', + type: 'fido_error', + } as GenericError; + } + const getAssertionµ = Micro.sync(() => transformAuthenticationOptions(options)).pipe( Micro.flatMap((publicKeyCredentialRequestOptions) => Micro.tryPromise({