From d14d301349bb08040363be5dafc01e100fb5862d Mon Sep 17 00:00:00 2001 From: Emma Rumsey Date: Mon, 4 May 2026 10:49:26 +0100 Subject: [PATCH] fix: retrieve WebAuthn credentials inside try/catch OPENAM-26284 --- .changeset/fix-webauthn-cancel-error.md | 5 ++ .../src/fr-webauthn/fr-webauthn.test.ts | 62 ++++++++++++++++++- .../javascript-sdk/src/fr-webauthn/index.ts | 41 ++++++------ 3 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 .changeset/fix-webauthn-cancel-error.md diff --git a/.changeset/fix-webauthn-cancel-error.md b/.changeset/fix-webauthn-cancel-error.md new file mode 100644 index 00000000..e81c7626 --- /dev/null +++ b/.changeset/fix-webauthn-cancel-error.md @@ -0,0 +1,5 @@ +--- +'@forgerock/javascript-sdk': patch +--- + +fix: move getAuthenticationCredential back inside try/catch so that WebAuthn cancellation errors (e.g. NotAllowedError) are written to the HiddenValueCallback before re-throwing 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 15bce073..188b7447 100644 --- a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts +++ b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts @@ -8,7 +8,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { WebAuthnStepType } from './enums'; +import { WebAuthnOutcome, WebAuthnStepType } from './enums'; import FRWebAuthn from './index'; import { webAuthnRegJSCallback653, @@ -23,6 +23,7 @@ import { webAuthnAuthMetaCallback70StoredUsername, webAuthnAuthConditionalMetaCallback, } from './fr-webauthn.mock.data'; +import { CallbackType } from '../auth/enums'; import FRStep from '../fr-auth/fr-step'; import Config from '../config'; @@ -245,3 +246,62 @@ describe('Test FRWebAuthn class with Conditional UI', () => { expect(Array.from(idArray)).toEqual([1, 2, 3, 4]); }); }); + +describe('Test FRWebAuthn class with cancellation error handling', () => { + beforeEach(() => { + Object.defineProperty(global.navigator, 'credentials', { + value: { + get: vi.fn(), + create: vi.fn(), + }, + writable: true, + }); + Object.defineProperty(window, 'PublicKeyCredential', { + value: { + // Mocked as supported so conditional mediation checks pass through to the credential call + isConditionalMediationAvailable: vi.fn().mockResolvedValue(true), + }, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should write NotAllowedError to HiddenValueCallback when user cancels conditional authentication', async () => { + const cancelError = new Error('The operation either timed out or was not allowed.'); + cancelError.name = 'NotAllowedError'; + vi.spyOn(navigator.credentials, 'get').mockRejectedValue(cancelError); + + const step = new FRStep(webAuthnAuthConditionalMetaCallback as any); + + await expect(FRWebAuthn.authenticate(step)).rejects.toMatchObject({ + name: 'NotAllowedError', + }); + + const hiddenCallback = step.getCallbacksOfType(CallbackType.HiddenValueCallback)[0]; + expect(hiddenCallback).toBeDefined(); + expect(hiddenCallback.getInputValue()).toBe( + `${WebAuthnOutcome.Error}::NotAllowedError:The operation either timed out or was not allowed.`, + ); + }); + + it('should write NotAllowedError to HiddenValueCallback when user cancels standard authentication', async () => { + const cancelError = new Error('The operation either timed out or was not allowed.'); + cancelError.name = 'NotAllowedError'; + vi.spyOn(navigator.credentials, 'get').mockRejectedValue(cancelError); + + const step = new FRStep(webAuthnAuthMetaCallback70 as any); + + await expect(FRWebAuthn.authenticate(step)).rejects.toMatchObject({ + name: 'NotAllowedError', + }); + + const hiddenCallback = step.getCallbacksOfType(CallbackType.HiddenValueCallback)[0]; + expect(hiddenCallback).toBeDefined(); + expect(hiddenCallback.getInputValue()).toBe( + `${WebAuthnOutcome.Error}::NotAllowedError:The operation either timed out or was not allowed.`, + ); + }); +}); diff --git a/packages/javascript-sdk/src/fr-webauthn/index.ts b/packages/javascript-sdk/src/fr-webauthn/index.ts index ee1ec5ec..00714391 100644 --- a/packages/javascript-sdk/src/fr-webauthn/index.ts +++ b/packages/javascript-sdk/src/fr-webauthn/index.ts @@ -199,6 +199,26 @@ abstract class FRWebAuthn { } else { throw new Error('No Credential found from Public Key'); } + 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) { + hiddenCallback.setInputValue( + JSON.stringify({ + authenticatorAttachment: credential.authenticatorAttachment, + legacyData: outcome, + }), + ); + return step; + } + } + hiddenCallback.setInputValue(outcome); + return step; } catch (error) { if (!(error instanceof Error)) throw error; // NotSupportedError is a special case @@ -209,27 +229,6 @@ abstract class FRWebAuthn { hiddenCallback.setInputValue(`${WebAuthnOutcome.Error}::${error.name}:${error.message}`); 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) { - hiddenCallback.setInputValue( - JSON.stringify({ - authenticatorAttachment: credential.authenticatorAttachment, - legacyData: outcome, - }), - ); - return step; - } - } - hiddenCallback.setInputValue(outcome); - return step; } else { const e = new Error('Incorrect callbacks for WebAuthn authentication'); e.name = WebAuthnOutcomeType.DataError;