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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pink-jars-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@forgerock/journey-client': minor
'@forgerock/oidc-client': minor
---

Add AM server-side conditional mediation check to `WebAuthn.isConditionalMediationSupported` and export custom storage config types from oidc-client
6 changes: 1 addition & 5 deletions e2e/journey-app/components/text-input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand All @@ -21,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
3 changes: 1 addition & 2 deletions e2e/journey-app/components/validated-username.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand All @@ -20,7 +20,6 @@ export default function validatedUsernameComponent(
input.type = 'text';
input.id = collectorKey;
input.name = collectorKey;
input.setAttribute('autocomplete', 'webauthn');

journeyEl?.appendChild(label);
journeyEl?.appendChild(input);
Expand Down
55 changes: 26 additions & 29 deletions e2e/journey-app/components/webauthn-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,42 +57,39 @@ 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.');
});
const hasPasswordInput = journeyEl.querySelector('input[type="password"]') !== null;
const isPasskeyOnly = !hasPasswordInput;

// True only when the browser supports conditional mediation AND AM requested it
// (meta.mediation === 'conditional').
const isConditionalMediation = await WebAuthn.isConditionalMediationSupported(step);

const hasPasskeyAutocompleteValues = callbacks.some((callback) => {
const values = callback.getOutputByName<string[]>('autocompleteValues', []);
return values.includes('username') && values.includes('webauthn');
});
if (isConditionalMediation && hasPasskeyAutocompleteValues) {
journeyEl.querySelectorAll('input[type="text"]').forEach((input) => {
input.setAttribute('autocomplete', 'username webauthn');
});
}

// Only auto-invoke WebAuthn when the step offers no normal login path (passkey-only step)
// or when AM requested conditional mediation (silent autofill, no popup). On a hybrid page
// with a password field, WebAuthn must not pop a modal — the user logs in normally.
if (!isPasskeyOnly && !isConditionalMediation) {
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 };
}
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.');
});

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

Expand Down
38 changes: 38 additions & 0 deletions e2e/journey-suites/src/WEBAUTHN_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,41 @@ The assertion checks that the status message for deleted device contains the sam
2. Credential ids are passed around with query params, which makes it easy to replicate tests without any dependency on external storage.
3. The test provides freedom to choose how to resolve the uuid depending on the app, so the app can decide whether to retrieve the uuid through OIDC, session, or another way.
4. The test lets the app decide how to handle app-specific UI, so this pattern is framework agnostic and can be used by any app that supports Playwright, whether it's React, Vue, or Svelte.

## Passkey Autofill Journeys

The `TEST_AutofillPasskeyWebAuthn_*` journeys form a 2x2x2 matrix that tests the presence and absence of three AM-side configuration toggles on the WebAuthn authentication node:

| Toggle | Journey name part | AM signal |
| --------------------------- | ----------------- | --------------------------------------------------------------------------------------- |
| Passkey autocomplete values | `autocomplete` | Username callback output `autocompleteValues` contains `username` and `webauthn` |
| Conditional mediation | `conditional` | WebAuthn metadata `mediation: 'conditional'` (silent passkey autofill) |
| Authentication button | `button` | WebAuthn node config `conditionalManualButton` (manual "Sign in with a passkey" button) |

The journey named `TEST_AutofillPasskeyWebAuthn_disabled` is the 000 case: all three toggles off. The other seven combine the toggles, named in the order `autocomplete`, `conditional`, `button` (for example, `TEST_AutofillPasskeyWebAuthn_autocomplete_conditional_button` is the 111 case).

Each journey's page is a hybrid login page: a username collector, a password collector, and a WebAuthn authentication node on the same step. The journey app follows two rules for these pages:

1. If the step has a password field (a normal login path), WebAuthn never pops a modal. The user logs in normally; passkeys surface only through the autofill dropdown.
2. WebAuthn is auto-invoked only when AM requested conditional mediation (silent autofill). Passkey-only steps with no password field (like `TEST_WebAuthnAuthentication`) always auto-prompt, since there is no other way to continue.

| Journey | Autocomplete values | Conditional mediation | Button | Expected app behavior |
| ---------------------------------- | ------------------- | --------------------- | ------- | --------------------------------------------------------------------------------------------------------------- |
| `_disabled` | absent | absent | absent | Plain login form; user logs in with username and password; no WebAuthn prompt |
| `_autocomplete` | present | absent | absent | Plain login form; no decoration because conditional mediation is not active; user logs in normally |
| `_conditional` | absent | present | absent | Silent conditional WebAuthn fires on render; username input is not decorated (no autofill signal from AM) |
| `_conditional_button` | absent | present | present | Same as `_conditional`; the manual button is not rendered by the app (see note below) |
| `_autocomplete_conditional` | present | present | absent | Full passkey autofill: input decorated with `autocomplete="username webauthn"`, silent authentication, no popup |
| `_autocomplete_conditional_button` | present | present | present | Same as `_autocomplete_conditional` (see note below) |
| `_autocomplete_button` | present | absent | present | Plain login form; user logs in normally (see note below) |
| `_button` | absent | absent | present | Plain login form; user logs in normally (see note below) |

> **Note:** the authentication button feature is not yet supported in the SDK. The `button` toggle is part of the AM journey configuration (node field `conditionalManualButton`), and the journeys exist to cover the matrix, but the journey app (and the SDK) do not render the manual "Sign in with a passkey" button until that feature ships. The `*_button` journeys currently behave like their counterparts without the button part.

## Journey Prereqs (Passkey Autofill)

The autofill journeys reuse the same prereqs as the rest of the WebAuthn tests:

1. Chromium is required, and a CDP virtual authenticator with resident keys, user verification, and automatic presence simulation.
2. Register a passkey first via `TEST_WebAuthn-Registration`, since each authentication journey needs an existing credential.
3. Clear cookies and storage between registration and authentication, since the SDK persists session state in localStorage.
34 changes: 17 additions & 17 deletions e2e/journey-suites/src/webauthn-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,7 @@ 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 }) => {
test('registers a passkey then authenticates via conditional autofill', async ({ page }) => {
const { clickButton, navigate } = asyncEvents(page);

await test.step('Register a WebAuthn credential', async () => {
Expand All @@ -161,24 +159,26 @@ test.describe('WebAuthn conditional autofill (passkey)', () => {
});

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.
// Ensure we are not reusing an existing AM session. Cookies alone aren't enough:
// the SDK persists session state in localStorage, so clear that too, or the app
// renders the stale "Complete" screen instead of starting a new journey.
await page.context().clearCookies();
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});

// 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,
});
//
// We don't assert the intermediate "pending" autocomplete=webauthn input here: with
// automaticPresenceSimulation on, Chrome's virtual authenticator resolves a conditional
// get() almost immediately (no native picker UI to wait on like a real authenticator),
// so the app reaches "Complete" faster than a post-navigation assertion can reliably
// observe that input. We assert the completed outcome instead.
await navigate(
'/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional',
);

// With a virtual authenticator configured for automatic presence simulation, this should
// complete without any manual click.
Expand Down
Loading
Loading