Skip to content

Commit e624f97

Browse files
committed
test: add-virtual-authenticator-tests
1 parent 1e03985 commit e624f97

File tree

13 files changed

+444
-17
lines changed

13 files changed

+444
-17
lines changed

.zed/tasks.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,13 @@
6464
{
6565
"label": "oidc e2e ui",
6666
"command": "pnpm",
67-
"args": ["nx", "e2e", "@forgerock/oidc-suites", "--ui"],
67+
"args": ["nx", "e2e", "@forgerock/oidc-suites", "--ui", "--skipNxCache"],
6868
"cwd": "$ZED_WORKTREE_ROOT"
6969
},
7070
{
7171
"label": "protect-app e2e ui",
7272
"command": "pnpm",
73-
"args": ["nx", "e2e", "@forgerock/protect-suites", "--ui"],
73+
"args": ["nx", "e2e", "@forgerock/protect-suites", "--ui", "--skipNxCache"],
7474
"cwd": "$ZED_WORKTREE_ROOT",
7575
"reveal": "no_focus"
7676
},
@@ -80,5 +80,11 @@
8080
"args": ["release-local"],
8181
"cwd": "$ZED_WORKTREE_ROOT",
8282
"reveal": "no_focus"
83+
},
84+
{
85+
"label": "journey e2e ui",
86+
"command": "pnpm",
87+
"args": ["nx", "e2e", "@forgerock/journey-suites", "--ui", "--skipNxCache"],
88+
"cwd": "$ZED_WORKTREE_ROOT"
8389
}
8490
]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { JourneyStep } from '@forgerock/journey-client/types';
2+
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';
3+
4+
export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep, idx: number) {
5+
const container = document.createElement('div');
6+
container.id = `webauthn-container-${idx}`;
7+
const info = document.createElement('p');
8+
info.innerText = 'Please complete the WebAuthn challenge using your authenticator.';
9+
container.appendChild(info);
10+
journeyEl.appendChild(container);
11+
12+
const webAuthnStepType = WebAuthn.getWebAuthnStepType(step);
13+
14+
async function handleWebAuthn() {
15+
try {
16+
if (webAuthnStepType === WebAuthnStepType.Authentication) {
17+
console.log('trying authentication');
18+
await WebAuthn.authenticate(step);
19+
console.log('trying registration');
20+
} else if (WebAuthnStepType.Registration === webAuthnStepType) {
21+
await WebAuthn.register(step);
22+
} else {
23+
return Promise.resolve(undefined);
24+
}
25+
} catch (error) {
26+
console.error('WebAuthn error:', error);
27+
}
28+
}
29+
30+
return handleWebAuthn();
31+
}

e2e/journey-app/main.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,22 @@ import './style.css';
99
import { journey } from '@forgerock/journey-client';
1010

1111
import type {
12-
RequestMiddleware,
1312
NameCallback,
1413
PasswordCallback,
14+
RequestMiddleware,
1515
} from '@forgerock/journey-client/types';
1616

17-
import textComponent from './components/text.js';
1817
import passwordComponent from './components/password.js';
18+
import textComponent from './components/text.js';
1919
import { serverConfigs } from './server-configs.js';
20+
import { webauthnComponent } from './components/webauthn.js';
21+
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';
2022

2123
const qs = window.location.search;
2224
const searchParams = new URLSearchParams(qs);
2325

24-
const config = serverConfigs[searchParams.get('clientId') || 'basic'];
26+
const journeyName = searchParams.get('clientId') || 'basic';
27+
const config = serverConfigs[journeyName];
2528

