From 31044cfd2dd3e7cd14990fac867b41b98bd31585 Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Thu, 23 Apr 2026 11:06:31 -0700 Subject: [PATCH 1/4] feat(journey-client): conditional mediation autofill passkey support for webauthn --- .changeset/ready-snakes-sell.md | 10 ++ e2e/journey-app/components/text-input.ts | 6 +- .../components/validated-username.ts | 3 +- e2e/journey-app/components/webauthn-step.ts | 65 ++++++++++- e2e/journey-app/main.ts | 32 +++--- .../api-report/journey-client.webauthn.api.md | 5 +- .../src/lib/webauthn/webauthn.test.ts | 104 +++++++++++++++++- .../src/lib/webauthn/webauthn.ts | 83 +++++++++++++- 8 files changed, 280 insertions(+), 28 deletions(-) create mode 100644 .changeset/ready-snakes-sell.md diff --git a/.changeset/ready-snakes-sell.md b/.changeset/ready-snakes-sell.md new file mode 100644 index 0000000000..c2f57d110c --- /dev/null +++ b/.changeset/ready-snakes-sell.md @@ -0,0 +1,10 @@ +--- +'@forgerock/journey-client': minor +--- + +Add WebAuthn conditional mediation (passkey autofill) support. + +- `WebAuthn.authenticate(step, mediation?, signal?)` forwards `mediation` and `signal` to `navigator.credentials.get`. +- When `mediation` is `'conditional'`, an `AbortSignal` is required. +- 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. diff --git a/e2e/journey-app/components/text-input.ts b/e2e/journey-app/components/text-input.ts index 0a45074160..9e46a81483 100644 --- a/e2e/journey-app/components/text-input.ts +++ b/e2e/journey-app/components/text-input.ts @@ -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. @@ -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); diff --git a/e2e/journey-app/components/validated-username.ts b/e2e/journey-app/components/validated-username.ts index 0efddfaebe..fada1fe690 100644 --- a/e2e/journey-app/components/validated-username.ts +++ b/e2e/journey-app/components/validated-username.ts @@ -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. @@ -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); diff --git a/e2e/journey-app/components/webauthn-step.ts b/e2e/journey-app/components/webauthn-step.ts index ce5035e8a4..c523f0afa0 100644 --- a/e2e/journey-app/components/webauthn-step.ts +++ b/e2e/journey-app/components/webauthn-step.ts @@ -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}`; @@ -39,3 +46,59 @@ 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 { + 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(); + if (isConditionalSupported && conditionalInput) { + const controller = new AbortController(); + void WebAuthn.authenticate(step, 'conditional', controller.signal) + .then(() => submitForm()) + .catch(() => { + setError('WebAuthn failed or was cancelled. Please try again or use a different method.'); + }); + + return { callbacksRendered: true, didSubmit: false }; + } + + // 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 }; +} diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index 35aa41262a..3b61558b4d 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -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'; @@ -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; @@ -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); } diff --git a/packages/journey-client/api-report/journey-client.webauthn.api.md b/packages/journey-client/api-report/journey-client.webauthn.api.md index dfa9bd9f70..0d2fe54a4a 100644 --- a/packages/journey-client/api-report/journey-client.webauthn.api.md +++ b/packages/journey-client/api-report/journey-client.webauthn.api.md @@ -92,10 +92,10 @@ export enum UserVerificationType { // @public export abstract class WebAuthn { - static authenticate(step: JourneyStep): Promise; + static authenticate(step: JourneyStep, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise; static createAuthenticationPublicKey(metadata: WebAuthnAuthenticationMetadata): PublicKeyCredentialRequestOptions; static createRegistrationPublicKey(metadata: WebAuthnRegistrationMetadata): PublicKeyCredentialCreationOptions; - static getAuthenticationCredential(options: PublicKeyCredentialRequestOptions): Promise; + static getAuthenticationCredential(options: PublicKeyCredentialRequestOptions, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise; static getAuthenticationOutcome(credential: PublicKeyCredential | null): OutcomeWithName | OutcomeWithName; static getCallbacks(step: JourneyStep): WebAuthnCallbacks; static getMetadataCallback(step: JourneyStep): MetadataCallback | undefined; @@ -104,6 +104,7 @@ export abstract class WebAuthn { static getRegistrationOutcome(credential: PublicKeyCredential | null): OutcomeWithName; static getTextOutputCallback(step: JourneyStep): TextOutputCallback | undefined; static getWebAuthnStepType(step: JourneyStep): WebAuthnStepType; + static isConditionalMediationSupported(): Promise; static register(step: JourneyStep, deviceName?: T): Promise; } diff --git a/packages/journey-client/src/lib/webauthn/webauthn.test.ts b/packages/journey-client/src/lib/webauthn/webauthn.test.ts index 6d8829daf9..414304fa80 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.test.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.test.ts @@ -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. */ @@ -23,6 +23,7 @@ import { webAuthnAuthMetaCallback70StoredUsername, } from './webauthn.mock.data.js'; import { createJourneyStep } from '../step.utils.js'; +import { vi, afterEach, beforeEach, expect } from 'vitest'; describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => { it('should return Registration type with register text-output callbacks', () => { @@ -98,3 +99,104 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => { expect(stepType).toBe(WebAuthnStepType.Authentication); }); }); + +describe('WebAuthn conditional mediation', () => { + const originalNavigatorCredentials = navigator.credentials; + const originalPublicKeyCredential = globalThis.PublicKeyCredential; + + beforeEach(() => { + Object.defineProperty(globalThis, 'PublicKeyCredential', { + configurable: true, + writable: true, + value: class PublicKeyCredential { + static async isConditionalMediationAvailable(): Promise { + return true; + } + }, + }); + + Object.defineProperty(navigator, 'credentials', { + configurable: true, + value: { + get: vi.fn(), + }, + }); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'credentials', { + configurable: true, + value: originalNavigatorCredentials, + }); + + Object.defineProperty(globalThis, 'PublicKeyCredential', { + configurable: true, + writable: true, + value: originalPublicKeyCredential, + }); + + vi.restoreAllMocks(); + }); + + it('requires an AbortSignal when mediation is conditional', async () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthMetaCallback70 as any); + const hiddenCallback = WebAuthn.getOutcomeCallback(step); + if (!hiddenCallback) throw new Error('Missing hidden callback for test'); + + await expect(WebAuthn.authenticate(step, 'conditional')).rejects.toThrow( + 'AbortSignal is required for conditional mediation WebAuthn requests', + ); + + expect(hiddenCallback.getInputValue()).toContain( + 'AbortSignal is required for conditional mediation WebAuthn requests', + ); + }); + + it('throws NotSupportedError when conditional mediation is not supported by the browser', async () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthMetaCallback70 as any); + const hiddenCallback = WebAuthn.getOutcomeCallback(step); + if (!hiddenCallback) throw new Error('Missing hidden callback for test'); + + const conditionalSupportSpy = vi + .spyOn(WebAuthn, 'isConditionalMediationSupported') + .mockResolvedValue(false); + + await expect( + WebAuthn.authenticate(step, 'conditional', new AbortController().signal), + ).rejects.toMatchObject({ name: 'NotSupportedError' }); + + expect(conditionalSupportSpy).toHaveBeenCalledTimes(1); + expect(hiddenCallback.getInputValue()).toBe('unsupported'); + expect(navigator.credentials.get as unknown as ReturnType).not.toHaveBeenCalled(); + }); + + it('passes mediation + signal through to navigator.credentials.get when supported', async () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthMetaCallback70 as any); + const hiddenCallback = WebAuthn.getOutcomeCallback(step); + if (!hiddenCallback) throw new Error('Missing hidden callback for test'); + + const abortController = new AbortController(); + const credentialsGet = vi + .spyOn(navigator.credentials, 'get') + .mockResolvedValue({} as unknown as Credential); + + const outcomeSpy = vi + .spyOn(WebAuthn, 'getAuthenticationOutcome') + .mockReturnValue('ok' as unknown as ReturnType); + + await WebAuthn.authenticate(step, 'conditional', abortController.signal); + + expect(outcomeSpy).toHaveBeenCalledTimes(1); + expect(credentialsGet).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + signal: abortController.signal, + publicKey: expect.any(Object), + }), + ); + expect(hiddenCallback.getInputValue()).toBe('ok'); + }); +}); diff --git a/packages/journey-client/src/lib/webauthn/webauthn.ts b/packages/journey-client/src/lib/webauthn/webauthn.ts index 28f2be1a05..85a995cb74 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.ts @@ -1,9 +1,9 @@ /* * @forgerock/ping-javascript-sdk * - * index.ts + * webauthn.ts * - * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2024 - 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. @@ -60,6 +60,28 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet * await WebAuthn.authenticate(step); * } * ``` + * + * Conditional mediation (passkey autofill) support: + * + * Conditional mediation is **opt-in** in this SDK via the `authenticate()` parameters. + * + * ```js + * // Optional: feature-detect conditional UI before attempting + * const supportsConditionalUI = await WebAuthn.isConditionalMediationSupported(); + * + * if (supportsConditionalUI) { + * const controller = new AbortController(); + * + * await WebAuthn.authenticate(step, 'conditional', controller.signal); + * } + * ``` + * + * Notes: + * - When `mediation` is `'conditional'`, an `AbortSignal` is required. + * - If conditional mediation is requested but not supported by the browser, + * `authenticate()` throws a `NotSupportedError` and sets the hidden WebAuthn outcome to `unsupported`. + * - To enable passkey autofill, add `autocomplete="webauthn"` to your username field: + * `` */ export abstract class WebAuthn { /** @@ -101,9 +123,15 @@ export abstract class WebAuthn { * Populates the step with the necessary authentication outcome. * * @param step The step that contains WebAuthn authentication data + * @param mediation Optional mediation requirement passed through to `navigator.credentials.get()` + * @param signal Optional AbortSignal passed through to `navigator.credentials.get()` (required when `mediation` is `'conditional'`) * @return The populated step */ - public static async authenticate(step: JourneyStep): Promise { + public static async authenticate( + step: JourneyStep, + mediation?: CredentialMediationRequirement, + signal?: AbortSignal, + ): Promise { const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step); if (hiddenCallback && (metadataCallback || textOutputCallback)) { let outcome: ReturnType; @@ -115,8 +143,27 @@ export abstract class WebAuthn { const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata; publicKey = this.createAuthenticationPublicKey(meta); + if (mediation === 'conditional') { + if (!signal) { + throw new Error( + 'AbortSignal is required for conditional mediation WebAuthn requests', + ); + } + + const isConditionalMediationSupported = await this.isConditionalMediationSupported(); + if (!isConditionalMediationSupported) { + const e = new Error( + 'Conditional mediation was requested, but is not supported by this browser.', + ); + e.name = WebAuthnOutcomeType.NotSupportedError; + throw e; + } + } + credential = await this.getAuthenticationCredential( publicKey as PublicKeyCredentialRequestOptions, + mediation, + signal, ); outcome = this.getAuthenticationOutcome(credential); } else { @@ -126,6 +173,12 @@ export abstract class WebAuthn { } } catch (error) { if (!(error instanceof Error)) throw error; + // In conditional mediation flows, the app may abort an in-flight request when the user + // submits a different method or the step changes. Treat this as cancellation and do not + // mutate the hidden outcome. + if (mediation === 'conditional' && error.name === 'AbortError') { + throw error; + } // NotSupportedError is a special case if (error.name === WebAuthnOutcomeType.NotSupportedError) { hiddenCallback.setInputValue(WebAuthnOutcome.Unsupported); @@ -295,10 +348,14 @@ export abstract class WebAuthn { * Retrieves the credential from the browser Web Authentication API. * * @param options The public key options associated with the request + * @param mediation Optional mediation requirement passed through to `navigator.credentials.get()` + * @param signal Optional AbortSignal passed through to `navigator.credentials.get()` * @return The credential */ public static async getAuthenticationCredential( options: PublicKeyCredentialRequestOptions, + mediation?: CredentialMediationRequirement, + signal?: AbortSignal, ): Promise { // Feature check before we attempt registering a device if (!window.PublicKeyCredential) { @@ -306,10 +363,28 @@ export abstract class WebAuthn { e.name = WebAuthnOutcomeType.NotSupportedError; throw e; } - const credential = await navigator.credentials.get({ publicKey: options }); + + const credential = await navigator.credentials.get({ + publicKey: options, + ...(mediation && { mediation }), + ...(signal && { signal }), + }); return credential as PublicKeyCredential; } + /** + * Determines if the browser supports conditional mediation. + * + * @return Whether the browser supports conditional mediation + */ + public static async isConditionalMediationSupported(): Promise { + return ( + typeof PublicKeyCredential !== 'undefined' && + typeof PublicKeyCredential.isConditionalMediationAvailable === 'function' && + (await PublicKeyCredential.isConditionalMediationAvailable()) + ); + } + /** * Converts an authentication credential into the outcome expected by OpenAM. * From 75c47f416e435fe2535b3d8eaaa76cd19ff7160a Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Wed, 29 Apr 2026 17:20:01 -0700 Subject: [PATCH 2/4] feat(journey-client): automatic server based mediation and abort controller --- .changeset/ready-snakes-sell.md | 4 +- e2e/journey-app/components/webauthn-step.ts | 12 +- .../src/webauthn-device.test.ts | 94 ++++++++++++++-- .../api-report/journey-client.webauthn.api.md | 4 +- .../src/lib/webauthn/interfaces.ts | 1 + .../src/lib/webauthn/webauthn.test.ts | 103 ------------------ .../src/lib/webauthn/webauthn.ts | 93 ++++++++++------ 7 files changed, 157 insertions(+), 154 deletions(-) diff --git a/.changeset/ready-snakes-sell.md b/.changeset/ready-snakes-sell.md index c2f57d110c..8bdb56ab98 100644 --- a/.changeset/ready-snakes-sell.md +++ b/.changeset/ready-snakes-sell.md @@ -4,7 +4,7 @@ Add WebAuthn conditional mediation (passkey autofill) support. -- `WebAuthn.authenticate(step, mediation?, signal?)` forwards `mediation` and `signal` to `navigator.credentials.get`. -- When `mediation` is `'conditional'`, an `AbortSignal` is required. +- `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. diff --git a/e2e/journey-app/components/webauthn-step.ts b/e2e/journey-app/components/webauthn-step.ts index c523f0afa0..fe136b202b 100644 --- a/e2e/journey-app/components/webauthn-step.ts +++ b/e2e/journey-app/components/webauthn-step.ts @@ -66,9 +66,17 @@ export async function handleWebAuthnStep( conditionalInput?.focus(); const isConditionalSupported = await WebAuthn.isConditionalMediationSupported(); - if (isConditionalSupported && conditionalInput) { + + 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, 'conditional', controller.signal) + void WebAuthn.authenticate(step, controller.signal) .then(() => submitForm()) .catch(() => { setError('WebAuthn failed or was cancelled. Please try again or use a different method.'); diff --git a/e2e/journey-suites/src/webauthn-device.test.ts b/e2e/journey-suites/src/webauthn-device.test.ts index b079287685..b79c2518b0 100644 --- a/e2e/journey-suites/src/webauthn-device.test.ts +++ b/e2e/journey-suites/src/webauthn-device.test.ts @@ -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); @@ -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 = @@ -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(); + }); + }); +}); diff --git a/packages/journey-client/api-report/journey-client.webauthn.api.md b/packages/journey-client/api-report/journey-client.webauthn.api.md index 0d2fe54a4a..311a102828 100644 --- a/packages/journey-client/api-report/journey-client.webauthn.api.md +++ b/packages/journey-client/api-report/journey-client.webauthn.api.md @@ -92,7 +92,7 @@ export enum UserVerificationType { // @public export abstract class WebAuthn { - static authenticate(step: JourneyStep, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise; + static authenticate(step: JourneyStep, signal?: AbortSignal): Promise; static createAuthenticationPublicKey(metadata: WebAuthnAuthenticationMetadata): PublicKeyCredentialRequestOptions; static createRegistrationPublicKey(metadata: WebAuthnRegistrationMetadata): PublicKeyCredentialCreationOptions; static getAuthenticationCredential(options: PublicKeyCredentialRequestOptions, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise; @@ -117,6 +117,8 @@ export interface WebAuthnAuthenticationMetadata { // (undocumented) challenge: string; // (undocumented) + mediation?: CredentialMediationRequirement; + // (undocumented) relyingPartyId: string; // (undocumented) supportsJsonResponse?: boolean; diff --git a/packages/journey-client/src/lib/webauthn/interfaces.ts b/packages/journey-client/src/lib/webauthn/interfaces.ts index 33ebee244e..0f5ab52c3f 100644 --- a/packages/journey-client/src/lib/webauthn/interfaces.ts +++ b/packages/journey-client/src/lib/webauthn/interfaces.ts @@ -80,6 +80,7 @@ export interface WebAuthnAuthenticationMetadata { acceptableCredentials?: string; allowCredentials?: string; challenge: string; + mediation?: CredentialMediationRequirement; relyingPartyId: string; timeout: number; userVerification: UserVerificationType; diff --git a/packages/journey-client/src/lib/webauthn/webauthn.test.ts b/packages/journey-client/src/lib/webauthn/webauthn.test.ts index 414304fa80..60714f1434 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.test.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.test.ts @@ -23,7 +23,6 @@ import { webAuthnAuthMetaCallback70StoredUsername, } from './webauthn.mock.data.js'; import { createJourneyStep } from '../step.utils.js'; -import { vi, afterEach, beforeEach, expect } from 'vitest'; describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => { it('should return Registration type with register text-output callbacks', () => { @@ -53,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); }); @@ -99,104 +97,3 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => { expect(stepType).toBe(WebAuthnStepType.Authentication); }); }); - -describe('WebAuthn conditional mediation', () => { - const originalNavigatorCredentials = navigator.credentials; - const originalPublicKeyCredential = globalThis.PublicKeyCredential; - - beforeEach(() => { - Object.defineProperty(globalThis, 'PublicKeyCredential', { - configurable: true, - writable: true, - value: class PublicKeyCredential { - static async isConditionalMediationAvailable(): Promise { - return true; - } - }, - }); - - Object.defineProperty(navigator, 'credentials', { - configurable: true, - value: { - get: vi.fn(), - }, - }); - }); - - afterEach(() => { - Object.defineProperty(navigator, 'credentials', { - configurable: true, - value: originalNavigatorCredentials, - }); - - Object.defineProperty(globalThis, 'PublicKeyCredential', { - configurable: true, - writable: true, - value: originalPublicKeyCredential, - }); - - vi.restoreAllMocks(); - }); - - it('requires an AbortSignal when mediation is conditional', async () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnAuthMetaCallback70 as any); - const hiddenCallback = WebAuthn.getOutcomeCallback(step); - if (!hiddenCallback) throw new Error('Missing hidden callback for test'); - - await expect(WebAuthn.authenticate(step, 'conditional')).rejects.toThrow( - 'AbortSignal is required for conditional mediation WebAuthn requests', - ); - - expect(hiddenCallback.getInputValue()).toContain( - 'AbortSignal is required for conditional mediation WebAuthn requests', - ); - }); - - it('throws NotSupportedError when conditional mediation is not supported by the browser', async () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnAuthMetaCallback70 as any); - const hiddenCallback = WebAuthn.getOutcomeCallback(step); - if (!hiddenCallback) throw new Error('Missing hidden callback for test'); - - const conditionalSupportSpy = vi - .spyOn(WebAuthn, 'isConditionalMediationSupported') - .mockResolvedValue(false); - - await expect( - WebAuthn.authenticate(step, 'conditional', new AbortController().signal), - ).rejects.toMatchObject({ name: 'NotSupportedError' }); - - expect(conditionalSupportSpy).toHaveBeenCalledTimes(1); - expect(hiddenCallback.getInputValue()).toBe('unsupported'); - expect(navigator.credentials.get as unknown as ReturnType).not.toHaveBeenCalled(); - }); - - it('passes mediation + signal through to navigator.credentials.get when supported', async () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnAuthMetaCallback70 as any); - const hiddenCallback = WebAuthn.getOutcomeCallback(step); - if (!hiddenCallback) throw new Error('Missing hidden callback for test'); - - const abortController = new AbortController(); - const credentialsGet = vi - .spyOn(navigator.credentials, 'get') - .mockResolvedValue({} as unknown as Credential); - - const outcomeSpy = vi - .spyOn(WebAuthn, 'getAuthenticationOutcome') - .mockReturnValue('ok' as unknown as ReturnType); - - await WebAuthn.authenticate(step, 'conditional', abortController.signal); - - expect(outcomeSpy).toHaveBeenCalledTimes(1); - expect(credentialsGet).toHaveBeenCalledWith( - expect.objectContaining({ - mediation: 'conditional', - signal: abortController.signal, - publicKey: expect.any(Object), - }), - ); - expect(hiddenCallback.getInputValue()).toBe('ok'); - }); -}); diff --git a/packages/journey-client/src/lib/webauthn/webauthn.ts b/packages/journey-client/src/lib/webauthn/webauthn.ts index 85a995cb74..0c2da99b7f 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.ts @@ -63,7 +63,7 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet * * Conditional mediation (passkey autofill) support: * - * Conditional mediation is **opt-in** in this SDK via the `authenticate()` parameters. + * Conditional mediation is **server-driven** in this SDK via WebAuthn metadata (`meta.mediation`). * * ```js * // Optional: feature-detect conditional UI before attempting @@ -72,18 +72,22 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet * if (supportsConditionalUI) { * const controller = new AbortController(); * - * await WebAuthn.authenticate(step, 'conditional', controller.signal); + * // Optional: provide a signal to cancel an in-flight request + * await WebAuthn.authenticate(step, controller.signal); * } * ``` * * Notes: - * - When `mediation` is `'conditional'`, an `AbortSignal` is required. + * - When server-driven mediation is `'conditional'`, an `AbortSignal` will be used. + * If you don't provide one, the SDK will create one. * - If conditional mediation is requested but not supported by the browser, * `authenticate()` throws a `NotSupportedError` and sets the hidden WebAuthn outcome to `unsupported`. * - To enable passkey autofill, add `autocomplete="webauthn"` to your username field: * `` */ export abstract class WebAuthn { + private static conditionalAbortController?: AbortController; + /** * Determines if the given step is a WebAuthn step. * @@ -119,36 +123,46 @@ export abstract class WebAuthn { } } + /** + * Determines if the browser supports conditional mediation. + * + * @return Whether the browser supports conditional mediation + */ + public static async isConditionalMediationSupported(): Promise { + return ( + typeof PublicKeyCredential !== 'undefined' && + typeof PublicKeyCredential.isConditionalMediationAvailable === 'function' && + (await PublicKeyCredential.isConditionalMediationAvailable()) + ); + } + /** * Populates the step with the necessary authentication outcome. * * @param step The step that contains WebAuthn authentication data - * @param mediation Optional mediation requirement passed through to `navigator.credentials.get()` - * @param signal Optional AbortSignal passed through to `navigator.credentials.get()` (required when `mediation` is `'conditional'`) + * @param signal Optional AbortSignal passed through to `navigator.credentials.get()` * @return The populated step */ - public static async authenticate( - step: JourneyStep, - mediation?: CredentialMediationRequirement, - signal?: AbortSignal, - ): Promise { + public static async authenticate(step: JourneyStep, signal?: AbortSignal): Promise { const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step); if (hiddenCallback && (metadataCallback || textOutputCallback)) { let outcome: ReturnType; let credential: PublicKeyCredential | null = null; + let mediation: CredentialMediationRequirement | undefined; try { let publicKey: PublicKeyCredentialRequestOptions; if (metadataCallback) { const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata; + mediation = meta.mediation; publicKey = this.createAuthenticationPublicKey(meta); if (mediation === 'conditional') { - if (!signal) { - throw new Error( - 'AbortSignal is required for conditional mediation WebAuthn requests', - ); - } + // Abort any prior conditional request started by the SDK. + // (If the caller provides their own signal, we still abort the prior SDK-owned one.) + this.conditionalAbortController?.abort(); + + const abortSignal = signal ?? this.createAbortController().signal; const isConditionalMediationSupported = await this.isConditionalMediationSupported(); if (!isConditionalMediationSupported) { @@ -158,14 +172,21 @@ export abstract class WebAuthn { e.name = WebAuthnOutcomeType.NotSupportedError; throw e; } - } - credential = await this.getAuthenticationCredential( - publicKey as PublicKeyCredentialRequestOptions, - mediation, - signal, - ); - outcome = this.getAuthenticationOutcome(credential); + credential = await this.getAuthenticationCredential( + publicKey as PublicKeyCredentialRequestOptions, + mediation, + abortSignal, + ); + outcome = this.getAuthenticationOutcome(credential); + } else { + credential = await this.getAuthenticationCredential( + publicKey as PublicKeyCredentialRequestOptions, + mediation, + signal, + ); + outcome = this.getAuthenticationOutcome(credential); + } } else { throw new Error( 'No metadata callback found for WebAuthn authentication. Please disable JavaScript in server node.', @@ -372,19 +393,6 @@ export abstract class WebAuthn { return credential as PublicKeyCredential; } - /** - * Determines if the browser supports conditional mediation. - * - * @return Whether the browser supports conditional mediation - */ - public static async isConditionalMediationSupported(): Promise { - return ( - typeof PublicKeyCredential !== 'undefined' && - typeof PublicKeyCredential.isConditionalMediationAvailable === 'function' && - (await PublicKeyCredential.isConditionalMediationAvailable()) - ); - } - /** * Converts an authentication credential into the outcome expected by OpenAM. * @@ -515,7 +523,7 @@ export abstract class WebAuthn { challenge: Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)).buffer, timeout, // only add key-value pair if proper value is provided - ...(allowCredentialsValue && { allowCredentials: allowCredentialsValue }), + ...(allowCredentialsValue?.length ? { allowCredentials: allowCredentialsValue } : {}), ...(userVerification && { userVerification }), ...(rpId && { rpId }), }; @@ -572,6 +580,19 @@ export abstract class WebAuthn { }, }; } + + /** + * Creates and stores an SDK-owned {@link AbortController} for conditional mediation, + * aborting any previous SDK-owned controller first. + * + * @return A new AbortController for conditional mediation. + */ + private static createAbortController(): AbortController { + this.conditionalAbortController?.abort(); + const abortController = new AbortController(); + this.conditionalAbortController = abortController; + return abortController; + } } export { WebAuthnOutcome, WebAuthnStepType }; From 72550dab44cc865fc1768886e775e351fee3bffe Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:28:05 +0000 Subject: [PATCH 3/4] feat(journey-client): automatic server based mediation and abort controller [Self-Healing CI Rerun] From 8d314c62bf01b2e21087abf3fee573ec1279f354 Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:55:29 +0000 Subject: [PATCH 4/4] feat(journey-client): automatic server based mediation and abort controller [Self-Healing CI Rerun]