Skip to content

Commit c4b719e

Browse files
authored
Merge pull request #577 from thomas-schofield-fr/webauthn-autocomplete-extra-fixes
Webauthn autocomplete extra fixes
2 parents 1253482 + 41389bc commit c4b719e

File tree

6 files changed

+116
-72
lines changed

6 files changed

+116
-72
lines changed

.changeset/thirty-rules-film.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@forgerock/javascript-sdk': patch
3+
---
4+
5+
WebAuthn improvements
6+
7+
- Fix parsing of WebAuthn scripts when `asScript` is true
8+
- Improve handling when conditional mediation is not supported
9+
- Enable re-invocation of WebAuthn requests
10+
- Enable modification of options passed to navigator.credentials.get()

packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ export const webAuthnAuthConditionalMetaCallback = {
505505
_allowCredentials: [],
506506
timeout: 60000,
507507
userVerification: 'preferred',
508-
conditionalWebAuthn: true,
508+
mediation: 'conditional',
509509
relyingPartyId: '',
510510
_relyingPartyId: 'example.com',
511511
extensions: {},

packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ describe('Test FRWebAuthn class with Conditional UI', () => {
131131

132132
it('should detect if conditional UI is supported', async () => {
133133
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true);
134-
const isSupported = await FRWebAuthn.isConditionalUISupported();
134+
const isSupported = await FRWebAuthn.isConditionalMediationSupported();
135135
expect(isSupported).toBe(true);
136136
});
137137

@@ -149,7 +149,7 @@ describe('Test FRWebAuthn class with Conditional UI', () => {
149149
_allowCredentials: [],
150150
timeout: 60000,
151151
userVerification: 'preferred',
152-
conditionalWebAuthn: true,
152+
mediation: 'conditional',
153153
relyingPartyId: '',
154154
_relyingPartyId: 'example.com',
155155
extensions: {},
@@ -180,19 +180,10 @@ describe('Test FRWebAuthn class with Conditional UI', () => {
180180
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(
181181
false,
182182
);
183-
// FIX APPLIED HERE: Added block comment to empty function
184-
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
185-
/* empty */
186-
});
187183
const getSpy = vi.spyOn(navigator.credentials, 'get');
188184

189185
// Attempt to authenticate with conditional UI requested
190-
await FRWebAuthn.getAuthenticationCredential({}, true);
191-
192-
// Expect a warning to be logged
193-
expect(consoleSpy).toHaveBeenCalledWith(
194-
'Conditional UI was requested, but is not supported by this browser.',
195-
);
186+
await FRWebAuthn.getAuthenticationCredential({});
196187

197188
// Expect the call to navigator.credentials.get to NOT have the mediation property
198189
expect(getSpy).toHaveBeenCalledWith(
@@ -208,7 +199,9 @@ describe('Test FRWebAuthn class with Conditional UI', () => {
208199
const getSpy = vi.spyOn(navigator.credentials, 'get');
209200

210201
// Attempt to authenticate with conditional UI requested
211-
await FRWebAuthn.getAuthenticationCredential({}, true);
202+
await FRWebAuthn.getAuthenticationCredential({
203+
mediation: 'conditional',
204+
});
212205

213206
// Expect the call to navigator.credentials.get to have the mediation property
214207
expect(getSpy).toHaveBeenCalledWith(

packages/javascript-sdk/src/fr-webauthn/index.ts

Lines changed: 96 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet
4848
type WebAuthnTextOutput = WebAuthnTextOutputRegistration;
4949
const TWO_SECOND = 2000;
5050

51+
declare global {
52+
interface Window {
53+
PingWebAuthnAbortController: AbortController;
54+
}
55+
}
56+
5157
/**
5258
* Utility for integrating a web browser's WebAuthn API.
5359
*
@@ -65,19 +71,19 @@ const TWO_SECOND = 2000;
6571
* }
6672
* ```
6773
*
68-
* Conditional UI (Autofill) Support:
74+
* Conditional mediation (Autofill) Support:
6975
*
7076
* ```js
71-
* // Check if browser supports conditional UI
77+
* // Check if browser supports conditional mediation
7278
* const supportsConditionalUI = await FRWebAuthn.isConditionalUISupported();
7379
*
7480
* if (supportsConditionalUI) {
75-
* // The authenticate() method automatically handles conditional UI
81+
* // The authenticate() method automatically handles conditional mediation
7682
* // when the server indicates support via conditionalWebAuthn: true
7783
* // in the metadata. No additional code changes needed.
7884
* await FRWebAuthn.authenticate(step);
7985
*
80-
* // For conditional UI to work in the browser, add autocomplete="webauthn"
86+
* // For conditional mediation to work in the browser, add autocomplete="webauthn"
8187
* // to your username input field:
8288
* // <input type="text" name="username" autocomplete="webauthn" />
8389
* }
@@ -117,12 +123,21 @@ abstract class FRWebAuthn {
117123
}
118124

119125
/**
120-
* Checks if the browser supports conditional UI (autofill) for WebAuthn.
126+
* Checks if the browser supports WebAuthn.
127+
*
128+
* @return boolean indicating if WebAuthn is available
129+
*/
130+
public static isWebAuthnSupported(): boolean {
131+
return !!window.PublicKeyCredential;
132+
}
133+
134+
/**
135+
* Checks if the browser supports conditional mediation (autofill) for WebAuthn.
121136
*
122137
* @return Promise<boolean> indicating if conditional mediation is available
123138
*/
124-
public static async isConditionalUISupported(): Promise<boolean> {
125-
if (!window.PublicKeyCredential) {
139+
public static async isConditionalMediationSupported(): Promise<boolean> {
140+
if (!this.isWebAuthnSupported()) {
126141
return false;
127142
}
128143

@@ -138,41 +153,49 @@ abstract class FRWebAuthn {
138153

139154
/**
140155
* Populates the step with the necessary authentication outcome.
141-
* Automatically handles conditional UI if indicated by the server metadata.
156+
* Automatically handles conditional mediation if indicated by the server metadata.
142157
*
143158
* @param step The step that contains WebAuthn authentication data
159+
* @param optionsTransformer Augments the derived options with custom behaviour
144160
* @return The populated step
145161
*/
146-
public static async authenticate(step: FRStep): Promise<FRStep> {
162+
public static async authenticate(
163+
step: FRStep,
164+
optionsTransformer: (options: CredentialRequestOptions) => CredentialRequestOptions = (
165+
options,
166+
) => options,
167+
): Promise<FRStep> {
147168
const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step);
148169
if (hiddenCallback && (metadataCallback || textOutputCallback)) {
149-
let outcome: ReturnType<typeof this.getAuthenticationOutcome>;
150-
let credential: PublicKeyCredential | null = null;
170+
const options: CredentialRequestOptions = {};
151171

152172
try {
153-
let publicKey: PublicKeyCredentialRequestOptions;
154-
let useConditionalUI = false;
155-
156173
if (metadataCallback) {
157174
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
158-
159-
// Check if server indicates conditional UI should be used
160-
useConditionalUI = meta.conditional === 'true';
161-
publicKey = this.createAuthenticationPublicKey(meta);
162-
163-
credential = await this.getAuthenticationCredential(
164-
publicKey as PublicKeyCredentialRequestOptions,
165-
useConditionalUI,
166-
);
167-
outcome = this.getAuthenticationOutcome(credential);
175+
const mediation = meta.mediation as CredentialMediationRequirement;
176+
177+
if (mediation === 'conditional') {
178+
const isConditionalMediationSupported = await this.isConditionalMediationSupported();
179+
if (!isConditionalMediationSupported) {
180+
const e = new Error(
181+
'Conditional mediation was requested, but is not supported by this browser.',
182+
);
183+
e.name = WebAuthnOutcomeType.NotSupportedError;
184+
throw e;
185+
}
186+
}
187+
188+
options.publicKey = this.createAuthenticationPublicKey(meta);
189+
options.mediation = mediation;
168190
} else if (textOutputCallback) {
169-
publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage());
170-
171-
credential = await this.getAuthenticationCredential(
172-
publicKey as PublicKeyCredentialRequestOptions,
173-
false, // Script-based callbacks don't support conditional UI
174-
);
175-
outcome = this.getAuthenticationOutcome(credential);
191+
const metadata = this.extractMetadata(textOutputCallback.getMessage());
192+
if (metadata) {
193+
options.publicKey = this.createAuthenticationPublicKey(
194+
metadata as WebAuthnAuthenticationMetadata,
195+
);
196+
} else {
197+
options.publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage());
198+
}
176199
} else {
177200
throw new Error('No Credential found from Public Key');
178201
}
@@ -187,6 +210,12 @@ abstract class FRWebAuthn {
187210
throw error;
188211
}
189212

213+
const credential: PublicKeyCredential | null = await this.getAuthenticationCredential(
214+
optionsTransformer(options),
215+
);
216+
const outcome: ReturnType<typeof this.getAuthenticationOutcome> =
217+
this.getAuthenticationOutcome(credential);
218+
190219
if (metadataCallback) {
191220
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
192221
if (meta?.supportsJsonResponse && credential && 'authenticatorAttachment' in credential) {
@@ -236,7 +265,13 @@ abstract class FRWebAuthn {
236265
);
237266
outcome = this.getRegistrationOutcome(credential);
238267
} else if (textOutputCallback) {
239-
publicKey = parseWebAuthnRegisterText(textOutputCallback.getMessage());
268+
const metadata = this.extractMetadata(textOutputCallback.getMessage());
269+
270+
if (metadata) {
271+
publicKey = this.createRegistrationPublicKey(metadata as WebAuthnRegistrationMetadata);
272+
} else {
273+
publicKey = parseWebAuthnRegisterText(textOutputCallback.getMessage());
274+
}
240275
credential = await this.getRegistrationCredential(
241276
publicKey as PublicKeyCredentialCreationOptions,
242277
);
@@ -349,37 +384,23 @@ abstract class FRWebAuthn {
349384
/**
350385
* Retrieves the credential from the browser Web Authentication API.
351386
*
352-
* @param options The public key options associated with the request
353-
* @param useConditionalUI Whether to use conditional UI (autofill)
387+
* @param options The options associated with the request
354388
* @return The credential
355389
*/
356390
public static async getAuthenticationCredential(
357-
options: PublicKeyCredentialRequestOptions,
358-
useConditionalUI = false,
391+
options: CredentialRequestOptions,
359392
): Promise<PublicKeyCredential | null> {
360393
// Feature check before we attempt authenticating
361-
if (!window.PublicKeyCredential) {
394+
if (!this.isWebAuthnSupported()) {
362395
const e = new Error('PublicKeyCredential not supported by this browser');
363396
e.name = WebAuthnOutcomeType.NotSupportedError;
364397
throw e;
365398
}
366-
// Build the credential request options
367-
const credentialRequestOptions: CredentialRequestOptions = {
368-
publicKey: options,
369-
};
370-
371-
// Add conditional mediation if requested and supported
372-
if (useConditionalUI) {
373-
const isConditionalSupported = await this.isConditionalUISupported();
374-
if (isConditionalSupported) {
375-
credentialRequestOptions.mediation = 'conditional' as CredentialMediationRequirement;
376-
} else {
377-
// eslint-disable-next-line no-console
378-
FRLogger.warn('Conditional UI was requested, but is not supported by this browser.');
379-
}
380-
}
381399

382-
const credential = await navigator.credentials.get(credentialRequestOptions);
400+
const credential = await navigator.credentials.get({
401+
...options,
402+
signal: this.createAbortController().signal,
403+
});
383404
return credential as PublicKeyCredential;
384405
}
385406

@@ -448,7 +469,7 @@ abstract class FRWebAuthn {
448469
options: PublicKeyCredentialCreationOptions,
449470
): Promise<PublicKeyCredential | null> {
450471
// Feature check before we attempt registering a device
451-
if (!window.PublicKeyCredential) {
472+
if (this.isWebAuthnSupported()) {
452473
const e = new Error('PublicKeyCredential not supported by this browser');
453474
e.name = WebAuthnOutcomeType.NotSupportedError;
454475
throw e;
@@ -525,7 +546,7 @@ abstract class FRWebAuthn {
525546
challenge: Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)).buffer,
526547
timeout,
527548
};
528-
// For conditional UI, allowCredentials can be omitted.
549+
// For conditional mediation, allowCredentials can be omitted.
529550
// For standard WebAuthn, it may or may not be present.
530551
// Only add the property if the array is not empty.
531552
if (allowCredentialsValue && allowCredentialsValue.length > 0) {
@@ -599,6 +620,25 @@ abstract class FRWebAuthn {
599620
},
600621
};
601622
}
623+
624+
private static createAbortController() {
625+
window.PingWebAuthnAbortController?.abort();
626+
627+
const abortController = new AbortController();
628+
window.PingWebAuthnAbortController = abortController;
629+
return abortController;
630+
}
631+
632+
private static extractMetadata(message: string): object | null {
633+
const contextMatch = message.match(/^var scriptContext = (.*);*$/m);
634+
const jsonString = contextMatch?.[1];
635+
636+
if (jsonString) {
637+
return JSON.parse(jsonString);
638+
}
639+
640+
return null;
641+
}
602642
}
603643

604644
export default FRWebAuthn;
@@ -608,4 +648,4 @@ export type {
608648
WebAuthnCallbacks,
609649
WebAuthnRegistrationMetadata,
610650
};
611-
export { WebAuthnOutcome, WebAuthnStepType };
651+
export { WebAuthnOutcome, WebAuthnOutcomeType, WebAuthnStepType };

packages/javascript-sdk/src/fr-webauthn/interfaces.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ interface WebAuthnAuthenticationMetadata {
8686
_relyingPartyId?: string;
8787
timeout: number;
8888
userVerification: UserVerificationType;
89-
conditional?: string;
89+
mediation?: string;
9090
extensions?: Record<string, unknown>;
9191
_type?: 'WebAuthn';
9292
supportsJsonResponse?: boolean;

packages/javascript-sdk/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import type {
6161
WebAuthnCallbacks,
6262
WebAuthnRegistrationMetadata,
6363
} from './fr-webauthn';
64-
import FRWebAuthn, { WebAuthnOutcome, WebAuthnStepType } from './fr-webauthn';
64+
import FRWebAuthn, { WebAuthnOutcome, WebAuthnOutcomeType, WebAuthnStepType } from './fr-webauthn';
6565
import HttpClient from './http-client';
6666
import type {
6767
GetAuthorizationUrlOptions,
@@ -160,5 +160,6 @@ export {
160160
ValidatedCreatePasswordCallback,
161161
ValidatedCreateUsernameCallback,
162162
WebAuthnOutcome,
163+
WebAuthnOutcomeType,
163164
WebAuthnStepType,
164165
};

0 commit comments

Comments
 (0)