Skip to content

Commit 014e22d

Browse files
authored
Merge pull request #504 from ForgeRock/SDKS-4522-virtual-auth-e2e
test(davinci-client): virtual authenticator e2e tests
2 parents af4bf4c + 7834cde commit 014e22d

File tree

6 files changed

+248
-3
lines changed

6 files changed

+248
-3
lines changed

.changeset/full-bikes-boil.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/davinci-client': patch
3+
---
4+
5+
Improve FIDO module error handling when no options

e2e/davinci-app/components/fido.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
import { fido } from '@forgerock/davinci-client';
8+
import type {
9+
FidoRegistrationCollector,
10+
FidoAuthenticationCollector,
11+
Updater,
12+
} from '@forgerock/davinci-client/types';
13+
14+
export default function fidoComponent(
15+
formEl: HTMLFormElement,
16+
collector: FidoRegistrationCollector | FidoAuthenticationCollector,
17+
updater: Updater<FidoRegistrationCollector | FidoAuthenticationCollector>,
18+
submitForm: () => Promise<void>,
19+
) {
20+
const fidoApi = fido();
21+
if (collector.type === 'FidoRegistrationCollector') {
22+
const button = document.createElement('button');
23+
button.type = 'button';
24+
button.value = collector.output.key;
25+
button.innerHTML = 'FIDO Register';
26+
formEl.appendChild(button);
27+
28+
button.onclick = async () => {
29+
const credentialOptions = collector.output.config.publicKeyCredentialCreationOptions;
30+
const response = await fidoApi.register(credentialOptions);
31+
console.log('fido.register response:', response);
32+
if ('error' in response) {
33+
console.error(response);
34+
} else {
35+
const error = updater(response);
36+
if (error && 'error' in error) {
37+
console.error(error.error.message);
38+
} else {
39+
await submitForm();
40+
}
41+
}
42+
};
43+
} else if (collector.type === 'FidoAuthenticationCollector') {
44+
const button = document.createElement('button');
45+
button.type = 'button';
46+
button.value = collector.output.key;
47+
button.innerHTML = 'FIDO Authenticate';
48+
formEl.appendChild(button);
49+
50+
button.onclick = async () => {
51+
const credentialOptions = collector.output.config.publicKeyCredentialRequestOptions;
52+
const response = await fidoApi.authenticate(credentialOptions);
53+
console.log('fido.authenticate response:', response);
54+
if ('error' in response) {
55+
console.error(response);
56+
} else {
57+
const error = updater(response);
58+
if (error && 'error' in error) {
59+
console.error(error.error.message);
60+
} else {
61+
await submitForm();
62+
}
63+
}
64+
};
65+
}
66+
}

e2e/davinci-app/main.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import singleValueComponent from './components/single-value.js';
3131
import multiValueComponent from './components/multi-value.js';
3232
import labelComponent from './components/label.js';
3333
import objectValueComponent from './components/object-value.js';
34+
import fidoComponent from './components/fido.js';
3435