2629
const requestMiddleware: RequestMiddleware[] = [
2730
(req, action, next) => {
@@ -48,7 +51,7 @@ const requestMiddleware: RequestMiddleware[] = [
4851
const formEl = document.getElementById('form') as HTMLFormElement;
4952
const journeyEl = document.getElementById('journey') as HTMLDivElement;
5053

51-
let step = await journeyClient.start();
54+
let step = await journeyClient.start({ ...config, journey: journeyName });
5255

5356
function renderComplete() {
5457
if (step?.type !== 'LoginSuccess') {
@@ -103,9 +106,30 @@ const requestMiddleware: RequestMiddleware[] = [
103106
header.innerText = formName || '';
104107
journeyEl.appendChild(header);
105108

106-
const callbacks = step.callbacks;
109+
const webAuthnStep = WebAuthn.getWebAuthnStepType(step);
110+
111+
if (
112+
webAuthnStep === WebAuthnStepType.Authentication ||
113+
webAuthnStep === WebAuthnStepType.Registration
114+
) {
115+
await webauthnComponent(journeyEl, step, 0);
116+
step = await journeyClient.next(step);
117+
if (step?.type === 'Step') {
118+
await renderForm();
119+
} else if (step?.type === 'LoginSuccess') {
120+
console.log('Basic login successful');
121+
renderComplete();
122+
} else if (step?.type === 'LoginFailure') {
123+
renderForm();
124+
renderError();
125+
} else {
126+
console.error('Unknown node status', step);
127+
}
128+
return; // prevent the rest of the function from running
129+
}
107130

108-
callbacks.forEach((callback, idx) => {
131+
const callbacks = step.callbacks;
132+
callbacks.forEach(async (callback, idx) => {
109133
if (callback.getType() === 'NameCallback') {
110134
const cb = callback as NameCallback;
111135
textComponent(
@@ -146,7 +170,7 @@ const requestMiddleware: RequestMiddleware[] = [
146170
* Recursively render the form with the new state
147171
*/
148172
if (step?.type === 'Step') {
149-
renderForm();
173+
await renderForm();
150174
} else if (step?.type === 'LoginSuccess') {
151175
console.log('Basic login successful');
152176
renderComplete();

e2e/journey-app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"dependencies": {
1717
"@forgerock/journey-client": "workspace:*",
1818
"@forgerock/oidc-client": "workspace:*",
19-
"@forgerock/sdk-logger": "workspace:*"
19+
"@forgerock/sdk-logger": "workspace:*",
20+
"@forgerock/sdk-types": "workspace:*"
2021
}
2122
}

e2e/journey-app/server-configs.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,34 @@ export const serverConfigs: Record<string, JourneyClientConfig> = {
1313
},
1414
realmPath: '/alpha',
1515
},
16+
['TEST_WebAuthn-Registration']: {
17+
serverConfig: {
18+
baseUrl: 'https://openam-sdks.forgeblocks.com/am/',
19+
},
20+
realmPath: '/alpha',
21+
},
22+
['TEST_WebAuthnAuthentication_UsernamePassword']: {
23+
serverConfig: {
24+
baseUrl: 'https://openam-sdks.forgeblocks.com/am/',
25+
},
26+
realmPath: '/alpha',
27+
},
28+
['TEST_WebAuthnAuthentication']: {
29+
serverConfig: {
30+
baseUrl: 'https://openam-sdks.forgeblocks.com/am/',
31+
},
32+
realmPath: '/alpha',
33+
},
34+
['TEST_WebAuthn-Registration-UsernameToDevice']: {
35+
serverConfig: {
36+
baseUrl: 'https://openam-sdks.forgeblocks.com/am/',
37+
},
38+
realmPath: '/alpha',
39+
},
40+
['TEST_WebAuthnAuthentication_Usernameless']: {
41+
serverConfig: {
42+
baseUrl: 'https://openam-sdks.forgeblocks.com/am/',
43+
},
44+
realmPath: '/alpha',
45+
},
1646
};

e2e/journey-app/tsconfig.app.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
{
1919
"path": "../../packages/oidc-client/tsconfig.lib.json"
2020
},
21+
{
22+
"path": "../../packages/sdk-types/tsconfig.lib.json"
23+
},
2124
{
2225
"path": "../../packages/journey-client/tsconfig.lib.json"
2326
}

e2e/journey-app/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
{
2121
"path": "../../packages/oidc-client"
2222
},
23+
{
24+
"path": "../../packages/sdk-types"
25+
},
2326
{
2427
"path": "../../packages/journey-client"
2528
},

e2e/journey-suites/playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const config: PlaywrightTestConfig = {
2626
webServer: [
2727
process.env.CI == 'false'
2828
? {
29-
command: 'pnpm watch @forgerock/journey-app',
29+
command: 'pnpm nx vite:watch-deps @forgerock/journey-app',
3030
port: 5829,
3131
ignoreHTTPSErrors: true,
3232
reuseExistingServer: !process.env.CI,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { test, expect } from '@playwright/test';
2+
import type { CDPSession } from 'playwright';
3+
import { asyncEvents } from './utils/async-events.js';
4+
import { password, username } from './utils/demo-user.js';
5+
6+
test.use({ browserName: 'chromium' }); // ensure CDP/WebAuthn is available
7+
8+
test('Register and authenticate with webauthn device (username + password journey)', async ({
9+
page,
10+
context,
11+
}) => {
12+
let cdp: CDPSession | undefined;
13+
let authenticatorId: string | undefined;
14+
let webauthnEnabled = false;
15+
let recordedCredentialIds: string[] = [];
16+
17+
await test.step('Configure virtual authenticator', async () => {
18+
cdp = await context.newCDPSession(page);
19+
await cdp.send('WebAuthn.enable');
20+
webauthnEnabled = true;
21+
22+
// A "platform" authenticator (aka internal) with UV+RK enabled is the usual default for passkeys.
23+
const response = await cdp.send('WebAuthn.addVirtualAuthenticator', {
24+
options: {
25+
protocol: 'ctap2',
26+
transport: 'internal', // platform authenticator
27+
hasResidentKey: true, // allow discoverable credentials (passkeys)
28+
hasUserVerification: true, // device supports UV
29+
isUserVerified: true, // simulate successful UV (PIN/biometric)
30+
automaticPresenceSimulation: true, // auto "touch"/presence
31+
},
32+
});
33+
authenticatorId = response.authenticatorId;
34+
});
35+
36+
const { navigate } = asyncEvents(page);
37+
38+
try {
39+
// First, register a credential we can use later
40+
await test.step('Navigate to registration journey', async () => {
41+
await navigate('/?clientId=TEST_WebAuthn-Registration');
42+
await expect(page).toHaveURL('http://localhost:5829/?clientId=TEST_WebAuthn-Registration');
43+
});
44+
45+
await test.step('Complete primary credentials', async () => {
46+
await page.getByLabel('User Name').fill(username);
47+
await page.getByLabel('Password').fill(password);
48+
await page.getByRole('button', { name: 'Submit' }).click();
49+
});
50+
51+
await test.step('Register WebAuthn credential', async () => {
52+
// With the virtual authenticator present and presence auto-simulated,
53+
// registration will complete without any OS prompts.
54+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
55+
});
56+
57+
await test.step('Capture virtual authenticator credentials', async () => {
58+
if (!cdp || !authenticatorId) {
59+
throw new Error('Expected CDP session and authenticator to be configured');
60+
}
61+
const { credentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
62+
recordedCredentialIds = (credentials || []).map((item) => item.credentialId);
63+
expect(recordedCredentialIds.length).toBeGreaterThan(0);
64+
});
65+
66+
await test.step('Logout after registration', async () => {
67+
await page.getByRole('button', { name: 'Logout' }).click();
68+
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
69+
});
70+
71+
// Now authenticate via Username -> Password -> WebAuthn
72+
await test.step('Navigate to username+password authentication journey', async () => {
73+
await navigate('/?clientId=TEST_WebAuthnAuthentication_UsernamePassword');
74+
await expect(page).toHaveURL(
75+
'http://localhost:5829/?clientId=TEST_WebAuthnAuthentication_UsernamePassword',
76+
);
77+
});
78+
79+
await test.step('Complete username credential', async () => {
80+
await page.getByLabel('User Name').fill(username);
81+
await page.getByRole('button', { name: 'Submit' }).click();
82+
});
83+
84+
await test.step('Complete password credential', async () => {
85+
await page.getByLabel('Password').fill(password);
86+
await page.getByRole('button', { name: 'Submit' }).click();
87+
});
88+
89+
await test.step('Authenticate WebAuthn credential', async () => {
90+
if (!cdp || !authenticatorId) {
91+
throw new Error('Expected CDP session and authenticator to be configured');
92+
}
93+
// Server has UV set to Discouraged; providing UV is fine.
94+
await cdp.send('WebAuthn.setUserVerified', { authenticatorId, isUserVerified: true });
95+
// With the virtual authenticator present and presence auto-simulated,
96+
// authentication will complete without any OS prompts.
97+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
98+
});
99+
100+
await test.step('Logout after authentication', async () => {
101+
await page.getByRole('button', { name: 'Logout' }).click();
102+
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
103+
});
104+
} finally {
105+
if (!cdp) {
106+
return;
107+
}
108+
109+
const activeCdp = cdp;
110+
await test.step('Remove virtual authenticator', async () => {
111+
if (authenticatorId) {
112+
await activeCdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
113+
}
114+
if (webauthnEnabled) {
115+
await activeCdp.send('WebAuthn.disable');
116+
}
117+
});
118+
}
119+
});

0 commit comments

Comments
 (0)