diff --git a/.changeset/webauthn-passkey-autocomplete.md b/.changeset/webauthn-passkey-autocomplete.md new file mode 100644 index 0000000000..2914c64bd9 --- /dev/null +++ b/.changeset/webauthn-passkey-autocomplete.md @@ -0,0 +1,5 @@ +--- +'@forgerock/journey-client': minor +--- + +Add `WebAuthn.hasPasskeyAutocompleteValues()` to detect when a step's `NameCallback` carries both `username` and `webauthn` autocomplete values, signalling that the username input should be decorated with `autocomplete="username webauthn"` for passkey autofill. diff --git a/e2e/journey-app/components/text-input.ts b/e2e/journey-app/components/text-input.ts index 9e46a81483..4ec384d124 100644 --- a/e2e/journey-app/components/text-input.ts +++ b/e2e/journey-app/components/text-input.ts @@ -21,10 +21,6 @@ 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/webauthn-step.ts b/e2e/journey-app/components/webauthn-step.ts index fe136b202b..4cbbaf19a7 100644 --- a/e2e/journey-app/components/webauthn-step.ts +++ b/e2e/journey-app/components/webauthn-step.ts @@ -57,42 +57,29 @@ export async function handleWebAuthnStep( 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 }; + // Fire WebAuthn without awaiting so handleWebAuthnStep returns and main.ts can render the + // Submit button (traditional login stays available in every case). The SDK decides silent + // (conditional mediation) vs. modal popup internally from meta.mediation — the app doesn't. + 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.'); + }); + + // hasPasskeyAutocompleteValues reflects the AM admin's decision to emit username+webauthn + // autocomplete values on the NameCallback — the signal that this step is a passkey-autofill + // step. Only then do we decorate the username input. + if (isConditionalSupported && WebAuthn.hasPasskeyAutocompleteValues(step)) { + journeyEl.querySelectorAll('input[type="text"]').forEach((input) => { + input.setAttribute('autocomplete', 'username webauthn'); + }); } - // 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 }; } diff --git a/e2e/journey-suites/src/webauthn-device.test.ts b/e2e/journey-suites/src/webauthn-device.test.ts index b79c2518b0..52538a06bc 100644 --- a/e2e/journey-suites/src/webauthn-device.test.ts +++ b/e2e/journey-suites/src/webauthn-device.test.ts @@ -6,7 +6,7 @@ */ import { expect, test } from '@playwright/test'; -import type { CDPSession } from '@playwright/test'; +import type { CDPSession, Page } from '@playwright/test'; import { asyncEvents } from './utils/async-events.js'; import { username, password } from './utils/demo-user.js'; @@ -113,11 +113,9 @@ test.describe('WebAuthn conditional autofill (passkey)', () => { 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', @@ -136,54 +134,114 @@ test.describe('WebAuthn conditional autofill (passkey)', () => { 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 }) => { + // The autofill (conditional) cases fire WebAuthn on render. With automatic presence simulation + // on, the virtual authenticator resolves that request immediately and the page logs in before + // the transient autofill UI (decorated input, manual button) can be asserted. Disabling presence + // simulation lets the request hang so the rendered UI can be checked deterministically. + async function setPresenceSimulation(enabled: boolean): Promise { + await cdp.send('WebAuthn.setAutomaticPresenceSimulation', { authenticatorId, enabled }); + } + + async function registerPasskey( + page: Page, + navigate: (route: string) => Promise, + clickButton: (text: string, endpoint: string) => Promise, + ): Promise { + const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + }); + expect(initialCredentials).toHaveLength(0); + + 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); + } + + // 000: no autocomplete values, default mediation, no button + // AM does not signal passkey autofill. Falls back to traditional (prompted) WebAuthn. + test('disabled (000) — falls back to traditional WebAuthn, no autofill input, no manual button', async ({ + page, + }) => { const { clickButton, navigate } = asyncEvents(page); + await test.step('Register', async () => { + await registerPasskey(page, navigate, clickButton); + }); + await test.step('Authenticate', async () => { + await page.context().clearCookies(); + await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_disabled'); - 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.locator('input[autocomplete="username webauthn"]')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0); 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. + // 100: autocomplete values present, default mediation, no button + // AM emits autocomplete values so the conditional path is entered and the autofill input is + // rendered. No manual button because manualButtonEnabled is false. + test('autocomplete only (100) — autofill input rendered, no manual button', async ({ page }) => { + const { clickButton, navigate } = asyncEvents(page); + await test.step('Register', async () => { + await registerPasskey(page, navigate, clickButton); + }); + await test.step('Authenticate', async () => { await page.context().clearCookies(); + // Hold the fired conditional request open so the rendered autofill UI can be asserted. + await setPresenceSimulation(false); + await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete'); - // 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(); + await expect(page.locator('input[autocomplete="username webauthn"]')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0); + }); + }); - // Re-enable presence simulation so the in-flight WebAuthn request can resolve. - await cdp.send('WebAuthn.setAutomaticPresenceSimulation', { - authenticatorId, - enabled: true, - }); + // 010: no autocomplete values, conditional mediation, no button + // AM uses conditional mediation internally but did not emit autocomplete values. Journey-app + // never enters the conditional path — falls back to traditional WebAuthn. + test('conditional only (010) — falls back to traditional WebAuthn, no autofill input, no manual button', async ({ + page, + }) => { + const { clickButton, navigate } = asyncEvents(page); + await test.step('Register', async () => { + await registerPasskey(page, navigate, clickButton); + }); + await test.step('Authenticate', async () => { + await page.context().clearCookies(); + await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_conditional'); - // With a virtual authenticator configured for automatic presence simulation, this should - // complete without any manual click. + await expect(page.locator('input[autocomplete="username webauthn"]')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible(); + }); + }); + + // 110: autocomplete values, conditional mediation, no button + // Both signals for passkey autofill are present. Silent authentication, no manual button. + test('autocomplete + conditional (110) — silent passkey auth, no manual button', async ({ + page, + }) => { + const { clickButton, navigate } = asyncEvents(page); + await test.step('Register', async () => { + await registerPasskey(page, navigate, clickButton); + }); + await test.step('Authenticate', async () => { + await page.context().clearCookies(); + // Hold the fired conditional request open so the rendered autofill UI can be asserted. + // Silent completion depends on real autofill-dropdown interaction that the virtual + // authenticator cannot drive deterministically, so we assert the rendered UI only. + await setPresenceSimulation(false); + await navigate( + '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional', + ); + + await expect(page.locator('input[autocomplete="username webauthn"]')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in with a passkey' })).toHaveCount(0); }); }); }); 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 311a102828..e7984df222 100644 --- a/packages/journey-client/api-report/journey-client.webauthn.api.md +++ b/packages/journey-client/api-report/journey-client.webauthn.api.md @@ -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 hasPasskeyAutocompleteValues(step: JourneyStep): boolean; static isConditionalMediationSupported(): Promise; static register(step: JourneyStep, deviceName?: T): Promise; } diff --git a/packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts b/packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts index dcb4809860..ad36d1c231 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts @@ -441,6 +441,93 @@ export const webAuthnRegMetaCallbackJsonResponse = { ], }; +export const webAuthnAuthMetaCallbackWithPasskeyAutofill = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.NameCallback, + output: [ + { name: 'prompt', value: 'User Name' }, + { name: 'autocompleteValues', value: ['username', 'webauthn'] }, + ], + input: [{ name: 'IDToken1', value: '' }], + }, + { + type: callbackType.MetadataCallback, + output: [ + { + name: 'data', + value: { + _action: 'webauthn_authentication', + challenge: 'tbon5Eo3gdE0KPRkoT8H9ek59OoL1/q4iUQSlzV2zhI=', + allowCredentials: '', + _allowCredentials: [], + timeout: '60000', + userVerification: 'preferred', + conditional: true, + mediation: 'conditional', + relyingPartyId: 'rpId: "localhost",', + _relyingPartyId: 'localhost', + _type: 'WebAuthn', + supportsJsonResponse: true, + }, + }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken4', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnAuthMetaCallbackWithoutPasskeyAutofill = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.NameCallback, + output: [ + { name: 'prompt', value: 'User Name' }, + { name: 'autocompleteValues', value: ['username'] }, + ], + input: [{ name: 'IDToken1', value: '' }], + }, + { + type: callbackType.MetadataCallback, + output: [ + { + name: 'data', + value: { + _action: 'webauthn_authentication', + challenge: 'tbon5Eo3gdE0KPRkoT8H9ek59OoL1/q4iUQSlzV2zhI=', + allowCredentials: '', + _allowCredentials: [], + timeout: '60000', + userVerification: 'preferred', + mediation: 'conditional', + relyingPartyId: 'rpId: "localhost",', + _relyingPartyId: 'localhost', + _type: 'WebAuthn', + supportsJsonResponse: true, + }, + }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken4', value: 'webAuthnOutcome' }], + }, + ], +}; + export const webAuthnAuthJSCallback70StoredUsername = { authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', callbacks: [ diff --git a/packages/journey-client/src/lib/webauthn/webauthn.test.ts b/packages/journey-client/src/lib/webauthn/webauthn.test.ts index 60714f1434..a568283517 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.test.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.test.ts @@ -8,6 +8,8 @@ * of the MIT license. See the LICENSE file for details. */ +import type { Step } from '@forgerock/sdk-types'; + import { WebAuthnStepType } from './enums.js'; import { WebAuthn } from './webauthn.js'; import { @@ -21,20 +23,20 @@ import { webAuthnAuthJSCallback70StoredUsername, webAuthnRegMetaCallback70StoredUsername, webAuthnAuthMetaCallback70StoredUsername, + webAuthnAuthMetaCallbackWithPasskeyAutofill, + webAuthnAuthMetaCallbackWithoutPasskeyAutofill, } from './webauthn.mock.data.js'; import { createJourneyStep } from '../step.utils.js'; describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => { it('should return Registration type with register text-output callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnRegJSCallback653 as any); + const step = createJourneyStep(webAuthnRegJSCallback653 as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Registration); }); it('should return Authentication type with authenticate text-output callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnAuthJSCallback653 as any); + const step = createJourneyStep(webAuthnAuthJSCallback653 as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Authentication); }); @@ -42,29 +44,25 @@ describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => { describe('Test FRWebAuthn class with 7.0 "Passwordless"', () => { it('should return Registration type with register text-output callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnRegJSCallback70 as any); + const step = createJourneyStep(webAuthnRegJSCallback70 as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Registration); }); it('should return Authentication type with authenticate text-output callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnAuthJSCallback70 as any); + const step = createJourneyStep(webAuthnAuthJSCallback70 as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Authentication); }); it('should return Registration type with register metadata callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnRegMetaCallback70 as any); + const step = createJourneyStep(webAuthnRegMetaCallback70 as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Registration); }); it('should return Authentication type with authenticate metadata callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnAuthMetaCallback70 as any); + const step = createJourneyStep(webAuthnAuthMetaCallback70 as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Authentication); }); @@ -72,28 +70,41 @@ describe('Test FRWebAuthn class with 7.0 "Passwordless"', () => { describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => { it('should return Registration type with register text-output callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnRegJSCallback70StoredUsername as any); + const step = createJourneyStep(webAuthnRegJSCallback70StoredUsername as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Registration); }); it('should return Authentication type with authenticate text-output callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnAuthJSCallback70StoredUsername as any); + const step = createJourneyStep(webAuthnAuthJSCallback70StoredUsername as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Authentication); }); it('should return Registration type with register metadata callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnRegMetaCallback70StoredUsername as any); + const step = createJourneyStep(webAuthnRegMetaCallback70StoredUsername as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Registration); }); it('should return Authentication type with authenticate metadata callbacks', () => { - // eslint-disable-next-line - const step = createJourneyStep(webAuthnAuthMetaCallback70StoredUsername as any); + const step = createJourneyStep(webAuthnAuthMetaCallback70StoredUsername as Step); const stepType = WebAuthn.getWebAuthnStepType(step); expect(stepType).toBe(WebAuthnStepType.Authentication); }); }); + +describe('WebAuthn.hasPasskeyAutocompleteValues', () => { + it('returns true when a NameCallback has both "username" and "webauthn" autocomplete values', () => { + const step = createJourneyStep(webAuthnAuthMetaCallbackWithPasskeyAutofill as Step); + expect(WebAuthn.hasPasskeyAutocompleteValues(step)).toBe(true); + }); + + it('returns false when a NameCallback is missing the "webauthn" autocomplete value', () => { + const step = createJourneyStep(webAuthnAuthMetaCallbackWithoutPasskeyAutofill as Step); + expect(WebAuthn.hasPasskeyAutocompleteValues(step)).toBe(false); + }); + + it('returns false when there is no NameCallback in the step', () => { + const step = createJourneyStep(webAuthnAuthMetaCallback70 as Step); + expect(WebAuthn.hasPasskeyAutocompleteValues(step)).toBe(false); + }); +}); diff --git a/packages/journey-client/src/lib/webauthn/webauthn.ts b/packages/journey-client/src/lib/webauthn/webauthn.ts index 0c2da99b7f..842596e3aa 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.ts @@ -30,6 +30,7 @@ import type { WebAuthnTextOutputRegistration, } from './interfaces.js'; import type { MetadataCallback } from '../callbacks/metadata-callback.js'; +import type { NameCallback } from '../callbacks/name-callback.js'; import type { TextOutputCallback } from '../callbacks/text-output-callback.js'; // :::::: @@ -82,8 +83,8 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet * 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: - * `` + * - To enable passkey autofill, add `autocomplete="username webauthn"` to your username field: + * `` */ export abstract class WebAuthn { private static conditionalAbortController?: AbortController; @@ -136,6 +137,24 @@ export abstract class WebAuthn { ); } + /** + * Returns true if the step contains a NameCallback with both "username" and "webauthn" + * autocomplete values. + * + * @param step The step to evaluate + * @return Whether the step has passkey autocomplete values + */ + public static hasPasskeyAutocompleteValues(step: JourneyStep): boolean { + const nameCallbacks = step.getCallbacksOfType(callbackType.NameCallback); + if (!nameCallbacks.length) { + return false; + } + return nameCallbacks.some((cb: NameCallback) => { + const values = cb.getOutputByName('autocompleteValues', []); + return values.includes('username') && values.includes('webauthn'); + }); + } + /** * Populates the step with the necessary authentication outcome. *