Skip to content

Commit 1028da7

Browse files
committed
feat(journey-client): automatic server based mediation and abort controller
1 parent 31044cf commit 1028da7

7 files changed

Lines changed: 161 additions & 158 deletions

File tree

.changeset/ready-snakes-sell.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Add WebAuthn conditional mediation (passkey autofill) support.
66

7-
- `WebAuthn.authenticate(step, mediation?, signal?)` forwards `mediation` and `signal` to `navigator.credentials.get`.
8-
- When `mediation` is `'conditional'`, an `AbortSignal` is required.
7+
- `WebAuthn.authenticate(step, signal?)` derives mediation from WebAuthn metadata (`meta.mediation`).
8+
- When `meta.mediation` is `'conditional'`, an `AbortSignal` is used (caller-provided if present, otherwise created by the SDK).
99
- If conditional mediation is requested but not supported, `authenticate()` throws `NotSupportedError` (and the existing error handling sets the hidden outcome to `unsupported`).
1010
- Adds `WebAuthn.isConditionalMediationSupported()` helper, docs, and unit tests.

e2e/journey-app/components/webauthn-step.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,17 @@ export async function handleWebAuthnStep(
6666
conditionalInput?.focus();
6767

6868
const isConditionalSupported = await WebAuthn.isConditionalMediationSupported();
69-
if (isConditionalSupported && conditionalInput) {
69+
70+
const metadataCallback = WebAuthn.getMetadataCallback(step);
71+
const meta = metadataCallback?.getData<{
72+
mediation?: CredentialMediationRequirement;
73+
conditional?: boolean;
74+
}>();
75+
const isConditionalMediation = meta?.mediation === 'conditional' || meta?.conditional === true;
76+
77+
if (isConditionalSupported && conditionalInput && isConditionalMediation) {
7078
const controller = new AbortController();
71-
void WebAuthn.authenticate(step, 'conditional', controller.signal)
79+
void WebAuthn.authenticate(step, controller.signal)
7280
.then(() => submitForm())
7381
.catch(() => {
7482
setError('WebAuthn failed or was cancelled. Please try again or use a different method.');

e2e/journey-suites/src/webauthn-device.test.ts

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ const WEBAUTHN_CREDENTIAL_ID_QUERY_PARAM = 'webauthnCredentialId';
1515
test.use({ browserName: 'chromium' });
1616

1717
test.describe('WebAuthn register, authenticate, and delete device', () => {
18-
let cdp: CDPSession | undefined;
19-
let authenticatorId: string | undefined;
18+
let cdp!: CDPSession;
19+
let authenticatorId!: string;
2020

2121
test.beforeEach(async ({ context, page }) => {
2222
cdp = await context.newCDPSession(page);
@@ -35,17 +35,11 @@ test.describe('WebAuthn register, authenticate, and delete device', () => {
3535
});
3636

3737
test.afterEach(async () => {
38-
if (cdp && authenticatorId) {
39-
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
40-
await cdp.send('WebAuthn.disable');
41-
}
38+
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
39+
await cdp.send('WebAuthn.disable');
4240
});
4341

4442
test('should register, authenticate, and delete a device', async ({ page }) => {
45-
if (!cdp || !authenticatorId) {
46-
throw new Error('Virtual authenticator was not initialized');
47-
}
48-
4943
const { clickButton, navigate } = asyncEvents(page);
5044

5145
const registeredCredentialId =
@@ -113,3 +107,70 @@ test.describe('WebAuthn register, authenticate, and delete device', () => {
113107
});
114108
});
115109
});
110+
111+
test.describe('WebAuthn conditional autofill (passkey)', () => {
112+
let cdp!: CDPSession;
113+
let authenticatorId!: string;
114+
115+
test.beforeEach(async ({ context, page }) => {
116+
// Chromium + CDP WebAuthn virtual authenticator is required for repeatable automation.
117+
cdp = await context.newCDPSession(page);
118+
await cdp.send('WebAuthn.enable');
119+
120+
// Configure a platform authenticator with resident keys and auto presence simulation.
121+
const response = await cdp.send('WebAuthn.addVirtualAuthenticator', {
122+
options: {
123+
protocol: 'ctap2',
124+
transport: 'internal',
125+
hasResidentKey: true,
126+
hasUserVerification: true,
127+
isUserVerified: true,
128+
automaticPresenceSimulation: true,
129+
},
130+
});
131+
authenticatorId = response.authenticatorId;
132+
});
133+
134+
test.afterEach(async () => {
135+
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
136+
await cdp.send('WebAuthn.disable');
137+
});
138+
139+
test('registers a passkey then authenticates via conditional autofill', async ({ page }) => {
140+
const { clickButton, navigate } = asyncEvents(page);
141+
142+
await test.step('Register a WebAuthn credential', async () => {
143+
// Start with an empty virtual authenticator.
144+
const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', {
145+
authenticatorId,
146+
});
147+
expect(initialCredentials).toHaveLength(0);
148+
149+
// Run a registration journey that creates a credential in the authenticator.
150+
await navigate('/?clientId=tenant&journey=TEST_WebAuthn-Registration');
151+
await expect(page.getByLabel('User Name')).toBeVisible();
152+
await page.getByLabel('User Name').fill(username);
153+
await page.getByLabel('Password').fill(password);
154+
await clickButton('Submit', '/authenticate');
155+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
156+
157+
const { credentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
158+
expect(credentials.length).toBeGreaterThan(0);
159+
});
160+
161+
await test.step('Authenticate using conditional UI / passkey autofill', async () => {
162+
// Ensure we are not reusing an existing AM session.
163+
// This makes the test exercise passkey auth, not cookie auth.
164+
await page.context().clearCookies();
165+
166+
// This journey emits conditional mediation metadata and should complete via background
167+
// WebAuthn (journey-app triggers the request and submits when a credential is returned).
168+
await navigate('/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn');
169+
170+
// With a virtual authenticator configured for automatic presence simulation, this should
171+
// complete without any manual click.
172+
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
173+
await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible();
174+
});
175+
});
176+
});

packages/journey-client/api-report/journey-client.webauthn.api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export enum UserVerificationType {
9292

9393
// @public
9494
export abstract class WebAuthn {
95-
static authenticate(step: JourneyStep, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise<JourneyStep>;
95+
static authenticate(step: JourneyStep, signal?: AbortSignal): Promise<JourneyStep>;
9696
static createAuthenticationPublicKey(metadata: WebAuthnAuthenticationMetadata): PublicKeyCredentialRequestOptions;
9797
static createRegistrationPublicKey(metadata: WebAuthnRegistrationMetadata): PublicKeyCredentialCreationOptions;
9898
static getAuthenticationCredential(options: PublicKeyCredentialRequestOptions, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise<PublicKeyCredential | null>;
@@ -117,6 +117,8 @@ export interface WebAuthnAuthenticationMetadata {
117117
// (undocumented)
118118
challenge: string;
119119
// (undocumented)
120+
mediation?: CredentialMediationRequirement;
121+
// (undocumented)
120122
relyingPartyId: string;
121123
// (undocumented)
122124
supportsJsonResponse?: boolean;

packages/journey-client/src/lib/webauthn/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export interface WebAuthnAuthenticationMetadata {
8080
acceptableCredentials?: string;
8181
allowCredentials?: string;
8282
challenge: string;
83+
mediation?: CredentialMediationRequirement;
8384
relyingPartyId: string;
8485
timeout: number;
8586
userVerification: UserVerificationType;

packages/journey-client/src/lib/webauthn/webauthn.test.ts

Lines changed: 0 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
webAuthnAuthMetaCallback70StoredUsername,
2424
} from './webauthn.mock.data.js';
2525
import { createJourneyStep } from '../step.utils.js';
26-
import { vi, afterEach, beforeEach, expect } from 'vitest';
2726

2827
describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => {
2928
it('should return Registration type with register text-output callbacks', () => {
@@ -53,7 +52,6 @@ describe('Test FRWebAuthn class with 7.0 "Passwordless"', () => {
5352
// eslint-disable-next-line
5453
const step = createJourneyStep(webAuthnAuthJSCallback70 as any);
5554
const stepType = WebAuthn.getWebAuthnStepType(step);
56-
console.log('the step type', stepType, WebAuthnStepType.Authentication);
5755
expect(stepType).toBe(WebAuthnStepType.Authentication);
5856
});
5957

@@ -99,104 +97,3 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => {
9997
expect(stepType).toBe(WebAuthnStepType.Authentication);
10098
});
10199
});
102-
103-
describe('WebAuthn conditional mediation', () => {
104-
const originalNavigatorCredentials = navigator.credentials;
105-
const originalPublicKeyCredential = globalThis.PublicKeyCredential;
106-
107-
beforeEach(() => {
108-
Object.defineProperty(globalThis, 'PublicKeyCredential', {
109-
configurable: true,
110-
writable: true,
111-
value: class PublicKeyCredential {
112-
static async isConditionalMediationAvailable(): Promise<boolean> {
113-
return true;
114-
}
115-
},
116-
});
117-
118-
Object.defineProperty(navigator, 'credentials', {
119-
configurable: true,
120-
value: {
121-
get: vi.fn(),
122-
},
123-
});
124-
});
125-
126-
afterEach(() => {
127-
Object.defineProperty(navigator, 'credentials', {
128-
configurable: true,
129-
value: originalNavigatorCredentials,
130-
});
131-
132-
Object.defineProperty(globalThis, 'PublicKeyCredential', {
133-
configurable: true,
134-
writable: true,
135-
value: originalPublicKeyCredential,
136-
});
137-
138-
vi.restoreAllMocks();
139-
});
140-
141-
it('requires an AbortSignal when mediation is conditional', async () => {
142-
// eslint-disable-next-line
143-
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
144-
const hiddenCallback = WebAuthn.getOutcomeCallback(step);
145-
if (!hiddenCallback) throw new Error('Missing hidden callback for test');
146-
147-
await expect(WebAuthn.authenticate(step, 'conditional')).rejects.toThrow(
148-
'AbortSignal is required for conditional mediation WebAuthn requests',
149-
);
150-
151-
expect(hiddenCallback.getInputValue()).toContain(
152-
'AbortSignal is required for conditional mediation WebAuthn requests',
153-
);
154-
});
155-
156-
it('throws NotSupportedError when conditional mediation is not supported by the browser', async () => {
157-
// eslint-disable-next-line
158-
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
159-
const hiddenCallback = WebAuthn.getOutcomeCallback(step);
160-
if (!hiddenCallback) throw new Error('Missing hidden callback for test');
161-
162-
const conditionalSupportSpy = vi
163-
.spyOn(WebAuthn, 'isConditionalMediationSupported')
164-
.mockResolvedValue(false);
165-
166-
await expect(
167-
WebAuthn.authenticate(step, 'conditional', new AbortController().signal),
168-
).rejects.toMatchObject({ name: 'NotSupportedError' });
169-
170-
expect(conditionalSupportSpy).toHaveBeenCalledTimes(1);
171-
expect(hiddenCallback.getInputValue()).toBe('unsupported');
172-
expect(navigator.credentials.get as unknown as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
173-
});
174-
175-
it('passes mediation + signal through to navigator.credentials.get when supported', async () => {
176-
// eslint-disable-next-line
177-
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
178-
const hiddenCallback = WebAuthn.getOutcomeCallback(step);
179-
if (!hiddenCallback) throw new Error('Missing hidden callback for test');
180-
181-
const abortController = new AbortController();
182-
const credentialsGet = vi
183-
.spyOn(navigator.credentials, 'get')
184-
.mockResolvedValue({} as unknown as Credential);
185-
186-
const outcomeSpy = vi
187-
.spyOn(WebAuthn, 'getAuthenticationOutcome')
188-
.mockReturnValue('ok' as unknown as ReturnType<typeof WebAuthn.getAuthenticationOutcome>);
189-
190-
await WebAuthn.authenticate(step, 'conditional', abortController.signal);
191-
192-
expect(outcomeSpy).toHaveBeenCalledTimes(1);
193-
expect(credentialsGet).toHaveBeenCalledWith(
194-
expect.objectContaining({
195-
mediation: 'conditional',
196-
signal: abortController.signal,
197-
publicKey: expect.any(Object),
198-
}),
199-
);
200-
expect(hiddenCallback.getInputValue()).toBe('ok');
201-
});
202-
});

0 commit comments

Comments
 (0)