Skip to content
Merged
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/thirty-badgers-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/davinci-client': minor
---

Catch FIDO/WebAuthn DOMExceptions and send them to DaVinci
26 changes: 13 additions & 13 deletions e2e/davinci-app/components/fido.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -32,13 +32,13 @@ export default function fidoComponent(
console.log('fido.register response:', response);
if ('error' in response) {
console.error(response);
}

const error = updater(response);
if (error && 'error' in error) {
console.error(error.error.message);
} else {
const error = updater(response);
if (error && 'error' in error) {
console.error(error.error.message);
} else {
await submitForm();
}
await submitForm();
}
};
} else if (collector.type === 'FidoAuthenticationCollector') {
Expand All @@ -54,13 +54,13 @@ export default function fidoComponent(
console.log('fido.authenticate response:', response);
if ('error' in response) {
console.error(response);
}

const error = updater(response);
if (error && 'error' in error) {
console.error(error.error.message);
} else {
const error = updater(response);
if (error && 'error' in error) {
console.error(error.error.message);
} else {
await submitForm();
}
await submitForm();
}
};
}
Expand Down
22 changes: 2 additions & 20 deletions e2e/davinci-app/server-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
},
/**
* Form Fields
* ValidatedPasswordCollector / Password Policy
*/
'e4ef2896-8d90-4abd-bf0f-7b8034995927': {
clientId: 'e4ef2896-8d90-4abd-bf0f-7b8034995927',
Expand Down Expand Up @@ -93,7 +94,7 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
},
},
/**
* AutoCollectors: Polling, Metadata
* AutoCollectors: Polling, Metadata, FIDO
*/
'31a587ce-9aa4-4f36-a09f-78cd8a0a74a0': {
clientId: '31a587ce-9aa4-4f36-a09f-78cd8a0a74a0',
Expand All @@ -104,23 +105,4 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
'https://auth.pingone.ca/356a254c-cba3-4ade-be1a-860136e8df01/as/.well-known/openid-configuration',
},
},
/**
* ValidatedPasswordCollector / Password Policy
* Flow: Andy - MFA Device Registration/Authentication (PingOne Forms)
* Policy ID (acr_values): 769eecb92f8e66f88005a85e8b939a01
* Environment: 356a254c-cba3-4ade-be1a-860136e8df01
*
* New client created 2026-05-28 with:
* - http://localhost:5829 in Redirect URIs
* - http://localhost:5829 in CORS Allowed Origins
*/
'fb456db5-2e08-46d3-adf0-05bf8d26ad60': {
clientId: 'fb456db5-2e08-46d3-adf0-05bf8d26ad60',
redirectUri: window.location.origin + '/',
scope: 'openid profile email revoke',
serverConfig: {
wellknown:
'https://auth.pingone.ca/356a254c-cba3-4ade-be1a-860136e8df01/as/.well-known/openid-configuration',
},
},
};
230 changes: 198 additions & 32 deletions e2e/davinci-suites/src/fido.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,62 @@
/*
* 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.
*/
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;

const clientId = '31a587ce-9aa4-4f36-a09f-78cd8a0a74a0';
const policyId = '3eff62cf953372519225d375fd200358';

