Skip to content

Commit 07015a2

Browse files
authored
feat(journey-client): webauthn conditional mediation autofill passkey support … (#581)
* feat(journey-client): conditional mediation autofill passkey support for webauthn * feat(journey-client): automatic server based mediation and abort controller
1 parent b1e4b5e commit 07015a2

10 files changed

Lines changed: 299 additions & 44 deletions

File tree

.changeset/ready-snakes-sell.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@forgerock/journey-client': minor
3+
---
4+
5+
Add WebAuthn conditional mediation (passkey autofill) support.
6+
7+
- `WebAuthn.authenticate(step, signal?)` derives mediation from WebAuthn metadata (`meta.mediation`).
8+
- When `meta.mediation` is `'conditional'`, an `AbortSignal` is used (caller-provided if present, otherwise created by the SDK).
9+
- If conditional mediation is requested but not supported, `authenticate()` throws `NotSupportedError` (and the existing error handling sets the hidden outcome to `unsupported`).
10+
- Adds `WebAuthn.isConditionalMediationSupported()` helper, docs, and unit tests.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -21,6 +21,10 @@ 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+
2428
journeyEl?.appendChild(label);
2529
journeyEl?.appendChild(input);
2630

e2e/journey-app/components/validated-username.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -20,6 +20,7 @@ export default function validatedUsernameComponent(
2020
input.type = 'text';
2121
input.id = collectorKey;
2222
input.name = collectorKey;
23+
input.setAttribute('autocomplete', 'webauthn');
2324

2425
journeyEl?.appendChild(label);
2526
journeyEl?.appendChild(input);

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

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
* of the MIT license. See the LICENSE file for details.
66
*/
77

8-
import { JourneyStep } from '@forgerock/journey-client/types';
8+
import type { BaseCallback, JourneyStep } from '@forgerock/journey-client/types';
99
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';
1010

11+
import { renderCallbacks } from '../callback-map.js';
12+
13+
type WebAuthnStepHandlerResult = {
14+
callbacksRendered: boolean;
15+
didSubmit: boolean;
16+
};
17+
1118
export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep, idx: number) {
1219
const container = document.createElement('div');
1320
container.id = `webauthn-container-${idx}`;
@@ -39,3 +46,67 @@ export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep,
3946

4047
return handleWebAuthn();
4148
}
49+
50+
export async function handleWebAuthnStep(
51+
journeyEl: HTMLDivElement,
52+
step: JourneyStep,
53+
callbacks: BaseCallback[],
54+
submitForm: () => void,
55+
setError: (message: string) => void,
56+
): Promise<WebAuthnStepHandlerResult> {
57+
const webAuthnStep = WebAuthn.getWebAuthnStepType(step);
58+
59+
if (webAuthnStep === WebAuthnStepType.Authentication) {
60+
// For conditional mediation, we need an input with `autocomplete="webauthn"` to exist.
61+
renderCallbacks(journeyEl, callbacks, submitForm);
62+
63+
const conditionalInput = journeyEl.querySelector(
64+
'input[autocomplete="webauthn"]',
65+
) as HTMLInputElement | null;
66+
conditionalInput?.focus();
67+
68+
const isConditionalSupported = await WebAuthn.isConditionalMediationSupported();
69+
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 };
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 };
93+
}
94+
95+
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
96+
return { callbacksRendered: true, didSubmit: false };
97+
}
98+
99+
if (webAuthnStep === WebAuthnStepType.Registration) {
100+
// For registration, we keep the traditional (prompted) WebAuthn flow.
101+
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
102+
if (webAuthnSuccess) {
103+
submitForm();
104+
return { callbacksRendered: false, didSubmit: true };
105+
}
106+
107+
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
108+
return { callbacksRendered: false, didSubmit: false };
109+
}
110+
111+
return { callbacksRendered: false, didSubmit: false };
112+
}

e2e/journey-app/main.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import './style.css';
88

