Skip to content

Commit d14d301

Browse files
committed
fix: retrieve WebAuthn credentials inside try/catch OPENAM-26284
1 parent 60e6ffb commit d14d301

3 files changed

Lines changed: 86 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/javascript-sdk': patch
3+
---
4+
5+
fix: move getAuthenticationCredential back inside try/catch so that WebAuthn cancellation errors (e.g. NotAllowedError) are written to the HiddenValueCallback before re-throwing

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* of the MIT license. See the LICENSE file for details.
99
*/
1010

11-
import { WebAuthnStepType } from './enums';
11+
import { WebAuthnOutcome, WebAuthnStepType } from './enums';
1212
import FRWebAuthn from './index';
1313
import {
1414
webAuthnRegJSCallback653,
@@ -23,6 +23,7 @@ import {
2323
webAuthnAuthMetaCallback70StoredUsername,
2424
webAuthnAuthConditionalMetaCallback,
2525
} from './fr-webauthn.mock.data';
26+
import { CallbackType } from '../auth/enums';
2627
import FRStep from '../fr-auth/fr-step';
2728
import Config from '../config';
2829

@@ -245,3 +246,62 @@ describe('Test FRWebAuthn class with Conditional UI', () => {
245246
expect(Array.from(idArray)).toEqual([1, 2, 3, 4]);
246247
});
247248
});
249+
250+
describe('Test FRWebAuthn class with cancellation error handling', () => {
251+
beforeEach(() => {
252+
Object.defineProperty(global.navigator, 'credentials', {
253+
value: {
254+
get: vi.fn(),
255+
create: vi.fn(),
256+
},
257+
writable: true,
258+
});
259+
Object.defineProperty(window, 'PublicKeyCredential', {
260+
value: {
261+
// Mocked as supported so conditional mediation checks pass through to the credential call
262+
isConditionalMediationAvailable: vi.fn().mockResolvedValue(true),
263+
},
264+
writable: true,
265+
});
266+
});
267+
268+
afterEach(() => {
269+
vi.restoreAllMocks();
270+
});
271+
272+
it('should write NotAllowedError to HiddenValueCallback when user cancels conditional authentication', async () => {
273+
const cancelError = new Error('The operation either timed out or was not allowed.');
274+
cancelError.name = 'NotAllowedError';
275+
vi.spyOn(navigator.credentials, 'get').mockRejectedValue(cancelError);
276+
277+
const step = new FRStep(webAuthnAuthConditionalMetaCallback as any);
278+
279+
await expect(FRWebAuthn.authenticate(step)).rejects.toMatchObject({
280+
name: 'NotAllowedError',
281+
});
282+
283+
const hiddenCallback = step.getCallbacksOfType(CallbackType.HiddenValueCallback)[0];
284+
expect(hiddenCallback).toBeDefined();
285+
expect(hiddenCallback.getInputValue()).toBe(
286+
`${WebAuthnOutcome.Error}::NotAllowedError:The operation either timed out or was not allowed.`,
287+
);
288+
});
289+
290+
it('should write NotAllowedError to HiddenValueCallback when user cancels standard authentication', async () => {
291+
const cancelError = new Error('The operation either timed out or was not allowed.');
292+
cancelError.name = 'NotAllowedError';
293+
vi.spyOn(navigator.credentials, 'get').mockRejectedValue(cancelError);
294+
295+
const step = new FRStep(webAuthnAuthMetaCallback70 as any);
296+
297+
await expect(FRWebAuthn.authenticate(step)).rejects.toMatchObject({
298+
name: 'NotAllowedError',
299+
});
300+
301+
const hiddenCallback = step.getCallbacksOfType(CallbackType.HiddenValueCallback)[0];
302+
expect(hiddenCallback).toBeDefined();
303+
expect(hiddenCallback.getInputValue()).toBe(
304+
`${WebAuthnOutcome.Error}::NotAllowedError:The operation either timed out or was not allowed.`,
305+
);
306+
});
307+
});

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

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,26 @@ abstract class FRWebAuthn {
199199
} else {
200200
throw new Error('No Credential found from Public Key');
201201
}
202+
const credential: PublicKeyCredential | null = await this.getAuthenticationCredential(
203+
optionsTransformer(options),
204+
);
205+
const outcome: ReturnType<typeof this.getAuthenticationOutcome> =
206+
this.getAuthenticationOutcome(credential);
207+
208+
if (metadataCallback) {
209+
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
210+
if (meta?.supportsJsonResponse && credential && 'authenticatorAttachment' in credential) {
211+
hiddenCallback.setInputValue(
212+
JSON.stringify({
213+
authenticatorAttachment: credential.authenticatorAttachment,
214+
legacyData: outcome,
215+
}),
216+
);
217+
return step;
218+
}
219+
}
220+
hiddenCallback.setInputValue(outcome);
221+
return step;
202222
} catch (error) {
203223
if (!(error instanceof Error)) throw error;
204224
// NotSupportedError is a special case
@@ -209,27 +229,6 @@ abstract class FRWebAuthn {
209229
hiddenCallback.setInputValue(`${WebAuthnOutcome.Error}::${error.name}:${error.message}`);
210230
throw error;
211231
}
212-
213-
const credential: PublicKeyCredential | null = await this.getAuthenticationCredential(
214-
optionsTransformer(options),
215-
);
216-
const outcome: ReturnType<typeof this.getAuthenticationOutcome> =
217-
this.getAuthenticationOutcome(credential);
218-
219-
if (metadataCallback) {
220-
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;
221-
if (meta?.supportsJsonResponse && credential && 'authenticatorAttachment' in credential) {
222-
hiddenCallback.setInputValue(
223-
JSON.stringify({
224-
authenticatorAttachment: credential.authenticatorAttachment,
225-
legacyData: outcome,
226-
}),
227-
);
228-
return step;
229-
}
230-
}
231-
hiddenCallback.setInputValue(outcome);
232-
return step;
233232
} else {
234233
const e = new Error('Incorrect callbacks for WebAuthn authentication');
235234
e.name = WebAuthnOutcomeType.DataError;

0 commit comments

Comments
 (0)