diff --git a/.changeset/full-bikes-boil.md b/.changeset/full-bikes-boil.md new file mode 100644 index 000000000..79a7db050 --- /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 new file mode 100644 index 000000000..37fd16cf0 --- /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 { fido } from '@forgerock/davinci-client'; +import type { + FidoRegistrationCollector, + FidoAuthenticationCollector, + Updater, +} from '@forgerock/davinci-client/types'; + +export default function fidoComponent( + formEl: HTMLFormElement, + collector: FidoRegistrationCollector | FidoAuthenticationCollector, + updater: Updater, + submitForm: () => Promise, +) { + const fidoApi = fido(); + 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 fb61ccb55..e1048cd37 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -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,13 @@ 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 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 +252,16 @@ 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 + submitForm, + ); } else if (collector.type === 'FlowCollector') { flowLinkComponent( formEl, // You can ignore this; it's just for rendering @@ -278,7 +289,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 65484789d..4c3393bcc 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 000000000..3252e9a16 --- /dev/null +++ b/e2e/davinci-suites/src/fido.test.ts @@ -0,0 +1,146 @@ +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: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + await expect(initialCredentials).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(); + }); + + // 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( + '/?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: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + await expect(initialCredentials).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(); + }); +}); diff --git a/packages/davinci-client/src/lib/fido/fido.ts b/packages/davinci-client/src/lib/fido/fido.ts index 320734362..a48a9f63f 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({