Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/ready-snakes-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@forgerock/journey-client': minor
---

Add WebAuthn conditional mediation (passkey autofill) support.

- `WebAuthn.authenticate(step, signal?)` derives mediation from WebAuthn metadata (`meta.mediation`).
- When `meta.mediation` is `'conditional'`, an `AbortSignal` is used (caller-provided if present, otherwise created by the SDK).
- If conditional mediation is requested but not supported, `authenticate()` throws `NotSupportedError` (and the existing error handling sets the hidden outcome to `unsupported`).
- Adds `WebAuthn.isConditionalMediationSupported()` helper, docs, and unit tests.
6 changes: 5 additions & 1 deletion e2e/journey-app/components/text-input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand All @@ -21,6 +21,10 @@ export default function textComponent(
input.id = collectorKey;
input.name = collectorKey;

if (callback.getType() === 'NameCallback') {
input.setAttribute('autocomplete', 'webauthn');
}

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

Expand Down
3 changes: 2 additions & 1 deletion e2e/journey-app/components/validated-username.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand All @@ -20,6 +20,7 @@ export default function validatedUsernameComponent(
input.type = 'text';
input.id = collectorKey;
input.name = collectorKey;
input.setAttribute('autocomplete', 'webauthn');

journeyEl?.appendChild(label);
journeyEl?.appendChild(input);
Expand Down
73 changes: 72 additions & 1 deletion e2e/journey-app/components/webauthn-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
* of the MIT license. See the LICENSE file for details.
*/

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

import { renderCallbacks } from '../callback-map.js';

type WebAuthnStepHandlerResult = {
callbacksRendered: boolean;
didSubmit: boolean;
};

export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep, idx: number) {
const container = document.createElement('div');
container.id = `webauthn-container-${idx}`;
Expand Down Expand Up @@ -39,3 +46,67 @@ export function webauthnComponent(journeyEl: HTMLDivElement, step: JourneyStep,

return handleWebAuthn();
}

export async function handleWebAuthnStep(
journeyEl: HTMLDivElement,
step: JourneyStep,
callbacks: BaseCallback[],
submitForm: () => void,
setError: (message: string) => void,
): Promise<WebAuthnStepHandlerResult> {
const webAuthnStep = WebAuthn.getWebAuthnStepType(step);

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

const conditionalInput = journeyEl.querySelector(
'input[autocomplete="webauthn"]',
) as HTMLInputElement | null;
conditionalInput?.focus();

const isConditionalSupported = await WebAuthn.isConditionalMediationSupported();

const metadataCallback = WebAuthn.getMetadataCallback(step);
const meta = metadataCallback?.getData<{
mediation?: CredentialMediationRequirement;
conditional?: boolean;
}>();
const isConditionalMediation = meta?.mediation === 'conditional' || meta?.conditional === true;

if (isConditionalSupported && conditionalInput && isConditionalMediation) {
const controller = new AbortController();
void WebAuthn.authenticate(step, controller.signal)
.then(() => submitForm())
.catch(() => {
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');
});

return { callbacksRendered: true, didSubmit: false };
}
Comment on lines +78 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stale in-flight conditional authenticate can submit or error against a newer step.

handleWebAuthnStep returns { callbacksRendered: true, didSubmit: false } while the WebAuthn.authenticate(step, 'conditional', controller.signal) promise keeps running in the background. The AbortController is created but never aborted, so if the user submits via a different method (e.g., username/password on the same step) or renderForm() is invoked again for a new step, the in-flight conditional request can still resolve later and call submitForm() on a now-stale step, or call setError() over the new UI. You should abort the controller when the step changes / form is submitted / component is torn down.

Suggested direction

Expose an abort hook (or module-level controller) so main.ts can cancel the in-flight conditional request before navigating to the next step, and abort it inside the submit handler in main.ts before calling journeyClient.next(...).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/journey-app/components/webauthn-step.ts` around lines 70 - 78,
handleWebAuthnStep currently launches WebAuthn.authenticate(step, 'conditional',
controller.signal) with a local AbortController that is never aborted, so the
in-flight promise can later call submitForm() or setError() against a stale
step; fix by exposing and using an abort mechanism (e.g., return or register the
controller via a module-level hook) so callers (main.ts and the submit handler)
can call controller.abort() whenever renderForm()/step changes, the form is
submitted, or the component is torn down; specifically ensure the
AbortController created in handleWebAuthnStep (and used in
WebAuthn.authenticate) is aborted from main.ts before calling
journeyClient.next(...) and when swapping steps to prevent stale
submitForm()/setError() invocations.


// Fallback to the traditional (prompted) WebAuthn flow.
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
if (webAuthnSuccess) {
submitForm();
return { callbacksRendered: true, didSubmit: true };
}

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

if (webAuthnStep === WebAuthnStepType.Registration) {
// For registration, we keep the traditional (prompted) WebAuthn flow.
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
if (webAuthnSuccess) {
submitForm();
return { callbacksRendered: false, didSubmit: true };
}

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

return { callbacksRendered: false, didSubmit: false };
}
32 changes: 14 additions & 18 deletions e2e/journey-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import './style.css';

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

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

Expand All @@ -16,7 +15,7 @@ import { renderDeleteDevicesSection } from './components/delete-device.js';
import { renderQRCodeStep } from './components/qr-code.js';
import { renderRecoveryCodesStep } from './components/recovery-codes.js';
import { deleteWebAuthnDevice } from './services/delete-webauthn-device.js';
import { webauthnComponent } from './components/webauthn-step.js';
import { handleWebAuthnStep } from './components/webauthn-step.js';
import { serverConfigs } from './server-configs.js';

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

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

// Handle WebAuthn steps first so we can hide the Submit button while processing,
// auto-submit on success, and show an error on failure.
const webAuthnStep = WebAuthn.getWebAuthnStepType(step);
if (
webAuthnStep === WebAuthnStepType.Authentication ||
webAuthnStep === WebAuthnStepType.Registration
) {
const webAuthnSuccess = await webauthnComponent(journeyEl, step, 0);
if (webAuthnSuccess) {
submitForm();
return;
} else {
errorEl.textContent =
'WebAuthn failed or was cancelled. Please try again or use a different method.';
}
const { callbacksRendered, didSubmit } = await handleWebAuthnStep(
journeyEl,
step,
step.callbacks,
submitForm,
(message) => {
errorEl.textContent = message;
},
);

if (didSubmit) {
return;
}

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

if (!stepRendered) {
if (!stepRendered && !callbacksRendered) {
const callbacks = step.callbacks;
renderCallbacks(journeyEl, callbacks, submitForm);
}
Expand Down
94 changes: 84 additions & 10 deletions e2e/journey-suites/src/webauthn-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId';
test.use({ browserName: 'chromium' });

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

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

test.afterEach(async () => {
if (cdp && authenticatorId) {
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
await cdp.send('WebAuthn.disable');
}
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
await cdp.send('WebAuthn.disable');
});

test('should register, authenticate, and delete a device', async ({ page }) => {
if (!cdp || !authenticatorId) {
throw new Error('Virtual authenticator was not initialized');
}

const { clickButton, navigate } = asyncEvents(page);

const registeredCredentialId =
Expand Down Expand Up @@ -113,3 +107,83 @@ test.describe('WebAuthn register, authenticate, and delete device', () => {
});
});
});

test.describe('WebAuthn conditional autofill (passkey)', () => {
let cdp!: CDPSession;
let authenticatorId!: string;

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

// Configure a platform authenticator with resident keys and auto presence simulation.
const response = await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
authenticatorId = response.authenticatorId;
});

test.afterEach(async () => {
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
await cdp.send('WebAuthn.disable');
});

// TODO: This test is currently skipped because the journey used does not allow enabling conditional mediation in admin console
// When we start using v2.0 of Page Node in admin console, this test can be executed again
test.skip('registers a passkey then authenticates via conditional autofill', async ({ page }) => {
const { clickButton, navigate } = asyncEvents(page);

await test.step('Register a WebAuthn credential', async () => {
// Start with an empty virtual authenticator.
const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', {
authenticatorId,
});
expect(initialCredentials).toHaveLength(0);

// Run a registration journey that creates a credential in the authenticator.
await navigate('/?clientId=tenant&journey=TEST_WebAuthn-Registration');
await expect(page.getByLabel('User Name')).toBeVisible();
await page.getByLabel('User Name').fill(username);
await page.getByLabel('Password').fill(password);
await clickButton('Submit', '/authenticate');
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();

const { credentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
expect(credentials.length).toBeGreaterThan(0);
});

await test.step('Authenticate using conditional UI / passkey autofill', async () => {
// Ensure we are not reusing an existing AM session.
// This makes the test exercise passkey auth, not cookie auth.
await page.context().clearCookies();

// This journey emits conditional mediation metadata and should complete via background
// WebAuthn (journey-app triggers the request and submits when a credential is returned).
await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn');

const conditionalInput = page.locator('input[autocomplete="webauthn"]');
await expect(conditionalInput).toBeVisible({ timeout: 10000 });
await conditionalInput.focus();
await expect(conditionalInput).toBeFocused();

// Re-enable presence simulation so the in-flight WebAuthn request can resolve.
await cdp.send('WebAuthn.setAutomaticPresenceSimulation', {
authenticatorId,
enabled: true,
});

// With a virtual authenticator configured for automatic presence simulation, this should
// complete without any manual click.
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ export enum UserVerificationType {

// @public
export abstract class WebAuthn {
static authenticate(step: JourneyStep): Promise<JourneyStep>;
static authenticate(step: JourneyStep, signal?: AbortSignal): Promise<JourneyStep>;
static createAuthenticationPublicKey(metadata: WebAuthnAuthenticationMetadata): PublicKeyCredentialRequestOptions;
static createRegistrationPublicKey(metadata: WebAuthnRegistrationMetadata): PublicKeyCredentialCreationOptions;
static getAuthenticationCredential(options: PublicKeyCredentialRequestOptions): Promise<PublicKeyCredential | null>;
static getAuthenticationCredential(options: PublicKeyCredentialRequestOptions, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise<PublicKeyCredential | null>;
static getAuthenticationOutcome(credential: PublicKeyCredential | null): OutcomeWithName<string, AttestationType, PublicKeyCredential> | OutcomeWithName<string, AttestationType, PublicKeyCredential, string>;
static getCallbacks(step: JourneyStep): WebAuthnCallbacks;
static getMetadataCallback(step: JourneyStep): MetadataCallback | undefined;
Expand All @@ -104,6 +104,7 @@ export abstract class WebAuthn {
static getRegistrationOutcome(credential: PublicKeyCredential | null): OutcomeWithName<string, AttestationType, PublicKeyCredential>;
static getTextOutputCallback(step: JourneyStep): TextOutputCallback | undefined;
static getWebAuthnStepType(step: JourneyStep): WebAuthnStepType;
static isConditionalMediationSupported(): Promise<boolean>;
static register<T extends string = ''>(step: JourneyStep, deviceName?: T): Promise<JourneyStep>;
}

Expand All @@ -116,6 +117,8 @@ export interface WebAuthnAuthenticationMetadata {
// (undocumented)
challenge: string;
// (undocumented)
mediation?: CredentialMediationRequirement;
// (undocumented)
relyingPartyId: string;
// (undocumented)
supportsJsonResponse?: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/journey-client/src/lib/webauthn/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface WebAuthnAuthenticationMetadata {
acceptableCredentials?: string;
allowCredentials?: string;
challenge: string;
mediation?: CredentialMediationRequirement;
relyingPartyId: string;
timeout: number;
userVerification: UserVerificationType;
Expand Down
3 changes: 1 addition & 2 deletions packages/journey-client/src/lib/webauthn/webauthn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* fr-webauthn.test.ts
*
* Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved.
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
Expand Down Expand Up @@ -52,7 +52,6 @@ describe('Test FRWebAuthn class with 7.0 "Passwordless"', () => {
// eslint-disable-next-line
const step = createJourneyStep(webAuthnAuthJSCallback70 as any);
const stepType = WebAuthn.getWebAuthnStepType(step);
console.log('the step type', stepType, WebAuthnStepType.Authentication);
expect(stepType).toBe(WebAuthnStepType.Authentication);
});

Expand Down
Loading
Loading