diff --git a/.changeset/thirty-rules-film.md b/.changeset/thirty-rules-film.md
new file mode 100644
index 00000000..ef986537
--- /dev/null
+++ b/.changeset/thirty-rules-film.md
@@ -0,0 +1,10 @@
+---
+'@forgerock/javascript-sdk': patch
+---
+
+WebAuthn improvements
+
+- Fix parsing of WebAuthn scripts when `asScript` is true
+- Improve handling when conditional mediation is not supported
+- Enable re-invocation of WebAuthn requests
+- Enable modification of options passed to navigator.credentials.get()
diff --git a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts
index 092910b3..1d210c04 100644
--- a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts
+++ b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts
@@ -505,7 +505,7 @@ export const webAuthnAuthConditionalMetaCallback = {
_allowCredentials: [],
timeout: 60000,
userVerification: 'preferred',
- conditionalWebAuthn: true,
+ mediation: 'conditional',
relyingPartyId: '',
_relyingPartyId: 'example.com',
extensions: {},
diff --git a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts
index 9d08af87..bfb73d20 100644
--- a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts
+++ b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts
@@ -131,7 +131,7 @@ describe('Test FRWebAuthn class with Conditional UI', () => {
it('should detect if conditional UI is supported', async () => {
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true);
- const isSupported = await FRWebAuthn.isConditionalUISupported();
+ const isSupported = await FRWebAuthn.isConditionalMediationSupported();
expect(isSupported).toBe(true);
});
@@ -149,7 +149,7 @@ describe('Test FRWebAuthn class with Conditional UI', () => {
_allowCredentials: [],
timeout: 60000,
userVerification: 'preferred',
- conditionalWebAuthn: true,
+ mediation: 'conditional',
relyingPartyId: '',
_relyingPartyId: 'example.com',
extensions: {},
@@ -180,19 +180,10 @@ describe('Test FRWebAuthn class with Conditional UI', () => {
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(
false,
);
- // FIX APPLIED HERE: Added block comment to empty function
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
- /* empty */
- });
const getSpy = vi.spyOn(navigator.credentials, 'get');
// Attempt to authenticate with conditional UI requested
- await FRWebAuthn.getAuthenticationCredential({}, true);
-
- // Expect a warning to be logged
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Conditional UI was requested, but is not supported by this browser.',
- );
+ await FRWebAuthn.getAuthenticationCredential({});
// Expect the call to navigator.credentials.get to NOT have the mediation property
expect(getSpy).toHaveBeenCalledWith(
@@ -208,7 +199,9 @@ describe('Test FRWebAuthn class with Conditional UI', () => {
const getSpy = vi.spyOn(navigator.credentials, 'get');
// Attempt to authenticate with conditional UI requested
- await FRWebAuthn.getAuthenticationCredential({}, true);
+ await FRWebAuthn.getAuthenticationCredential({
+ mediation: 'conditional',
+ });
// Expect the call to navigator.credentials.get to have the mediation property
expect(getSpy).toHaveBeenCalledWith(
diff --git a/packages/javascript-sdk/src/fr-webauthn/index.ts b/packages/javascript-sdk/src/fr-webauthn/index.ts
index 293366f8..d07a3072 100644
--- a/packages/javascript-sdk/src/fr-webauthn/index.ts
+++ b/packages/javascript-sdk/src/fr-webauthn/index.ts
@@ -48,6 +48,12 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet
type WebAuthnTextOutput = WebAuthnTextOutputRegistration;
const TWO_SECOND = 2000;
+declare global {
+ interface Window {
+ PingWebAuthnAbortController: AbortController;
+ }
+}
+
/**
* Utility for integrating a web browser's WebAuthn API.
*
@@ -65,19 +71,19 @@ const TWO_SECOND = 2000;
* }
* ```
*
- * Conditional UI (Autofill) Support:
+ * Conditional mediation (Autofill) Support:
*
* ```js
- * // Check if browser supports conditional UI
+ * // Check if browser supports conditional mediation
* const supportsConditionalUI = await FRWebAuthn.isConditionalUISupported();
*
* if (supportsConditionalUI) {
- * // The authenticate() method automatically handles conditional UI
+ * // The authenticate() method automatically handles conditional mediation
* // when the server indicates support via conditionalWebAuthn: true
* // in the metadata. No additional code changes needed.
* await FRWebAuthn.authenticate(step);
*
- * // For conditional UI to work in the browser, add autocomplete="webauthn"
+ * // For conditional mediation to work in the browser, add autocomplete="webauthn"
* // to your username input field:
* //
* }
@@ -117,12 +123,21 @@ abstract class FRWebAuthn {
}
/**
- * Checks if the browser supports conditional UI (autofill) for WebAuthn.
+ * Checks if the browser supports WebAuthn.
+ *
+ * @return boolean indicating if WebAuthn is available
+ */
+ public static isWebAuthnSupported(): boolean {
+ return !!window.PublicKeyCredential;
+ }
+
+ /**
+ * Checks if the browser supports conditional mediation (autofill) for WebAuthn.
*
* @return Promise indicating if conditional mediation is available
*/
- public static async isConditionalUISupported(): Promise {
- if (!window.PublicKeyCredential) {
+ public static async isConditionalMediationSupported(): Promise {
+ if (!this.isWebAuthnSupported()) {
return false;
}
@@ -138,41 +153,49 @@ abstract class FRWebAuthn {
/**
* Populates the step with the necessary authentication outcome.
- * Automatically handles conditional UI if indicated by the server metadata.
+ * Automatically handles conditional mediation if indicated by the server metadata.
*
* @param step The step that contains WebAuthn authentication data
+ * @param optionsTransformer Augments the derived options with custom behaviour
* @return The populated step
*/
- public static async authenticate(step: FRStep): Promise {
+ public static async authenticate(
+ step: FRStep,
+ optionsTransformer: (options: CredentialRequestOptions) => CredentialRequestOptions = (
+ options,
+ ) => options,
+ ): Promise {
const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step);
if (hiddenCallback && (metadataCallback || textOutputCallback)) {
- let outcome: ReturnType;
- let credential: PublicKeyCredential | null = null;
+ const options: CredentialRequestOptions = {};
try {
- let publicKey: PublicKeyCredentialRequestOptions;
- let useConditionalUI = false;
-
if (metadataCallback) {
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
-
- // Check if server indicates conditional UI should be used
- useConditionalUI = meta.conditional === 'true';
- publicKey = this.createAuthenticationPublicKey(meta);
-
- credential = await this.getAuthenticationCredential(
- publicKey as PublicKeyCredentialRequestOptions,
- useConditionalUI,
- );
- outcome = this.getAuthenticationOutcome(credential);
+ const mediation = meta.mediation as CredentialMediationRequirement;
+
+ if (mediation === 'conditional') {
+ const isConditionalMediationSupported = await this.isConditionalMediationSupported();
+ if (!isConditionalMediationSupported) {
+ const e = new Error(
+ 'Conditional mediation was requested, but is not supported by this browser.',
+ );
+ e.name = WebAuthnOutcomeType.NotSupportedError;
+ throw e;
+ }
+ }
+
+ options.publicKey = this.createAuthenticationPublicKey(meta);
+ options.mediation = mediation;
} else if (textOutputCallback) {
- publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage());
-
- credential = await this.getAuthenticationCredential(
- publicKey as PublicKeyCredentialRequestOptions,
- false, // Script-based callbacks don't support conditional UI
- );
- outcome = this.getAuthenticationOutcome(credential);
+ const metadata = this.extractMetadata(textOutputCallback.getMessage());
+ if (metadata) {
+ options.publicKey = this.createAuthenticationPublicKey(
+ metadata as WebAuthnAuthenticationMetadata,
+ );
+ } else {
+ options.publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage());
+ }
} else {
throw new Error('No Credential found from Public Key');
}
@@ -187,6 +210,12 @@ abstract class FRWebAuthn {
throw error;
}
+ const credential: PublicKeyCredential | null = await this.getAuthenticationCredential(
+ optionsTransformer(options),
+ );
+ const outcome: ReturnType =
+ this.getAuthenticationOutcome(credential);
+
if (metadataCallback) {
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
if (meta?.supportsJsonResponse && credential && 'authenticatorAttachment' in credential) {
@@ -236,7 +265,13 @@ abstract class FRWebAuthn {
);
outcome = this.getRegistrationOutcome(credential);
} else if (textOutputCallback) {
- publicKey = parseWebAuthnRegisterText(textOutputCallback.getMessage());
+ const metadata = this.extractMetadata(textOutputCallback.getMessage());
+
+ if (metadata) {
+ publicKey = this.createRegistrationPublicKey(metadata as WebAuthnRegistrationMetadata);
+ } else {
+ publicKey = parseWebAuthnRegisterText(textOutputCallback.getMessage());
+ }
credential = await this.getRegistrationCredential(
publicKey as PublicKeyCredentialCreationOptions,
);
@@ -349,37 +384,23 @@ abstract class FRWebAuthn {
/**
* Retrieves the credential from the browser Web Authentication API.
*
- * @param options The public key options associated with the request
- * @param useConditionalUI Whether to use conditional UI (autofill)
+ * @param options The options associated with the request
* @return The credential
*/
public static async getAuthenticationCredential(
- options: PublicKeyCredentialRequestOptions,
- useConditionalUI = false,
+ options: CredentialRequestOptions,
): Promise {
// Feature check before we attempt authenticating
- if (!window.PublicKeyCredential) {
+ if (!this.isWebAuthnSupported()) {
const e = new Error('PublicKeyCredential not supported by this browser');
e.name = WebAuthnOutcomeType.NotSupportedError;
throw e;
}
- // Build the credential request options
- const credentialRequestOptions: CredentialRequestOptions = {
- publicKey: options,
- };
-
- // Add conditional mediation if requested and supported
- if (useConditionalUI) {
- const isConditionalSupported = await this.isConditionalUISupported();
- if (isConditionalSupported) {
- credentialRequestOptions.mediation = 'conditional' as CredentialMediationRequirement;
- } else {
- // eslint-disable-next-line no-console
- FRLogger.warn('Conditional UI was requested, but is not supported by this browser.');
- }
- }
- const credential = await navigator.credentials.get(credentialRequestOptions);
+ const credential = await navigator.credentials.get({
+ ...options,
+ signal: this.createAbortController().signal,
+ });
return credential as PublicKeyCredential;
}
@@ -448,7 +469,7 @@ abstract class FRWebAuthn {
options: PublicKeyCredentialCreationOptions,
): Promise {
// Feature check before we attempt registering a device
- if (!window.PublicKeyCredential) {
+ if (this.isWebAuthnSupported()) {
const e = new Error('PublicKeyCredential not supported by this browser');
e.name = WebAuthnOutcomeType.NotSupportedError;
throw e;
@@ -525,7 +546,7 @@ abstract class FRWebAuthn {
challenge: Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)).buffer,
timeout,
};
- // For conditional UI, allowCredentials can be omitted.
+ // For conditional mediation, allowCredentials can be omitted.
// For standard WebAuthn, it may or may not be present.
// Only add the property if the array is not empty.
if (allowCredentialsValue && allowCredentialsValue.length > 0) {
@@ -599,6 +620,25 @@ abstract class FRWebAuthn {
},
};
}
+
+ private static createAbortController() {
+ window.PingWebAuthnAbortController?.abort();
+
+ const abortController = new AbortController();
+ window.PingWebAuthnAbortController = abortController;
+ return abortController;
+ }
+
+ private static extractMetadata(message: string): object | null {
+ const contextMatch = message.match(/^var scriptContext = (.*);*$/m);
+ const jsonString = contextMatch?.[1];
+
+ if (jsonString) {
+ return JSON.parse(jsonString);
+ }
+
+ return null;
+ }
}
export default FRWebAuthn;
@@ -608,4 +648,4 @@ export type {
WebAuthnCallbacks,
WebAuthnRegistrationMetadata,
};
-export { WebAuthnOutcome, WebAuthnStepType };
+export { WebAuthnOutcome, WebAuthnOutcomeType, WebAuthnStepType };
diff --git a/packages/javascript-sdk/src/fr-webauthn/interfaces.ts b/packages/javascript-sdk/src/fr-webauthn/interfaces.ts
index 9990868a..87da0a9f 100644
--- a/packages/javascript-sdk/src/fr-webauthn/interfaces.ts
+++ b/packages/javascript-sdk/src/fr-webauthn/interfaces.ts
@@ -86,7 +86,7 @@ interface WebAuthnAuthenticationMetadata {
_relyingPartyId?: string;
timeout: number;
userVerification: UserVerificationType;
- conditional?: string;
+ mediation?: string;
extensions?: Record;
_type?: 'WebAuthn';
supportsJsonResponse?: boolean;
diff --git a/packages/javascript-sdk/src/index.ts b/packages/javascript-sdk/src/index.ts
index 4added1e..0b7df6fc 100644
--- a/packages/javascript-sdk/src/index.ts
+++ b/packages/javascript-sdk/src/index.ts
@@ -61,7 +61,7 @@ import type {
WebAuthnCallbacks,
WebAuthnRegistrationMetadata,
} from './fr-webauthn';
-import FRWebAuthn, { WebAuthnOutcome, WebAuthnStepType } from './fr-webauthn';
+import FRWebAuthn, { WebAuthnOutcome, WebAuthnOutcomeType, WebAuthnStepType } from './fr-webauthn';
import HttpClient from './http-client';
import type {
GetAuthorizationUrlOptions,
@@ -160,5 +160,6 @@ export {
ValidatedCreatePasswordCallback,
ValidatedCreateUsernameCallback,
WebAuthnOutcome,
+ WebAuthnOutcomeType,
WebAuthnStepType,
};