Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/webauthn-passkey-autocomplete.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 0 additions & 4 deletions e2e/journey-app/components/text-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
47 changes: 17 additions & 30 deletions e2e/journey-app/components/webauthn-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
});
Comment on lines +68 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant files and show compact structure first.
git ls-files | rg '(^|/)e2e/journey-app/components/webauthn-step\.ts$|(^|/)webauthn|(^|/)passkey|(^|/)auth' || true

echo '--- outline: e2e/journey-app/components/webauthn-step.ts ---'
ast-grep outline e2e/journey-app/components/webauthn-step.ts --view expanded || true

# Read the relevant slice with line numbers.
echo '--- webauthn-step.ts (selected lines) ---'
sed -n '1,180p' e2e/journey-app/components/webauthn-step.ts | cat -n

# Search for the controller abort and authenticate implementation/usages.
echo '--- search: controller.abort / AbortError / WebAuthn.authenticate ---'
rg -n "controller\.abort|AbortError|WebAuthn\.authenticate|mediation === 'conditional'|conditional" e2e/journey-app . --glob '!**/node_modules/**' || true

# If WebAuthn.authenticate is in a nearby file, inspect it.
AUTH_FILE=$(rg -l "class WebAuthn|function authenticate|const WebAuthn|authenticate\\(" e2e/journey-app . --glob '!**/node_modules/**' | head -n 20 | tr '\n' ' ')
echo "--- candidate auth files: ${AUTH_FILE} ---"
for f in $AUTH_FILE; do
  echo "--- $f ---"
  sed -n '1,260p' "$f" | cat -n | sed -n '1,260p'
done

Repository: ForgeRock/ping-javascript-sdk

Length of output: 50386


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- packages/journey-client/src/lib/webauthn/webauthn.ts (relevant slices) ---'
sed -n '160,270p' packages/journey-client/src/lib/webauthn/webauthn.ts | cat -n
echo
sed -n '610,670p' packages/journey-client/src/lib/webauthn/webauthn.ts | cat -n

echo '--- e2e/journey-suites/src/webauthn-device.test.ts (conditional/passkey cases) ---'
sed -n '300,380p' e2e/journey-suites/src/webauthn-device.test.ts | cat -n

Repository: ForgeRock/ping-javascript-sdk

Length of output: 11725


Ignore AbortError from the background conditional request
e2e/journey-app/components/webauthn-step.ts:68-72 will show “WebAuthn failed or was cancelled…” when the passkey button intentionally aborts the in-flight conditional request. Skip setError(...) for AbortError here so the manual flow can continue cleanly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/journey-app/components/webauthn-step.ts` around lines 68 - 72, The
WebAuthn error handler in WebAuthn.authenticate should not surface an error for
the intentional conditional-request abort. Update the catch branch in
webauthn-step’s submit flow to inspect the thrown error and skip setError when
it is an AbortError, while still showing the existing failure message for real
authentication problems so the manual fallback can continue cleanly.


// 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 };
}

Expand Down
140 changes: 99 additions & 41 deletions e2e/journey-suites/src/webauthn-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand All @@ -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<void> {
await cdp.send('WebAuthn.setAutomaticPresenceSimulation', { authenticatorId, enabled });
}

async function registerPasskey(
page: Page,
navigate: (route: string) => Promise<void>,
clickButton: (text: string, endpoint: string) => Promise<void>,
): Promise<void> {
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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 hasPasskeyAutocompleteValues(step: JourneyStep): boolean;
static isConditionalMediationSupported(): Promise<boolean>;
static register<T extends string = ''>(step: JourneyStep, deviceName?: T): Promise<JourneyStep>;
}
Expand Down
87 changes: 87 additions & 0 deletions packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading
Loading