diff --git a/.changeset/ready-snakes-sell.md b/.changeset/ready-snakes-sell.md new file mode 100644 index 0000000000..8bdb56ab98 --- /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, 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/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..fe136b202b 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,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 { + 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 }; + } + + // 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/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 dfa9bd9f70..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,10 +92,10 @@ export enum UserVerificationType { // @public export abstract class WebAuthn { - static authenticate(step: JourneyStep): Promise; + static authenticate(step: JourneyStep, 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; } @@ -116,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 6d8829daf9..60714f1434 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. */ @@ -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); }); diff --git a/packages/journey-client/src/lib/webauthn/webauthn.ts b/packages/journey-client/src/lib/webauthn/webauthn.ts index 28f2be1a05..0c2da99b7f 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,8 +60,34 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet * await WebAuthn.authenticate(step); * } * ``` + * + * Conditional mediation (passkey autofill) support: + * + * Conditional mediation is **server-driven** in this SDK via WebAuthn metadata (`meta.mediation`). + * + * ```js + * // Optional: feature-detect conditional UI before attempting + * const supportsConditionalUI = await WebAuthn.isConditionalMediationSupported(); + * + * if (supportsConditionalUI) { + * const controller = new AbortController(); + * + * // Optional: provide a signal to cancel an in-flight request + * await WebAuthn.authenticate(step, controller.signal); + * } + * ``` + * + * Notes: + * - 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. * @@ -97,28 +123,70 @@ 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 signal Optional AbortSignal passed through to `navigator.credentials.get()` * @return The populated step */ - public static async authenticate(step: JourneyStep): 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); - credential = await this.getAuthenticationCredential( - publicKey as PublicKeyCredentialRequestOptions, - ); - outcome = this.getAuthenticationOutcome(credential); + if (mediation === 'conditional') { + // 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) { + 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, + 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.', @@ -126,6 +194,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 +369,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,7 +384,12 @@ 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; } @@ -440,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 }), }; @@ -497,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 };