99
import { journey } from '@forgerock/journey-client';
10-
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';
1110

1211
import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types';
1312

@@ -16,7 +15,7 @@ import { renderDeleteDevicesSection } from './components/delete-device.js';
1615
import { renderQRCodeStep } from './components/qr-code.js';
1716
import { renderRecoveryCodesStep } from './components/recovery-codes.js';
1817
import { deleteWebAuthnDevice } from './services/delete-webauthn-device.js';
19-
import { webauthnComponent } from './components/webauthn-step.js';
18+
import { handleWebAuthnStep } from './components/webauthn-step.js';
2019
import { serverConfigs } from './server-configs.js';
2120

2221
const qs = window.location.search;
@@ -107,27 +106,24 @@ if (searchParams.get('middleware') === 'true') {
107106

108107
const submitForm = () => formEl.requestSubmit();
109108

110-
// Handle WebAuthn steps first so we can hide the Submit button while processing,
111-
// auto-submit on success, and show an error on failure.
112-
const webAuthnStep = WebAuthn.getWebAuthnStepType(step);
113-
if (
114-
webAuthnStep === WebAuthnStepType.Authentication ||
115-
webAuthnStep === WebAuthnStepType.Registration
116-
) {
117-
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
118-
if (webAuthnSuccess) {
119-
submitForm();
120-
return;
121-
} else {
122-
errorEl.textContent =
123-
'WebAuthn failed or was cancelled. Please try again or use a different method.';
124-
}
109+
const { callbacksRendered, didSubmit } = await handleWebAuthnStep(
110+
journeyEl,
111+
step,
112+
step.callbacks,
113+
submitForm,
114+
(message) => {
115+
errorEl.textContent = message;
116+
},
117+
);
118+
119+
if (didSubmit) {
120+
return;
125121
}
126122

127123
const stepRendered =
128124
renderQRCodeStep(journeyEl, step) || renderRecoveryCodesStep(journeyEl, step);
129125

130-
if (!stepRendered) {
126+
if (!stepRendered && !callbacksRendered) {
131127
const callbacks = step.callbacks;
132128
renderCallbacks(journeyEl, callbacks, submitForm);
133129
}

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

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId';
1515
test.use({ browserName: 'chromium' });
1616

1717
test.describe('WebAuthn register, authenticate, and delete device', () => {
18-
let cdp: CDPSession | undefined;
19-
let authenticatorId: string | undefined;
18+
let cdp!: CDPSession;
19+
let authenticatorId!: string;
2020

2121
test.beforeEach(async ({ context, page }) => {
2222
cdp = await context.newCDPSession(page);
@@ -35,17 +35,11 @@ test.describe('WebAuthn register, authenticate, and delete device', () => {
3535
});
3636

3737
test.afterEach(async () => {
38-
if (cdp && authenticatorId) {
39-
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
40-
await cdp.send('WebAuthn.disable');
41-
}
38+
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
39+
await cdp.send('WebAuthn.disable');
4240
});
4341

4442
test('should register, authenticate, and delete a device', async ({ page }) => {
45-
if (!cdp || !authenticatorId) {
46-
throw new Error('Virtual authenticator was not initialized');
47-
}
48-
4943
const { clickButton, navigate } = asyncEvents(page);
5044

5145
const registeredCredentialId =
@@ -113,3 +107,83 @@ test.describe('WebAuthn register, authenticate, and delete device', () => {
113107
});
114108
});
115109
});
110+
111+
test.describe('WebAuthn conditional autofill (passkey)', () => {
112+
let cdp!: CDPSession;
113+
let authenticatorId!: string;
114+
115+
test.beforeEach(async ({ context, page }) => {
116+
// Chromium + CDP WebAuthn virtual authenticator is required for repeatable automation.
117+
cdp = await context.newCDPSession(page);
118+
await cdp.send('WebAuthn.enable');
119+
120+
// Configure a platform authenticator with resident keys and auto presence simulation.
121+
const response = await cdp.send('WebAuthn.addVirtualAuthenticator', {
122+
options: {
123+
protocol: 'ctap2',
124+
transport: 'internal',
125+
hasResidentKey: true,
126+
hasUserVerification: true,
127+
isUserVerified: true,
128+
automaticPresenceSimulation: true,
129+
},
130+
});
131+
authenticatorId = response.authenticatorId;
132+
});
133+
134+
test.afterEach(async () => {
135+
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
136+
await cdp.send('WebAuthn.disable');
137+
});
138+
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 }) => {
142+
const { clickButton, navigate } = asyncEvents(page);
143+
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');
157+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
158+
159+
const { credentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
160+
expect(credentials.length).toBeGreaterThan(0);
161+
});
162+
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.
166+
await page.context().clearCookies();
167+
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();
176+
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+
});
182+
183+
// With a virtual authenticator configured for automatic presence simulation, this should
184+
// complete without any manual click.
185+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
186+
await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible();
187+
});
188+
});
189+
});

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,10 @@ export enum UserVerificationType {
9292

9393
// @public
9494
export abstract class WebAuthn {
95-
static authenticate(step: JourneyStep): Promise<JourneyStep>;
95+
static authenticate(step: JourneyStep, signal?: AbortSignal): Promise<JourneyStep>;
9696
static createAuthenticationPublicKey(metadata: WebAuthnAuthenticationMetadata): PublicKeyCredentialRequestOptions;
9797
static createRegistrationPublicKey(metadata: WebAuthnRegistrationMetadata): PublicKeyCredentialCreationOptions;
98-
static getAuthenticationCredential(options: PublicKeyCredentialRequestOptions): Promise<PublicKeyCredential | null>;
98+
static getAuthenticationCredential(options: PublicKeyCredentialRequestOptions, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise<PublicKeyCredential | null>;
9999
static getAuthenticationOutcome(credential: PublicKeyCredential | null): OutcomeWithName<string, AttestationType, PublicKeyCredential> | OutcomeWithName<string, AttestationType, PublicKeyCredential, string>;
100100
static getCallbacks(step: JourneyStep): WebAuthnCallbacks;
101101
static getMetadataCallback(step: JourneyStep): MetadataCallback | undefined;
@@ -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 isConditionalMediationSupported(): Promise<boolean>;
107108
static register<T extends string = ''>(step: JourneyStep, deviceName?: T): Promise<JourneyStep>;
108109
}
109110

@@ -116,6 +117,8 @@ export interface WebAuthnAuthenticationMetadata {
116117
// (undocumented)
117118
challenge: string;
118119
// (undocumented)
120+
mediation?: CredentialMediationRequirement;
121+
// (undocumented)
119122
relyingPartyId: string;
120123
// (undocumented)
121124
supportsJsonResponse?: boolean;

packages/journey-client/src/lib/webauthn/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export interface WebAuthnAuthenticationMetadata {
8080
acceptableCredentials?: string;
8181
allowCredentials?: string;
8282
challenge: string;
83+
mediation?: CredentialMediationRequirement;
8384
relyingPartyId: string;
8485
timeout: number;
8586
userVerification: UserVerificationType;

packages/journey-client/src/lib/webauthn/webauthn.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*
44
* fr-webauthn.test.ts
55
*
6-
* Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved.
6+
* Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved.
77
* This software may be modified and distributed under the terms
88
* of the MIT license. See the LICENSE file for details.
99
*/
@@ -52,7 +52,6 @@ describe('Test FRWebAuthn class with 7.0 "Passwordless"', () => {
5252
// eslint-disable-next-line
5353
const step = createJourneyStep(webAuthnAuthJSCallback70 as any);
5454
const stepType = WebAuthn.getWebAuthnStepType(step);
55-
console.log('the step type', stepType, WebAuthnStepType.Authentication);
5655
expect(stepType).toBe(WebAuthnStepType.Authentication);
5756
});
5857

0 commit comments

Comments
 (0)