3536
const loggerFn = {
3637
error: () => {
@@ -81,13 +82,13 @@ const urlParams = new URLSearchParams(window.location.search);
8182

8283
(async () => {
8384
const davinciClient: DavinciClient = await davinci({ config, logger, requestMiddleware });
84-
const protectAPI = protect({ envId: '02fb4743-189a-4bc7-9d6c-a919edfe6447' });
85+
const protectApi = protect({ envId: '02fb4743-189a-4bc7-9d6c-a919edfe6447' });
8586
const continueToken = urlParams.get('continueToken');
8687
const formEl = document.getElementById('form') as HTMLFormElement;
8788
let resumed: InternalErrorResponse | NodeStates | undefined;
8889

8990
// Initialize Protect
90-
const error = await protectAPI.start();
91+
const error = await protectApi.start();
9192
if (error?.error) {
9293
console.error('Error starting Protect:', error.error);
9394
}
@@ -251,6 +252,16 @@ const urlParams = new URLSearchParams(window.location.search);
251252
);
252253
} else if (collector.type === 'IdpCollector') {
253254
socialLoginButtonComponent(formEl, collector, davinciClient.externalIdp());
255+
} else if (
256+
collector.type === 'FidoRegistrationCollector' ||
257+
collector.type === 'FidoAuthenticationCollector'
258+
) {
259+
fidoComponent(
260+
formEl, // You can ignore this; it's just for rendering
261+
collector, // This is the plain object of the collector
262+
davinciClient.update(collector), // Returns an update function for this collector
263+
submitForm,
264+
);
254265
} else if (collector.type === 'FlowCollector') {
255266
flowLinkComponent(
256267
formEl, // You can ignore this; it's just for rendering
@@ -278,7 +289,7 @@ const urlParams = new URLSearchParams(window.location.search);
278289
}
279290

280291
async function updateProtectCollector(protectCollector: ProtectCollector) {
281-
const data = await protectAPI.getData();
292+
const data = await protectApi.getData();
282293
if (typeof data !== 'string' && 'error' in data) {
283294
console.error(`Failed to retrieve data from PingOne Protect: ${data.error}`);
284295
return;

e2e/davinci-suites/playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const config: PlaywrightTestConfig = {
4141
cwd: workspaceRoot,
4242
},
4343
].filter(Boolean),
44+
testIgnore: '**/fido.test.ts',
4445
};
4546

4647
export default config;
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { test, expect, CDPSession } from '@playwright/test';
2+
import { asyncEvents } from './utils/async-events.js';
3+
4+
const username = 'JSFidoUser@user.com';
5+
const password = 'FakePassword#123';
6+
let cdp: CDPSession | undefined;
7+
let authenticatorId: string | undefined;
8+
9+
test.use({ browserName: 'chromium' }); // ensure CDP/WebAuthn is available
10+
11+
test.beforeEach(async ({ context, page }) => {
12+
cdp = await context.newCDPSession(page);
13+
await cdp.send('WebAuthn.enable');
14+
15+
// A "platform" authenticator (aka internal) with UV+RK enabled is the usual default for passkeys.
16+
const response = await cdp.send('WebAuthn.addVirtualAuthenticator', {
17+
options: {
18+
protocol: 'ctap2',
19+
transport: 'internal', // platform authenticator
20+
hasResidentKey: true, // allow discoverable credentials (passkeys)
21+
hasUserVerification: true, // device supports UV
22+
isUserVerified: true, // simulate successful UV (PIN/biometric)
23+
automaticPresenceSimulation: true, // auto "touch"/presence
24+
},
25+
});
26+
authenticatorId = response.authenticatorId;
27+
});
28+
29+
test.afterEach(async () => {
30+
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
31+
await cdp.send('WebAuthn.disable');
32+
});
33+
34+
test.describe('FIDO/WebAuthn Tests', () => {
35+
test('Register and authenticate with webauthn device', async ({ page }) => {
36+
const { navigate } = asyncEvents(page);
37+
38+
await navigate(
39+
'/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c',
40+
);
41+
await expect(page).toHaveURL(
42+
'http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c',
43+
);
44+
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();
45+
46+
await page.getByRole('button', { name: 'USER_LOGIN' }).click();
47+
await page.getByLabel('Username').fill(username);
48+
await page.getByLabel('Password').fill(password);
49+
await page.getByRole('button', { name: 'Sign On' }).click();
50+
51+
// Register WebAuthn credential
52+
const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', {
53+
authenticatorId,
54+
});
55+
await expect(initialCredentials).toHaveLength(0);
56+
57+
await page.getByRole('button', { name: 'DEVICE_REGISTRATION' }).click();
58+
await page.getByRole('button', { name: 'Biometrics/Security Key' }).click();
59+
await page.getByRole('button', { name: 'FIDO Register' }).click();
60+
61+
const { credentials: recordedCredentials } = await cdp.send('WebAuthn.getCredentials', {
62+
authenticatorId,
63+
});
64+
await expect(recordedCredentials).toHaveLength(1);
65+
66+
await page.getByRole('button', { name: 'Continue' }).click();
67+
68+
// Verify we're back at home page if successful
69+
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();
70+
71+
// Authenticate with the registered WebAuthn credential
72+
const initialSignCount = recordedCredentials[0].signCount;
73+
74+
await page.getByRole('button', { name: 'DEVICE_AUTHENTICATION' }).click();
75+
await page.getByRole('button', { name: 'Biometrics/Security Key' }).last().click();
76+
await page.getByRole('button', { name: 'FIDO Authenticate' }).click();
77+
78+
const credentialsAfterAuth = await cdp.send('WebAuthn.getCredentials', {
79+
authenticatorId,
80+
});
81+
await expect(credentialsAfterAuth.credentials).toHaveLength(1);
82+
83+
// Signature counter should have incremented after successful authentication/assertion
84+
await expect(credentialsAfterAuth.credentials[0].signCount).toBeGreaterThan(initialSignCount);
85+
86+
// Verify we're back at home page if successful
87+
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();
88+
});
89+
90+
// Note: This test is currently not working due to a DaVinci issue where the authentication options
91+
// are not included in the response.
92+
test.skip('Register and authenticate with usernameless', async ({ page }) => {
93+
const { navigate } = asyncEvents(page);
94+
95+
await navigate(
96+
'/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c',
97+
);
98+
await expect(page).toHaveURL(
99+
'http://localhost:5829/?clientId=20dd0ed0-bb9b-4c8f-9a60-9ebeb4b348e0&acr_values=98f2c058aae71ec09eb268db6810ff3c',
100+
);
101+
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();
102+
103+
await page.getByRole('button', { name: 'USER_LOGIN' }).click();
104+
await page.getByLabel('Username').fill(username);
105+
await page.getByLabel('Password').fill(password);
106+
await page.getByRole('button', { name: 'Sign On' }).click();
107+
108+
// Register WebAuthn credential
109+
const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', {
110+
authenticatorId,
111+
});
112+
await expect(initialCredentials).toHaveLength(0);
113+
114+
await page.getByRole('button', { name: 'DEVICE_REGISTRATION' }).click();
115+
await page.getByRole('button', { name: 'Biometrics/Security Key' }).click();
116+
await page.getByRole('button', { name: 'FIDO Register' }).click();
117+
118+
const { credentials: recordedCredentials } = await cdp.send('WebAuthn.getCredentials', {
119+
authenticatorId,
120+
});
121+
await expect(recordedCredentials).toHaveLength(1);
122+
123+
await page.getByRole('button', { name: 'Continue' }).click();
124+
125+
// Verify we're back at home page if successful
126+
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();
127+
128+
// Authenticate with the registered WebAuthn credential
129+
const initialSignCount = recordedCredentials[0].signCount;
130+
131+
await page.getByRole('button', { name: 'USER_NAMELESS' }).click();
132+
await expect(page.getByText('FIDO2 Authentication')).toBeVisible();
133+
await page.getByRole('button', { name: 'FIDO Authenticate' }).click();
134+
135+
const credentialsAfterAuth = await cdp.send('WebAuthn.getCredentials', {
136+
authenticatorId,
137+
});
138+
await expect(credentialsAfterAuth.credentials).toHaveLength(1);
139+
140+
// Signature counter should have incremented after successful authentication/assertion
141+
await expect(credentialsAfterAuth.credentials[0].signCount).toBeGreaterThan(initialSignCount);
142+
143+
// Verify we're back at home page if successful
144+
await expect(page.getByText('FIDO2 Test Form')).toBeVisible();
145+
});
146+
});

