Skip to content

Commit 5472d71

Browse files
committed
feat(journey-client): add hasPasskeyAutocompleteValues to WebAuthn for passkey autofill
1 parent 2f91101 commit 5472d71

8 files changed

Lines changed: 261 additions & 97 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 `WebAuthn.hasPasskeyAutocompleteValues()` to detect when a step's `NameCallback` carries both `username` and `webauthn` autocomplete values, signalling that the username input should be decorated with `autocomplete="username webauthn"` for passkey autofill.

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: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -57,42 +57,29 @@ 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.');
83-
});
84-
85-
return { callbacksRendered: true, didSubmit: false };
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.
77+
if (isConditionalSupported && WebAuthn.hasPasskeyAutocompleteValues(step)) {
78+
journeyEl.querySelectorAll('input[type="text"]').forEach((input) => {
79+
input.setAttribute('autocomplete', 'username webauthn');
80+
});
8681
}
8782

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 };
93-
}
94-
95-
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
9683
return { callbacksRendered: true, didSubmit: false };
9784
}
9885

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

Lines changed: 99 additions & 41 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,114 @@ 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);
150-
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');
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);
157181
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
158-
159-
const { credentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
160-
expect(credentials.length).toBeGreaterThan(0);
161182
});
183+
});
162184

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.
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 () => {
166194
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');
167198

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');
171-
172-
const conditionalInput = page.locator('input[autocomplete="webauthn"]');
173-
await expect(conditionalInput).toBeVisible({ timeout: 10000 });
174-
await conditionalInput.focus();
175-
await expect(conditionalInput).toBeFocused();
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+
});
176203

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-
});
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');
182217

183-
// With a virtual authenticator configured for automatic presence simulation, this should
184-
// complete without any manual click.
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);
185220
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
186-
await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible();
221+
});
222+
});
223+
224+
// 110: autocomplete values, conditional mediation, no button
225+
// Both signals for passkey autofill are present. Silent authentication, no manual button.
226+
test('autocomplete + conditional (110) — silent passkey auth, no manual button', async ({
227+
page,
228+
}) => {
229+
const { clickButton, navigate } = asyncEvents(page);
230+
await test.step('Register', async () => {
231+
await registerPasskey(page, navigate, clickButton);
232+
});
233+
await test.step('Authenticate', async () => {
234+
await page.context().clearCookies();
235+
// Hold the fired conditional request open so the rendered autofill UI can be asserted.
236+
// Silent completion depends on real autofill-dropdown interaction that the virtual
237+
// authenticator cannot drive deterministically, so we assert the rendered UI only.
238+
await setPresenceSimulation(false);
239+
await navigate(
240+
'/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional',
241+
);
242+
243+
await expect(page.locator('input[autocomplete="username webauthn"]')).toBeVisible();
244+
await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0);
187245
});
188246
});
189247
});

packages/journey-client/api-report/journey-client.webauthn.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export abstract class WebAuthn {
104104
static getRegistrationOutcome(credential: PublicKeyCredential | null): OutcomeWithName<string, AttestationType, PublicKeyCredential>;
105105
static getTextOutputCallback(step: JourneyStep): TextOutputCallback | undefined;
106106
static getWebAuthnStepType(step: JourneyStep): WebAuthnStepType;
107+
static hasPasskeyAutocompleteValues(step: JourneyStep): boolean;
107108
static isConditionalMediationSupported(): Promise<boolean>;
108109
static register<T extends string = ''>(step: JourneyStep, deviceName?: T): Promise<JourneyStep>;
109110
}

packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,93 @@ export const webAuthnRegMetaCallbackJsonResponse = {
441441
],
442442
};
443443

444+
export const webAuthnAuthMetaCallbackWithPasskeyAutofill = {
445+
authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ',
446+
callbacks: [
447+
{
448+
type: callbackType.NameCallback,
449+
output: [
450+
{ name: 'prompt', value: 'User Name' },
451+
{ name: 'autocompleteValues', value: ['username', 'webauthn'] },
452+
],
453+
input: [{ name: 'IDToken1', value: '' }],
454+
},
455+
{
456+
type: callbackType.MetadataCallback,
457+
output: [
458+
{
459+
name: 'data',
460+
value: {
461+
_action: 'webauthn_authentication',
462+
challenge: 'tbon5Eo3gdE0KPRkoT8H9ek59OoL1/q4iUQSlzV2zhI=',
463+
allowCredentials: '',
464+
_allowCredentials: [],
465+
timeout: '60000',
466+
userVerification: 'preferred',
467+
conditional: true,
468+
mediation: 'conditional',
469+
relyingPartyId: 'rpId: "localhost",',
470+
_relyingPartyId: 'localhost',
471+
_type: 'WebAuthn',
472+
supportsJsonResponse: true,
473+
},
474+
},
475+
],
476+
},
477+
{
478+
type: callbackType.HiddenValueCallback,
479+
output: [
480+
{ name: 'value', value: 'false' },
481+
{ name: 'id', value: 'webAuthnOutcome' },
482+
],
483+
input: [{ name: 'IDToken4', value: 'webAuthnOutcome' }],
484+
},
485+
],
486+
};
487+
488+
export const webAuthnAuthMetaCallbackWithoutPasskeyAutofill = {
489+
authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ',
490+
callbacks: [
491+
{
492+
type: callbackType.NameCallback,
493+
output: [
494+
{ name: 'prompt', value: 'User Name' },
495+
{ name: 'autocompleteValues', value: ['username'] },
496+
],
497+
input: [{ name: 'IDToken1', value: '' }],
498+
},
499+
{
500+
type: callbackType.MetadataCallback,
501+
output: [
502+
{
503+
name: 'data',
504+
value: {
505+
_action: 'webauthn_authentication',
506+
challenge: 'tbon5Eo3gdE0KPRkoT8H9ek59OoL1/q4iUQSlzV2zhI=',
507+
allowCredentials: '',
508+
_allowCredentials: [],
509+
timeout: '60000',
510+
userVerification: 'preferred',
511+
mediation: 'conditional',
512+
relyingPartyId: 'rpId: "localhost",',
513+
_relyingPartyId: 'localhost',
514+
_type: 'WebAuthn',
515+
supportsJsonResponse: true,
516+
},
517+
},
518+
],
519+
},
520+
{
521+
type: callbackType.HiddenValueCallback,
522+
output: [
523+
{ name: 'value', value: 'false' },
524+
{ name: 'id', value: 'webAuthnOutcome' },
525+
],
526+
input: [{ name: 'IDToken4', value: 'webAuthnOutcome' }],
527+
},
528+
],
529+
};
530+
444531
export const webAuthnAuthJSCallback70StoredUsername = {
445532
authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ',
446533
callbacks: [

0 commit comments

Comments
 (0)