Skip to content

Commit 31044cf

Browse files
committed
feat(journey-client): conditional mediation autofill passkey support for webauthn
1 parent b28b6b0 commit 31044cf

8 files changed

Lines changed: 280 additions & 28 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, mediation?, signal?)` forwards `mediation` and `signal` to `navigator.credentials.get`.
8+
- When `mediation` is `'conditional'`, an `AbortSignal` is required.
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: 64 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,59 @@ 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+
if (isConditionalSupported && conditionalInput) {
70+
const controller = new AbortController();
71+
void WebAuthn.authenticate(step, 'conditional', controller.signal)
72+
.then(() => submitForm())
73+
.catch(() => {
74+
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
75+
});
76+
77+
return { callbacksRendered: true, didSubmit: false };
78+
}
79+
80+
// Fallback to the traditional (prompted) WebAuthn flow.
81+
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
82+
if (webAuthnSuccess) {
83+
submitForm();
84+
return { callbacksRendered: true, didSubmit: true };
85+
}
86+
87+
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
88+
return { callbacksRendered: true, didSubmit: false };
89+
}
90+
91+
if (webAuthnStep === WebAuthnStepType.Registration) {
92+
// For registration, we keep the traditional (prompted) WebAuthn flow.
93+
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
94+
if (webAuthnSuccess) {
95+
submitForm();
96+
return { callbacksRendered: false, didSubmit: true };
97+
}
98+
99+
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
100+
return { callbacksRendered: false, didSubmit: false };
101+
}
102+
103+
return { callbacksRendered: false, didSubmit: false };
104+
}

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
}

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

Lines changed: 3 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, mediation?: CredentialMediationRequirement, 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

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

Lines changed: 103 additions & 1 deletion
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
*/
@@ -23,6 +23,7 @@ import {
2323
webAuthnAuthMetaCallback70StoredUsername,
2424
} from './webauthn.mock.data.js';
2525
import { createJourneyStep } from '../step.utils.js';
26+
import { vi, afterEach, beforeEach, expect } from 'vitest';
2627

2728
describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => {
2829
it('should return Registration type with register text-output callbacks', () => {
@@ -98,3 +99,104 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => {
9899
expect(stepType).toBe(WebAuthnStepType.Authentication);
99100
});
100101
});
102+
103+
describe('WebAuthn conditional mediation', () => {
104+
const originalNavigatorCredentials = navigator.credentials;
105+
const originalPublicKeyCredential = globalThis.PublicKeyCredential;
106+
107+
beforeEach(() => {
108+
Object.defineProperty(globalThis, 'PublicKeyCredential', {
109+
configurable: true,
110+
writable: true,
111+
value: class PublicKeyCredential {
112+
static async isConditionalMediationAvailable(): Promise<boolean> {
113+
return true;
114+
}
115+
},
116+
});
117+
118+
Object.defineProperty(navigator, 'credentials', {
119+
configurable: true,
120+
value: {
121+
get: vi.fn(),
122+
},
123+
});
124+
});
125+
126+
afterEach(() => {
127+
Object.defineProperty(navigator, 'credentials', {
128+
configurable: true,
129+
value: originalNavigatorCredentials,
130+
});
131+
132+
Object.defineProperty(globalThis, 'PublicKeyCredential', {
133+
configurable: true,
134+
writable: true,
135+
value: originalPublicKeyCredential,
136+
});
137+
138+
vi.restoreAllMocks();
139+
});
140+
141+
it('requires an AbortSignal when mediation is conditional', async () => {
142+
// eslint-disable-next-line
143+
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
144+
const hiddenCallback = WebAuthn.getOutcomeCallback(step);
145+
if (!hiddenCallback) throw new Error('Missing hidden callback for test');
146+
147+
await expect(WebAuthn.authenticate(step, 'conditional')).rejects.toThrow(
148+
'AbortSignal is required for conditional mediation WebAuthn requests',
149+
);
150+
151+
expect(hiddenCallback.getInputValue()).toContain(
152+
'AbortSignal is required for conditional mediation WebAuthn requests',
153+
);
154+
});
155+
156+
it('throws NotSupportedError when conditional mediation is not supported by the browser', async () => {
157+
// eslint-disable-next-line
158+
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
159+
const hiddenCallback = WebAuthn.getOutcomeCallback(step);
160+
if (!hiddenCallback) throw new Error('Missing hidden callback for test');
161+
162+
const conditionalSupportSpy = vi
163+
.spyOn(WebAuthn, 'isConditionalMediationSupported')
164+
.mockResolvedValue(false);
165+
166+
await expect(
167+
WebAuthn.authenticate(step, 'conditional', new AbortController().signal),
168+
).rejects.toMatchObject({ name: 'NotSupportedError' });
169+
170+
expect(conditionalSupportSpy).toHaveBeenCalledTimes(1);
171+
expect(hiddenCallback.getInputValue()).toBe('unsupported');
172+
expect(navigator.credentials.get as unknown as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
173+
});
174+
175+
it('passes mediation + signal through to navigator.credentials.get when supported', async () => {
176+
// eslint-disable-next-line
177+
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
178+
const hiddenCallback = WebAuthn.getOutcomeCallback(step);
179+
if (!hiddenCallback) throw new Error('Missing hidden callback for test');
180+
181+
const abortController = new AbortController();
182+
const credentialsGet = vi
183+
.spyOn(navigator.credentials, 'get')
184+
.mockResolvedValue({} as unknown as Credential);
185+
186+
const outcomeSpy = vi
187+
.spyOn(WebAuthn, 'getAuthenticationOutcome')
188+
.mockReturnValue('ok' as unknown as ReturnType<typeof WebAuthn.getAuthenticationOutcome>);
189+
190+
await WebAuthn.authenticate(step, 'conditional', abortController.signal);
191+
192+
expect(outcomeSpy).toHaveBeenCalledTimes(1);
193+
expect(credentialsGet).toHaveBeenCalledWith(
194+
expect.objectContaining({
195+
mediation: 'conditional',
196+
signal: abortController.signal,
197+
publicKey: expect.any(Object),
198+
}),
199+
);
200+
expect(hiddenCallback.getInputValue()).toBe('ok');
201+
});
202+
});

0 commit comments

Comments
 (0)