packages/davinci-client/src/lib/fido/fido.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ export function fido(): FidoClient {
5555
register: async function register(
5656
options: FidoRegistrationOptions,
5757
): Promise<FidoRegistrationInputValue | GenericError> {
58+
if (!options) {
59+
return {
60+
error: 'registration_error',
61+
message: 'FIDO registration failed: No options available',
62+
type: 'fido_error',
63+
} as GenericError;
64+
}
65+
5866
const createCredentialµ = Micro.sync(() => transformRegistrationOptions(options)).pipe(
5967
Micro.flatMap((publicKeyCredentialCreationOptions) =>
6068
Micro.tryPromise({
@@ -108,6 +116,14 @@ export function fido(): FidoClient {
108116
authenticate: async function authenticate(
109117
options: FidoAuthenticationOptions,
110118
): Promise<FidoAuthenticationInputValue | GenericError> {
119+
if (!options) {
120+
return {
121+
error: 'authentication_error',
122+
message: 'FIDO authentication failed: No options available',
123+
type: 'fido_error',
124+
} as GenericError;
125+
}
126+
111127
const getAssertionµ = Micro.sync(() => transformAuthenticationOptions(options)).pipe(
112128
Micro.flatMap((publicKeyCredentialRequestOptions) =>
113129
Micro.tryPromise({

0 commit comments

Comments
 (0)