Skip to content

Commit 7c19dea

Browse files
committed
feat(journey-client): add hasAuthenticationButton and hasPasskeyAutocompleteValues to WebAuthn
1 parent 2f91101 commit 7c19dea

9 files changed

Lines changed: 448 additions & 96 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/journey-client': minor
3+
---
4+
5+
Add an optional `mediationOverride` parameter to `WebAuthn.authenticate()`. When provided, it takes precedence over the mediation value in the step metadata, letting a manual "Sign in with a passkey" button force a modal (non-conditional) prompt even when the server requested conditional mediation.

e2e/journey-app/components/text-input.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ export default function textComponent(
2121
input.id = collectorKey;
2222
input.name = collectorKey;
2323

24-
if (callback.getType() === 'NameCallback') {
25-
input.setAttribute('autocomplete', 'webauthn');
26-
}
27-
2824
journeyEl?.appendChild(label);
2925
journeyEl?.appendChild(input);
3026

e2e/journey-app/components/webauthn-step.ts

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -57,42 +57,49 @@ export async function handleWebAuthnStep(
5757
const webAuthnStep = WebAuthn.getWebAuthnStepType(step);
5858

5959
if (webAuthnStep === WebAuthnStepType.Authentication) {
60-
// For conditional mediation, we need an input with `autocomplete="webauthn"` to exist.
6160
renderCallbacks(journeyEl, callbacks, submitForm);
6261

63-
const conditionalInput = journeyEl.querySelector(
64-
'input[autocomplete="webauthn"]',
65-
) as HTMLInputElement | null;
66-
conditionalInput?.focus();
67-
6862
const isConditionalSupported = await WebAuthn.isConditionalMediationSupported();
6963

70-
const metadataCallback = WebAuthn.getMetadataCallback(step);
71-
const meta = metadataCallback?.getData<{
72-
mediation?: CredentialMediationRequirement;
73-
conditional?: boolean;
74-
}>();
75-
const isConditionalMediation = meta?.mediation === 'conditional' || meta?.conditional === true;
76-
77-
if (isConditionalSupported && conditionalInput && isConditionalMediation) {
78-
const controller = new AbortController();
79-
void WebAuthn.authenticate(step, controller.signal)
80-
.then(() => submitForm())
81-
.catch(() => {
82-
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
64+
// Fire WebAuthn without awaiting so handleWebAuthnStep returns and main.ts can render the
65+
// Submit button (traditional login stays available in every case). The SDK decides silent
66+
// (conditional mediation) vs. modal popup internally from meta.mediation — the app doesn't.
67+
const controller = new AbortController();
68+
void WebAuthn.authenticate(step, controller.signal)
69+
.then(() => submitForm())
70+
.catch(() => {
71+
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
72+
});
73+
74+
// hasPasskeyAutocompleteValues reflects the AM admin's decision to emit username+webauthn
75+
// autocomplete values on the NameCallback — the signal that this step is a passkey-autofill
76+
// step. Only then do we decorate the username input and render the passkey button.
77+
if (isConditionalSupported && WebAuthn.hasPasskeyAutocompleteValues(step)) {
78+
journeyEl.querySelectorAll('input[type="text"]').forEach((input) => {
79+
input.setAttribute('autocomplete', 'username webauthn');
80+
});
81+
82+
// Render the passkey authentication button if AM has enabled it.
83+
if (WebAuthn.hasAuthenticationButton(step)) {
84+
const button = document.createElement('button');
85+
button.type = 'button';
86+
button.textContent = 'Sign in with a passkey';
87+
button.addEventListener('click', () => {
88+
// Cancel the background conditional request and force a modal prompt by overriding
89+
// mediation to 'optional' — the server requested 'conditional' (silent) mediation.
90+
controller.abort();
91+
void WebAuthn.authenticate(step, undefined, 'optional')
92+
.then(() => submitForm())
93+
.catch(() => {
94+
setError(
95+
'WebAuthn failed or was cancelled. Please try again or use a different method.',
96+
);
97+
});
8398
});
84-
85-
return { callbacksRendered: true, didSubmit: false };
86-
}
87-
88-
// Fallback to the traditional (prompted) WebAuthn flow.
89-
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
90-
if (webAuthnSuccess) {
91-
submitForm();
92-
return { callbacksRendered: true, didSubmit: true };
99+
journeyEl.appendChild(button);
100+
}
93101
}
94102

95-
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
96103
return { callbacksRendered: true, didSubmit: false };
97104
}
98105

e2e/journey-suites/src/webauthn-device.test.ts

Lines changed: 216 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import { expect, test } from '@playwright/test';
9-
import type { CDPSession } from '@playwright/test';
9+
import type { CDPSession, Page } from '@playwright/test';
1010
import { asyncEvents } from './utils/async-events.js';
1111
import { username, password } from './utils/demo-user.js';
1212

@@ -113,11 +113,9 @@ test.describe('WebAuthn conditional autofill (passkey)', () => {
113113
let authenticatorId!: string;
114114

115115
test.beforeEach(async ({ context, page }) => {
116-
// Chromium + CDP WebAuthn virtual authenticator is required for repeatable automation.
117116
cdp = await context.newCDPSession(page);
118117
await cdp.send('WebAuthn.enable');
119118

120-
// Configure a platform authenticator with resident keys and auto presence simulation.
121119
const response = await cdp.send('WebAuthn.addVirtualAuthenticator', {
122120
options: {
123121
protocol: 'ctap2',
@@ -136,54 +134,234 @@ test.describe('WebAuthn conditional autofill (passkey)', () => {
136134
await cdp.send('WebAuthn.disable');
137135
});
138136

139-
// TODO: This test is currently skipped because the journey used does not allow enabling conditional mediation in admin console
140-
// When we start using v2.0 of Page Node in admin console, this test can be executed again
141-
test.skip('registers a passkey then authenticates via conditional autofill', async ({ page }) => {
137+
// The autofill (conditional) cases fire WebAuthn on render. With automatic presence simulation
138+
// on, the virtual authenticator resolves that request immediately and the page logs in before
139+
// the transient autofill UI (decorated input, manual button) can be asserted. Disabling presence
140+
// simulation lets the request hang so the rendered UI can be checked deterministically.
141+
async function setPresenceSimulation(enabled: boolean): Promise<void> {
142+
await cdp.send('WebAuthn.setAutomaticPresenceSimulation', { authenticatorId, enabled });
143+
}
144+
145+
async function registerPasskey(
146+
page: Page,
147+
navigate: (route: string) => Promise<void>,
148+
clickButton: (text: string, endpoint: string) => Promise<void>,
149+
): Promise<void> {
150+
const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', {
151+
authenticatorId,
152+
});
153+
expect(initialCredentials).toHaveLength(0);
154+
155+
await navigate('/?clientId=tenant&journey=TEST_WebAuthn-Registration');
156+
await expect(page.getByLabel('User Name')).toBeVisible();
157+
await page.getByLabel('User Name').fill(username);
158+
await page.getByLabel('Password').fill(password);
159+
await clickButton('Submit', '/authenticate');
160+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
161+
162+
const { credentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
163+
expect(credentials.length).toBeGreaterThan(0);
164+
}
165+
166+
// 000: no autocomplete values, default mediation, no button
167+
// AM does not signal passkey autofill. Falls back to traditional (prompted) WebAuthn.
168+
test('disabled (000) — falls back to traditional WebAuthn, no autofill input, no manual button', async ({
169+
page,
170+
}) => {
142171
const { clickButton, navigate } = asyncEvents(page);
172+
await test.step('Register', async () => {
173+
await registerPasskey(page, navigate, clickButton);
174+
});
175+
await test.step('Authenticate', async () => {
176+
await page.context().clearCookies();
177+
await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_disabled');
143178

144-
await test.step('Register a WebAuthn credential', async () => {
145-
// Start with an empty virtual authenticator.
146-
const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', {
147-
authenticatorId,
148-
});
149-
expect(initialCredentials).toHaveLength(0);
179+
await expect(page.locator('input[autocomplete="username webauthn"]')).toHaveCount(0);
180+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0);
181+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
182+
});
183+
});
150184

151-
// Run a registration journey that creates a credential in the authenticator.
152-
await navigate('/?clientId=tenant&journey=TEST_WebAuthn-Registration');
153-
await expect(page.getByLabel('User Name')).toBeVisible();
154-
await page.getByLabel('User Name').fill(username);
155-
await page.getByLabel('Password').fill(password);
156-
await clickButton('Submit', '/authenticate');
185+
// 100: autocomplete values present, default mediation, no button
186+
// AM emits autocomplete values so the conditional path is entered and the autofill input is
187+
// rendered. No manual button because manualButtonEnabled is false.
188+
test('autocomplete only (100) — autofill input rendered, no manual button', async ({ page }) => {
189+
const { clickButton, navigate } = asyncEvents(page);
190+
await test.step('Register', async () => {
191+
await registerPasskey(page, navigate, clickButton);
192+
});
193+
await test.step('Authenticate', async () => {
194+
await page.context().clearCookies();
195+
// Hold the fired conditional request open so the rendered autofill UI can be asserted.
196+
await setPresenceSimulation(false);
197+
await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete');
198+
199+
await expect(page.locator('input[autocomplete="username webauthn"]')).toBeVisible();
200+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0);
201+
});
202+
});
203+
204+
// 010: no autocomplete values, conditional mediation, no button
205+
// AM uses conditional mediation internally but did not emit autocomplete values. Journey-app
206+
// never enters the conditional path — falls back to traditional WebAuthn.
207+
test('conditional only (010) — falls back to traditional WebAuthn, no autofill input, no manual button', async ({
208+
page,
209+
}) => {
210+
const { clickButton, navigate } = asyncEvents(page);
211+
await test.step('Register', async () => {
212+
await registerPasskey(page, navigate, clickButton);
213+
});
214+
await test.step('Authenticate', async () => {
215+
await page.context().clearCookies();
216+
await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_conditional');
217+
218+
await expect(page.locator('input[autocomplete="username webauthn"]')).toHaveCount(0);
219+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0);
220+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
221+
});
222+
});
223+
224+
// 001: no autocomplete values, default mediation, button enabled
225+
// AM set manualButtonEnabled but did not emit autocomplete values. The manual button only
226+
// renders inside the conditional path — which is not entered without autocomplete values.
227+
// This asserts that button alone is insufficient to reach the passkey path.
228+
test('button only (001) — manual button does NOT appear despite AM enabling it, falls back to traditional WebAuthn', async ({
229+
page,
230+
}) => {
231+
const { clickButton, navigate } = asyncEvents(page);
232+
await test.step('Register', async () => {
233+
await registerPasskey(page, navigate, clickButton);
234+
});
235+
await test.step('Authenticate', async () => {
236+
await page.context().clearCookies();
237+
await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_button');
238+
239+
await expect(page.locator('input[autocomplete="username webauthn"]')).toHaveCount(0);
240+
// Key assertion: AM enabled manualButtonEnabled but the button does not appear because
241+
// autocomplete values were absent and the conditional path was never entered.
242+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0);
157243
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
244+
});
245+
});
246+
247+
// 110: autocomplete values, conditional mediation, no button
248+
// Both signals for passkey autofill are present. Silent authentication, no manual button.
249+
test('autocomplete + conditional (110) — silent passkey auth, no manual button', async ({
250+
page,
251+
}) => {
252+
const { clickButton, navigate } = asyncEvents(page);
253+
await test.step('Register', async () => {
254+
await registerPasskey(page, navigate, clickButton);
255+
});
256+
await test.step('Authenticate', async () => {
257+
await page.context().clearCookies();
258+
// Hold the fired conditional request open so the rendered autofill UI can be asserted.
259+
// Silent completion depends on real autofill-dropdown interaction that the virtual
260+
// authenticator cannot drive deterministically, so we assert the rendered UI only.
261+
await setPresenceSimulation(false);
262+
await navigate(
263+
'/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional',
264+
);
265+
266+
await expect(page.locator('input[autocomplete="username webauthn"]')).toBeVisible();
267+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0);
268+
});
269+
});
158270

159-
const { credentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
160-
expect(credentials.length).toBeGreaterThan(0);
271+
// 101: autocomplete values, default mediation, button "enabled" in journey config
272+
// AM only sets manualButtonEnabled: true when mediation is conditional — the two are coupled.
273+
// With default mediation this journey sends manualButtonEnabled: false, so the button never
274+
// appears even though autocomplete values are present and the conditional path is entered.
275+
test('autocomplete + button (101) — autofill input present, manual button absent because AM sends manualButtonEnabled: false without conditional mediation', async ({
276+
page,
277+
}) => {
278+
const { clickButton, navigate } = asyncEvents(page);
279+
await test.step('Register', async () => {
280+
await registerPasskey(page, navigate, clickButton);
161281
});
282+
await test.step('Authenticate', async () => {
283+
await page.context().clearCookies();
284+
// Hold the fired conditional request open so the rendered autofill UI can be asserted.
285+
await setPresenceSimulation(false);
286+
await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_button');
287+
288+
await expect(page.locator('input[autocomplete="username webauthn"]')).toBeVisible();
289+
// No button: AM does not set manualButtonEnabled: true without conditional mediation.
290+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0);
291+
});
292+
});
162293

163-
await test.step('Authenticate using conditional UI / passkey autofill', async () => {
164-
// Ensure we are not reusing an existing AM session.
165-
// This makes the test exercise passkey auth, not cookie auth.
294+
// 011: no autocomplete values, conditional mediation, button enabled
295+
// No autocomplete signal means the conditional path is never entered — button does not appear.
296+
test('conditional + button (011) — manual button does NOT appear, falls back to traditional WebAuthn', async ({
297+
page,
298+
}) => {
299+
const { clickButton, navigate } = asyncEvents(page);
300+
await test.step('Register', async () => {
301+
await registerPasskey(page, navigate, clickButton);
302+
});
303+
await test.step('Authenticate', async () => {
166304
await page.context().clearCookies();
305+
await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_conditional_button');
167306

168-
// This journey emits conditional mediation metadata and should complete via background
169-
// WebAuthn (journey-app triggers the request and submits when a credential is returned).
170-
await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn');
307+
await expect(page.locator('input[autocomplete="username webauthn"]')).toHaveCount(0);
308+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0);
309+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
310+
});
311+
});
171312

172-
const conditionalInput = page.locator('input[autocomplete="webauthn"]');
173-
await expect(conditionalInput).toBeVisible({ timeout: 10000 });
174-
await conditionalInput.focus();
175-
await expect(conditionalInput).toBeFocused();
313+
// 111: autocomplete values, conditional mediation, button enabled — the full passkey experience.
314+
// All three signals present, so the step offers two distinct ways to authenticate. We cover both.
176315

177-
// Re-enable presence simulation so the in-flight WebAuthn request can resolve.
178-
await cdp.send('WebAuthn.setAutomaticPresenceSimulation', {
179-
authenticatorId,
180-
enabled: true,
181-
});
316+
// 111a: silent autofill via the username field. The conditional request runs in the background
317+
// and the virtual authenticator resolves it without the user touching the manual button.
318+
test('all enabled (111a) — autofill input and manual button both render, silent passkey auth via username field', async ({
319+
page,
320+
}) => {
321+
const { clickButton, navigate } = asyncEvents(page);
322+
await test.step('Register', async () => {
323+
await registerPasskey(page, navigate, clickButton);
324+
});
325+
await test.step('Authenticate', async () => {
326+
await page.context().clearCookies();
327+
// Hold the fired conditional request open so the rendered autofill UI can be asserted.
328+
// Silent completion depends on real autofill-dropdown interaction that the virtual
329+
// authenticator cannot drive deterministically, so we assert the rendered UI only.
330+
await setPresenceSimulation(false);
331+
await navigate(
332+
'/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional_button',
333+
);
334+
335+
await expect(page.locator('input[autocomplete="username webauthn"]')).toBeVisible();
336+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toBeVisible();
337+
});
338+
});
182339

183-
// With a virtual authenticator configured for automatic presence simulation, this should
184-
// complete without any manual click.
340+
// 111b: explicit authentication via the "Sign in with a passkey" button. Clicking it aborts the
341+
// background conditional request and forces a modal prompt (mediationOverride: 'optional'),
342+
// which the virtual authenticator resolves — exercising the manual button path end to end.
343+
test('all enabled (111b) — clicking "Sign in with a passkey" authenticates via a forced modal prompt', async ({
344+
page,
345+
}) => {
346+
const { clickButton, navigate } = asyncEvents(page);
347+
await test.step('Register', async () => {
348+
await registerPasskey(page, navigate, clickButton);
349+
});
350+
await test.step('Authenticate via the manual button', async () => {
351+
await page.context().clearCookies();
352+
// Hold the background conditional request open so it cannot silently log in before the
353+
// manual button is clicked; the button aborts it and fires the forced modal request.
354+
await setPresenceSimulation(false);
355+
await navigate(
356+
'/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional_button',
357+
);
358+
359+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toBeVisible();
360+
361+
// Re-enable presence so the forced modal request the button fires resolves.
362+
await setPresenceSimulation(true);
363+
await clickButton('Sign in with a passkey', '/authenticate');
185364
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
186-
await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible();
187365
});
188366
});
189367
});

0 commit comments

Comments
 (0)