Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/full-bikes-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/davinci-client': patch
---

Improve FIDO module error handling when no options
66 changes: 66 additions & 0 deletions e2e/davinci-app/components/fido.ts
Original file line number Diff line number Diff line change
@@ -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<FidoRegistrationCollector | FidoAuthenticationCollector>,
submitForm: () => Promise<void>,
) {
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();
}
}
};
}
}
17 changes: 14 additions & 3 deletions e2e/davinci-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand Down Expand Up @@ -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();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for finding these ❤️

if (error?.error) {
console.error('Error starting Protect:', error.error);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions e2e/davinci-suites/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const config: PlaywrightTestConfig = {
cwd: workspaceRoot,
},
].filter(Boolean),
testIgnore: '**/fido.test.ts',
};

export default config;
146 changes: 146 additions & 0 deletions e2e/davinci-suites/src/fido.test.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know if we are going to have the same issue with DaVinci as we did with AM? The too many credentials issue, that is.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Responded to this via slack, but for posterity

Technically yes to Justin's question, but our solution we discussed was to not include these tests as part of our ci directly and to use the playwright grep configuration so we can run virtual Authenticator tests manually
Like playwright test —grep “*.webauthn” and playwright only picks up those file types and we can alias it into a command
This circumvents the idea of ci failing consistently
We of course eventually could hit the error because it's a browser restriction and we would have to change the user or delete the devices but the tests themselves can hold value and not run all the time until we have a better way to delete devices automatically

Original file line number Diff line number Diff line change
@@ -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();
});
});
16 changes: 16 additions & 0 deletions packages/davinci-client/src/lib/fido/fido.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export function fido(): FidoClient {
register: async function register(
options: FidoRegistrationOptions,
): Promise<FidoRegistrationInputValue | GenericError> {
if (!options) {
return {
error: 'registration_error',
message: 'FIDO registration failed: No options available',
type: 'fido_error',
} as GenericError;
}

Comment on lines +58 to +65
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! It's good we caught this during our testing phase.

const createCredentialµ = Micro.sync(() => transformRegistrationOptions(options)).pipe(
Micro.flatMap((publicKeyCredentialCreationOptions) =>
Micro.tryPromise({
Expand Down Expand Up @@ -108,6 +116,14 @@ export function fido(): FidoClient {
authenticate: async function authenticate(
options: FidoAuthenticationOptions,
): Promise<FidoAuthenticationInputValue | GenericError> {
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({
Expand Down
Loading