test.use({ browserName: 'chromium' }); // ensure CDP/WebAuthn is available
test.describe.configure({ mode: 'serial' });

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.describe('FIDO/WebAuthn Success Tests', () => {
let cdp: CDPSession | undefined;
let authenticatorId: string | undefined;

test.afterEach(async () => {
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
await cdp.send('WebAuthn.disable');
});
test.beforeEach(async ({ context, page }) => {
if (authenticatorId) {
await cdp?.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
authenticatorId = undefined;
}

cdp = await context.newCDPSession(page);
await expect(cdp).toBeDefined();
await cdp.send('WebAuthn.enable');

test.describe('FIDO/WebAuthn Tests', () => {
// 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 () => {
if (authenticatorId) {
await cdp?.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
authenticatorId = undefined;
}
await cdp?.send('WebAuthn.disable');
});
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 navigate(`/?clientId=${clientId}&acr_values=${policyId}`);
await expect(page).toHaveURL(
'http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c',
`http://localhost:5829/?clientId=${clientId}&acr_values=${policyId}`,
);
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();

Expand All @@ -48,6 +65,10 @@ test.describe('FIDO/WebAuthn Tests', () => {
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign On' }).click();

if (!cdp || !authenticatorId) {
throw new Error('Missing virtual authenticator');
}

// Register WebAuthn credential
const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', {
authenticatorId,
Expand Down Expand Up @@ -90,11 +111,9 @@ test.describe('FIDO/WebAuthn Tests', () => {
test('Register and authenticate with usernameless', async ({ page }) => {
const { navigate } = asyncEvents(page);

await navigate(
'/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c',
);
await navigate(`/?clientId=${clientId}&acr_values=${policyId}`);
await expect(page).toHaveURL(
'http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c',
`http://localhost:5829/?clientId=${clientId}&acr_values=${policyId}`,
);
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();

Expand All @@ -103,6 +122,13 @@ test.describe('FIDO/WebAuthn Tests', () => {
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign On' }).click();

await expect(cdp).toBeDefined;
await expect(authenticatorId).toBeDefined();

if (!cdp || !authenticatorId) {
throw new Error('Missing virtual authenticator');
}

// Register WebAuthn credential
const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', {
authenticatorId,
Expand Down Expand Up @@ -142,3 +168,143 @@ test.describe('FIDO/WebAuthn Tests', () => {
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();
});
});

test.describe('FIDO/WebAuthn Error Tests', () => {
let cdp: CDPSession | undefined;
let authenticatorId: string | undefined;

test.beforeEach(async ({ context, page }) => {
if (authenticatorId) {
await cdp?.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
authenticatorId = undefined;
}

cdp = await context.newCDPSession(page);
await expect(cdp).toBeDefined();
await cdp.send('WebAuthn.enable');

// Starts with UV succeeding so setup steps (e.g. registering a device before testing
// an auth failure) work; individual tests call WebAuthn.setUserVerified(false) right
// before the operation that should fail, which Chromium surfaces as NotAllowedError —
// simulating the user canceling the prompt.
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) by default
automaticPresenceSimulation: true, // auto "touch"/presence
},
});
authenticatorId = response.authenticatorId;
});

test.afterEach(async () => {
if (authenticatorId) {
await cdp?.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
authenticatorId = undefined;
}
await cdp?.send('WebAuthn.disable');
});
test('Registration shows NotAllowedError when the WebAuthn prompt is canceled', async ({
page,
}) => {
const { navigate } = asyncEvents(page);

await navigate(`/?clientId=${clientId}&acr_values=${policyId}`);
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();

if (!cdp || !authenticatorId) {
throw new Error('Missing virtual authenticator');
}

await page.getByRole('button', { name: 'DEVICE_REGISTRATION' }).click();
await page.getByRole('button', { name: 'Biometrics/Security Key' }).click();

// Make user verification fail for this authenticator so the registration attempt is
// rejected with NotAllowedError — simulating the user canceling the prompt.
await cdp.send('WebAuthn.setUserVerified', { authenticatorId, isUserVerified: false });
await page.getByRole('button', { name: 'FIDO Register' }).click();

await expect(page.getByText('FIDO Registration Error - NotAllowedError')).toBeVisible();
await expect(page.getByRole('button', { name: 'FIDO Register' })).toBeVisible();
});

test('Device authentication shows NotAllowedError when the WebAuthn prompt is canceled', async ({
page,
}) => {
const { navigate } = asyncEvents(page);

await navigate(`/?clientId=${clientId}&acr_values=${policyId}`);
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();

if (!cdp || !authenticatorId) {
throw new Error('Missing virtual authenticator');
}

// Register a credential so there is a device to authenticate with.
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();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();

await page.getByRole('button', { name: 'DEVICE_AUTHENTICATION' }).click();
await page.getByRole('button', { name: 'Biometrics/Security Key' }).last().click();

// Make user verification fail for this authenticator so the assertion attempt is
// rejected with NotAllowedError — simulating the user canceling the prompt.
await cdp.send('WebAuthn.setUserVerified', { authenticatorId, isUserVerified: false });
await page.getByRole('button', { name: 'FIDO Authenticate' }).click();

await expect(page.getByText('FIDO Authentication Error - NotAllowedError')).toBeVisible();
await expect(page.getByRole('button', { name: 'FIDO Authenticate' })).toBeVisible();
});

test('Usernameless authentication shows NotAllowedError when the WebAuthn prompt is canceled', async ({
page,
}) => {
const { navigate } = asyncEvents(page);

await navigate(`/?clientId=${clientId}&acr_values=${policyId}`);
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();

if (!cdp || !authenticatorId) {
throw new Error('Missing virtual authenticator');
}

// Register a discoverable credential so there is a passkey to authenticate with.
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();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();

await page.getByRole('button', { name: 'USER_NAMELESS' }).click();
await expect(page.getByText('FIDO2 Authentication')).toBeVisible();

// Make user verification fail for this authenticator so the assertion attempt is
// rejected with NotAllowedError — simulating the user canceling the prompt.
await cdp.send('WebAuthn.setUserVerified', { authenticatorId, isUserVerified: false });
await page.getByRole('button', { name: 'FIDO Authenticate' }).click();

await expect(page.getByText('FIDO Usernameless Error - NotAllowedError')).toBeVisible();
await expect(page.getByRole('button', { name: 'FIDO Authenticate' })).toBeVisible();
});
});
Loading
Loading