From 010d5ea4a44bb779a0fecfa76f05d806ef91ff76 Mon Sep 17 00:00:00 2001 From: Thomas Schofield Date: Mon, 23 Feb 2026 14:46:37 +0000 Subject: [PATCH 1/3] fix: implementation of conditional UI for AME-34348 AME-34340 --- .../src/fr-webauthn/fr-webauthn.mock.data.ts | 2 +- .../src/fr-webauthn/fr-webauthn.test.ts | 17 ++--- .../javascript-sdk/src/fr-webauthn/index.ts | 67 ++++++++++--------- .../src/fr-webauthn/interfaces.ts | 2 +- packages/javascript-sdk/src/index.ts | 3 +- 5 files changed, 44 insertions(+), 47 deletions(-) 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 092910b3f..1d210c04e 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 9d08af879..2ea8806e5 100644 --- a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts +++ b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts @@ -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 293366f8a..073c5cb11 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. * @@ -151,27 +157,30 @@ abstract class FRWebAuthn { try { let publicKey: PublicKeyCredentialRequestOptions; - let useConditionalUI = false; if (metadataCallback) { const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata; + const mediation = meta.mediation as CredentialMediationRequirement; + + if (mediation === 'conditional') { + const isConditionalSupported = await this.isConditionalUISupported(); + if (!isConditionalSupported) { + const e = new Error( + 'Conditional UI was requested, but is not supported by this browser.', + ); + e.name = WebAuthnOutcomeType.NotSupportedError; + throw e; + } + } - // 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, - ); + credential = await this.getAuthenticationCredential({ publicKey, mediation }); outcome = this.getAuthenticationOutcome(credential); } else if (textOutputCallback) { publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage()); - credential = await this.getAuthenticationCredential( - publicKey as PublicKeyCredentialRequestOptions, - false, // Script-based callbacks don't support conditional UI - ); + credential = await this.getAuthenticationCredential({ publicKey }); outcome = this.getAuthenticationOutcome(credential); } else { throw new Error('No Credential found from Public Key'); @@ -349,13 +358,11 @@ 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) { @@ -363,23 +370,11 @@ abstract class FRWebAuthn { 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; } @@ -599,6 +594,14 @@ abstract class FRWebAuthn { }, }; } + + private static createAbortController() { + window.PingWebAuthnAbortController?.abort(); + + const abortController = new AbortController(); + window.PingWebAuthnAbortController = abortController; + return abortController; + } } export default FRWebAuthn; @@ -608,4 +611,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 9990868a9..87da0a9f7 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 4added1e7..0b7df6fcd 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, }; From 1fb1e574a6583b00cecb909534e005da3b7d247e Mon Sep 17 00:00:00 2001 From: Thomas Schofield Date: Mon, 23 Feb 2026 14:48:03 +0000 Subject: [PATCH 2/3] fix: webauthn script parsing for AME-34349 --- .changeset/thirty-rules-film.md | 8 +++++ .../javascript-sdk/src/fr-webauthn/index.ts | 29 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 .changeset/thirty-rules-film.md diff --git a/.changeset/thirty-rules-film.md b/.changeset/thirty-rules-film.md new file mode 100644 index 000000000..0cde6501a --- /dev/null +++ b/.changeset/thirty-rules-film.md @@ -0,0 +1,8 @@ +--- +"@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 diff --git a/packages/javascript-sdk/src/fr-webauthn/index.ts b/packages/javascript-sdk/src/fr-webauthn/index.ts index 073c5cb11..02c12f3a7 100644 --- a/packages/javascript-sdk/src/fr-webauthn/index.ts +++ b/packages/javascript-sdk/src/fr-webauthn/index.ts @@ -178,7 +178,15 @@ abstract class FRWebAuthn { credential = await this.getAuthenticationCredential({ publicKey, mediation }); outcome = this.getAuthenticationOutcome(credential); } else if (textOutputCallback) { - publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage()); + const metadata = this.extractMetadata(textOutputCallback.getMessage()); + + if (metadata) { + publicKey = this.createAuthenticationPublicKey( + metadata as WebAuthnAuthenticationMetadata, + ); + } else { + publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage()); + } credential = await this.getAuthenticationCredential({ publicKey }); outcome = this.getAuthenticationOutcome(credential); @@ -245,7 +253,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, ); @@ -602,6 +616,17 @@ abstract class FRWebAuthn { 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; From 41389bc49154220f28ce6945773624ec81418f6e Mon Sep 17 00:00:00 2001 From: Emma Rumsey Date: Fri, 27 Feb 2026 11:01:21 +0000 Subject: [PATCH 3/3] feat: ability to override WebAuthn options for authentication AME-33781 --- .changeset/thirty-rules-film.md | 10 +-- .../src/fr-webauthn/fr-webauthn.test.ts | 2 +- .../javascript-sdk/src/fr-webauthn/index.ts | 70 +++++++++++-------- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/.changeset/thirty-rules-film.md b/.changeset/thirty-rules-film.md index 0cde6501a..ef9865378 100644 --- a/.changeset/thirty-rules-film.md +++ b/.changeset/thirty-rules-film.md @@ -1,8 +1,10 @@ --- -"@forgerock/javascript-sdk": patch +'@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 + +- 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.test.ts b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts index 2ea8806e5..bfb73d201 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); }); diff --git a/packages/javascript-sdk/src/fr-webauthn/index.ts b/packages/javascript-sdk/src/fr-webauthn/index.ts index 02c12f3a7..d07a3072e 100644 --- a/packages/javascript-sdk/src/fr-webauthn/index.ts +++ b/packages/javascript-sdk/src/fr-webauthn/index.ts @@ -71,19 +71,19 @@ declare global { * } * ``` * - * 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: * // * } @@ -123,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; } @@ -144,52 +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; - if (metadataCallback) { const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata; const mediation = meta.mediation as CredentialMediationRequirement; if (mediation === 'conditional') { - const isConditionalSupported = await this.isConditionalUISupported(); - if (!isConditionalSupported) { + const isConditionalMediationSupported = await this.isConditionalMediationSupported(); + if (!isConditionalMediationSupported) { const e = new Error( - 'Conditional UI was requested, but is not supported by this browser.', + 'Conditional mediation was requested, but is not supported by this browser.', ); e.name = WebAuthnOutcomeType.NotSupportedError; throw e; } } - publicKey = this.createAuthenticationPublicKey(meta); - - credential = await this.getAuthenticationCredential({ publicKey, mediation }); - outcome = this.getAuthenticationOutcome(credential); + options.publicKey = this.createAuthenticationPublicKey(meta); + options.mediation = mediation; } else if (textOutputCallback) { const metadata = this.extractMetadata(textOutputCallback.getMessage()); - if (metadata) { - publicKey = this.createAuthenticationPublicKey( + options.publicKey = this.createAuthenticationPublicKey( metadata as WebAuthnAuthenticationMetadata, ); } else { - publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage()); + options.publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage()); } - - credential = await this.getAuthenticationCredential({ publicKey }); - outcome = this.getAuthenticationOutcome(credential); } else { throw new Error('No Credential found from Public Key'); } @@ -204,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) { @@ -379,7 +391,7 @@ abstract class FRWebAuthn { 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; @@ -457,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; @@ -534,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) {