Skip to content

Commit a935fc4

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

6 files changed

Lines changed: 101 additions & 44 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.');

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: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,43 +138,61 @@ describe('WebAuthn conditional mediation', () => {
138138
vi.restoreAllMocks();
139139
});
140140

141-
it('requires an AbortSignal when mediation is conditional', async () => {
141+
it('uses an internal AbortSignal when mediation is conditional and caller provides none', async () => {
142+
const metaDriven = JSON.parse(JSON.stringify(webAuthnAuthMetaCallback70)) as any;
143+
metaDriven.callbacks[0].output[0].value.mediation = 'conditional';
144+
142145
// eslint-disable-next-line
143-
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
146+
const step = createJourneyStep(metaDriven as any);
144147
const hiddenCallback = WebAuthn.getOutcomeCallback(step);
145148
if (!hiddenCallback) throw new Error('Missing hidden callback for test');
146149

147-
await expect(WebAuthn.authenticate(step, 'conditional')).rejects.toThrow(
148-
'AbortSignal is required for conditional mediation WebAuthn requests',
150+
const credentialsGet = vi
151+
.spyOn(navigator.credentials, 'get')
152+
.mockResolvedValue({} as unknown as Credential);
153+
154+
vi.spyOn(WebAuthn, 'getAuthenticationOutcome').mockReturnValue(
155+
'ok' as unknown as ReturnType<typeof WebAuthn.getAuthenticationOutcome>,
149156
);
150157

151-
expect(hiddenCallback.getInputValue()).toContain(
152-
'AbortSignal is required for conditional mediation WebAuthn requests',
158+
await WebAuthn.authenticate(step);
159+
160+
expect(credentialsGet).toHaveBeenCalledWith(
161+
expect.objectContaining({
162+
mediation: 'conditional',
163+
signal: expect.any(Object),
164+
publicKey: expect.any(Object),
165+
}),
153166
);
167+
expect(hiddenCallback.getInputValue()).toBe('ok');
154168
});
155169

156170
it('throws NotSupportedError when conditional mediation is not supported by the browser', async () => {
171+
const metaDriven = JSON.parse(JSON.stringify(webAuthnAuthMetaCallback70)) as any;
172+
metaDriven.callbacks[0].output[0].value.mediation = 'conditional';
173+
157174
// eslint-disable-next-line
158-
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
175+
const step = createJourneyStep(metaDriven as any);
159176
const hiddenCallback = WebAuthn.getOutcomeCallback(step);
160177
if (!hiddenCallback) throw new Error('Missing hidden callback for test');
161178

162179
const conditionalSupportSpy = vi
163180
.spyOn(WebAuthn, 'isConditionalMediationSupported')
164181
.mockResolvedValue(false);
165182

166-
await expect(
167-
WebAuthn.authenticate(step, 'conditional', new AbortController().signal),
168-
).rejects.toMatchObject({ name: 'NotSupportedError' });
183+
await expect(WebAuthn.authenticate(step)).rejects.toMatchObject({ name: 'NotSupportedError' });
169184

170185
expect(conditionalSupportSpy).toHaveBeenCalledTimes(1);
171186
expect(hiddenCallback.getInputValue()).toBe('unsupported');
172187
expect(navigator.credentials.get as unknown as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
173188
});
174189

175190
it('passes mediation + signal through to navigator.credentials.get when supported', async () => {
191+
const metaDriven = JSON.parse(JSON.stringify(webAuthnAuthMetaCallback70)) as any;
192+
metaDriven.callbacks[0].output[0].value.mediation = 'conditional';
193+
176194
// eslint-disable-next-line
177-
const step = createJourneyStep(webAuthnAuthMetaCallback70 as any);
195+
const step = createJourneyStep(metaDriven as any);
178196
const hiddenCallback = WebAuthn.getOutcomeCallback(step);
179197
if (!hiddenCallback) throw new Error('Missing hidden callback for test');
180198

@@ -187,7 +205,7 @@ describe('WebAuthn conditional mediation', () => {
187205
.spyOn(WebAuthn, 'getAuthenticationOutcome')
188206
.mockReturnValue('ok' as unknown as ReturnType<typeof WebAuthn.getAuthenticationOutcome>);
189207

190-
await WebAuthn.authenticate(step, 'conditional', abortController.signal);
208+
await WebAuthn.authenticate(step, abortController.signal);
191209

192210
expect(outcomeSpy).toHaveBeenCalledTimes(1);
193211
expect(credentialsGet).toHaveBeenCalledWith(

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

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet
6363
*
6464
* Conditional mediation (passkey autofill) support:
6565
*
66-
* Conditional mediation is **opt-in** in this SDK via the `authenticate()` parameters.
66+
* Conditional mediation is **server-driven** in this SDK via WebAuthn metadata (`meta.mediation`).
6767
*
6868
* ```js
6969
* // Optional: feature-detect conditional UI before attempting
@@ -72,18 +72,29 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet
7272
* if (supportsConditionalUI) {
7373
* const controller = new AbortController();
7474
*
75-
* await WebAuthn.authenticate(step, 'conditional', controller.signal);
75+
* // Optional: provide a signal to cancel an in-flight request
76+
* await WebAuthn.authenticate(step, controller.signal);
7677
* }
7778
* ```
7879
*
7980
* Notes:
80-
* - When `mediation` is `'conditional'`, an `AbortSignal` is required.
81+
* - When server-driven mediation is `'conditional'`, an `AbortSignal` will be used.
82+
* If you don't provide one, the SDK will create one.
8183
* - If conditional mediation is requested but not supported by the browser,
8284
* `authenticate()` throws a `NotSupportedError` and sets the hidden WebAuthn outcome to `unsupported`.
8385
* - To enable passkey autofill, add `autocomplete="webauthn"` to your username field:
8486
* `<input type="text" name="username" autocomplete="webauthn" />`
8587
*/
8688
export abstract class WebAuthn {
89+
private static conditionalAbortController?: AbortController;
90+
91+
private static createAbortController(): AbortController {
92+
this.conditionalAbortController?.abort();
93+
const abortController = new AbortController();
94+
this.conditionalAbortController = abortController;
95+
return abortController;
96+
}
97+
8798
/**
8899
* Determines if the given step is a WebAuthn step.
89100
*
@@ -123,32 +134,29 @@ export abstract class WebAuthn {
123134
* Populates the step with the necessary authentication outcome.
124135
*
125136
* @param step The step that contains WebAuthn authentication data
126-
* @param mediation Optional mediation requirement passed through to `navigator.credentials.get()`
127-
* @param signal Optional AbortSignal passed through to `navigator.credentials.get()` (required when `mediation` is `'conditional'`)
137+
* @param signal Optional AbortSignal passed through to `navigator.credentials.get()`
128138
* @return The populated step
129139
*/
130-
public static async authenticate(
131-
step: JourneyStep,
132-
mediation?: CredentialMediationRequirement,
133-
signal?: AbortSignal,
134-
): Promise<JourneyStep> {
140+
public static async authenticate(step: JourneyStep, signal?: AbortSignal): Promise<JourneyStep> {
135141
const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step);
136142
if (hiddenCallback && (metadataCallback || textOutputCallback)) {
137143
let outcome: ReturnType<typeof this.getAuthenticationOutcome>;
138144
let credential: PublicKeyCredential | null = null;
145+
let mediation: CredentialMediationRequirement | undefined;
139146

140147
try {
141148
let publicKey: PublicKeyCredentialRequestOptions;
142149
if (metadataCallback) {
143150
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
151+
mediation = meta.mediation;
144152
publicKey = this.createAuthenticationPublicKey(meta);
145153

146154
if (mediation === 'conditional') {
147-
if (!signal) {
148-
throw new Error(
149-
'AbortSignal is required for conditional mediation WebAuthn requests',
150-
);
151-
}
155+
// Abort any prior conditional request started by the SDK.
156+
// (If the caller provides their own signal, we still abort the prior SDK-owned one.)
157+
this.conditionalAbortController?.abort();
158+
159+
const abortSignal = signal ?? this.createAbortController().signal;
152160

153161
const isConditionalMediationSupported = await this.isConditionalMediationSupported();
154162
if (!isConditionalMediationSupported) {
@@ -158,14 +166,21 @@ export abstract class WebAuthn {
158166
e.name = WebAuthnOutcomeType.NotSupportedError;
159167
throw e;
160168
}
161-
}
162169

163-
credential = await this.getAuthenticationCredential(
164-
publicKey as PublicKeyCredentialRequestOptions,
165-
mediation,
166-
signal,
167-
);
168-
outcome = this.getAuthenticationOutcome(credential);
170+
credential = await this.getAuthenticationCredential(
171+
publicKey as PublicKeyCredentialRequestOptions,
172+
mediation,
173+
abortSignal,
174+
);
175+
outcome = this.getAuthenticationOutcome(credential);
176+
} else {
177+
credential = await this.getAuthenticationCredential(
178+
publicKey as PublicKeyCredentialRequestOptions,
179+
mediation,
180+
signal,
181+
);
182+
outcome = this.getAuthenticationOutcome(credential);
183+
}
169184
} else {
170185
throw new Error(
171186
'No metadata callback found for WebAuthn authentication. Please disable JavaScript in server node.',
@@ -511,14 +526,27 @@ export abstract class WebAuthn {
511526
const rpId = parseRelyingPartyId(relyingPartyId);
512527
const allowCredentialsValue = parseCredentials(allowCredentials || acceptableCredentials || '');
513528

514-
return {
529+
const options: PublicKeyCredentialRequestOptions = {
515530
challenge: Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)).buffer,
516531
timeout,
517-
// only add key-value pair if proper value is provided
518-
...(allowCredentialsValue && { allowCredentials: allowCredentialsValue }),
519-
...(userVerification && { userVerification }),
520-
...(rpId && { rpId }),
521532
};
533+
534+
// For conditional mediation, allowCredentials can be omitted.
535+
// For standard WebAuthn, it may or may not be present.
536+
// Only add the property if the array is not empty.
537+
if (allowCredentialsValue && allowCredentialsValue.length > 0) {
538+
options.allowCredentials = allowCredentialsValue;
539+
}
540+
541+
if (userVerification) {
542+
options.userVerification = userVerification;
543+
}
544+
545+
if (rpId) {
546+
options.rpId = rpId;
547+
}
548+
549+
return options;
522550
}
523551

524552
/**

0 commit comments

Comments
 (0)