From b975a8ba4d8956069aa7947b0c01422b0862aa01 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Mon, 8 Dec 2025 14:58:10 -0700 Subject: [PATCH] fix: fido-errors --- .changeset/fido-error-codes.md | 13 + .../src/lib/davinci.utils.test.ts | 27 + .../davinci-client/src/lib/davinci.utils.ts | 1 + .../davinci-client/src/lib/fido/fido.test.ts | 492 ++++++++++++++++++ packages/davinci-client/src/lib/fido/fido.ts | 75 +-- .../src/lib/fido/fido.types.test.ts | 58 +++ .../davinci-client/src/lib/fido/fido.types.ts | 76 +++ packages/davinci-client/src/types.ts | 5 +- 8 files changed, 710 insertions(+), 37 deletions(-) create mode 100644 .changeset/fido-error-codes.md create mode 100644 packages/davinci-client/src/lib/fido/fido.test.ts create mode 100644 packages/davinci-client/src/lib/fido/fido.types.test.ts create mode 100644 packages/davinci-client/src/lib/fido/fido.types.ts diff --git a/.changeset/fido-error-codes.md b/.changeset/fido-error-codes.md new file mode 100644 index 0000000000..3ed0e92aa2 --- /dev/null +++ b/.changeset/fido-error-codes.md @@ -0,0 +1,13 @@ +--- +'@forgerock/davinci-client': patch +--- + +Add WebAuthn error code propagation for FIDO operations + +- FIDO registration and authentication errors now include the WebAuthn error code in the `code` field of `GenericError` +- Supported error codes: `NotAllowedError`, `AbortError`, `InvalidStateError`, `NotSupportedError`, `SecurityError`, `TimeoutError`, `UnknownError` +- Added optional `FidoClientConfig` parameter to `fido()` for logger configuration +- Replaced `console.error` with SDK logger +- Added `formData: {}` to `transformActionRequest()` for API contract consistency + +Consumers can propagate FIDO errors to DaVinci using `client.flow({ action: result.code })()`. diff --git a/packages/davinci-client/src/lib/davinci.utils.test.ts b/packages/davinci-client/src/lib/davinci.utils.test.ts index d53d7dc61f..008241f04b 100644 --- a/packages/davinci-client/src/lib/davinci.utils.test.ts +++ b/packages/davinci-client/src/lib/davinci.utils.test.ts @@ -147,6 +147,7 @@ describe('transformActionRequest', () => { eventType: 'action', data: { actionKey: 'TEST_ACTION', + formData: {}, }, }, }; @@ -154,6 +155,32 @@ describe('transformActionRequest', () => { const result = transformActionRequest(node, action, logger({ level: 'none' })); expect(result).toEqual(expectedRequest); }); + + it('should include empty formData for FIDO error codes', () => { + const node: ContinueNode = { + cache: { key: '123' }, + client: { + action: 'SIGNON', + collectors: [], + status: 'continue' as const, + }, + error: null, + httpStatus: 200, + server: { + id: '123', + eventName: 'click', + interactionId: '456', + status: 'continue' as const, + }, + status: 'continue' as const, + }; + + const result = transformActionRequest(node, 'NotAllowedError', logger({ level: 'none' })); + + expect(result.parameters.eventType).toBe('action'); + expect(result.parameters.data.actionKey).toBe('NotAllowedError'); + expect(result.parameters.data.formData).toEqual({}); + }); }); describe('handleResponse', () => { diff --git a/packages/davinci-client/src/lib/davinci.utils.ts b/packages/davinci-client/src/lib/davinci.utils.ts index a742708178..59e4e35d99 100644 --- a/packages/davinci-client/src/lib/davinci.utils.ts +++ b/packages/davinci-client/src/lib/davinci.utils.ts @@ -98,6 +98,7 @@ export function transformActionRequest( eventType: 'action', data: { actionKey: action, + formData: {}, }, }, }; diff --git a/packages/davinci-client/src/lib/fido/fido.test.ts b/packages/davinci-client/src/lib/fido/fido.test.ts new file mode 100644 index 0000000000..b780f92054 --- /dev/null +++ b/packages/davinci-client/src/lib/fido/fido.test.ts @@ -0,0 +1,492 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fido } from './fido.js'; + +import type { FidoClientConfig } from './fido.js'; +import type { FidoRegistrationOptions, FidoAuthenticationOptions } from '../davinci.types'; +import type { GenericError } from '@forgerock/sdk-types'; + +const silentConfig: FidoClientConfig = { logger: { level: 'none' } }; + +const mockRegistrationOptions: FidoRegistrationOptions = { + rp: { id: 'test.example.com', name: 'Test RP' }, + user: { id: [1, 2, 3], displayName: 'test@example.com', name: 'Test User' }, + challenge: [4, 5, 6], + pubKeyCredParams: [{ type: 'public-key', alg: '-7' }], + timeout: 60000, + authenticatorSelection: { userVerification: 'required' }, + attestation: 'none', +}; + +const mockAuthenticationOptions: FidoAuthenticationOptions = { + challenge: [4, 5, 6], + timeout: 60000, + rpId: 'test.example.com', + allowCredentials: [{ type: 'public-key', id: [1, 2, 3] }], + userVerification: 'required', +}; + +function isGenericError(result: unknown): result is GenericError { + return typeof result === 'object' && result !== null && 'error' in result; +} + +describe('fido', () => { + const originalCredentials = navigator.credentials; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'credentials', { + value: originalCredentials, + writable: true, + configurable: true, + }); + }); + + describe('register', () => { + it('should return GenericError with NotAllowedError code when user cancels', async () => { + const mockCreate = vi + .fn() + .mockRejectedValue(new DOMException('User canceled', 'NotAllowedError')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('NotAllowedError'); + expect(result.error).toBe('registration_error'); + expect(result.type).toBe('fido_error'); + expect(result.message).toContain('NotAllowedError'); + } + }); + + it('should return GenericError with AbortError code when operation is aborted', async () => { + const mockCreate = vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('AbortError'); + expect(result.error).toBe('registration_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with InvalidStateError code when authenticator already registered', async () => { + const mockCreate = vi + .fn() + .mockRejectedValue(new DOMException('Already registered', 'InvalidStateError')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('InvalidStateError'); + expect(result.error).toBe('registration_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with NotSupportedError code when algorithm not supported', async () => { + const mockCreate = vi + .fn() + .mockRejectedValue(new DOMException('Not supported', 'NotSupportedError')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('NotSupportedError'); + expect(result.error).toBe('registration_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with SecurityError code when RP ID mismatch', async () => { + const mockCreate = vi + .fn() + .mockRejectedValue(new DOMException('Security error', 'SecurityError')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('SecurityError'); + expect(result.error).toBe('registration_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with TimeoutError code when operation times out', async () => { + const mockCreate = vi.fn().mockRejectedValue(new DOMException('Timeout', 'TimeoutError')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('TimeoutError'); + expect(result.error).toBe('registration_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with UnknownError code for unrecognized errors', async () => { + const mockCreate = vi.fn().mockRejectedValue(new Error('Something unexpected')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('UnknownError'); + expect(result.error).toBe('registration_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with UnknownError code when credential is null', async () => { + const mockCreate = vi.fn().mockResolvedValue(null); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('UnknownError'); + expect(result.error).toBe('registration_error'); + expect(result.type).toBe('fido_error'); + expect(result.message).toContain('No credential returned'); + } + }); + + it('should return success value when registration succeeds', async () => { + const mockCredential = { + id: 'test-credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + authenticatorAttachment: 'platform', + response: { + clientDataJSON: new ArrayBuffer(8), + attestationObject: new ArrayBuffer(8), + }, + }; + const mockCreate = vi.fn().mockResolvedValue(mockCredential); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(false); + expect('attestationValue' in result).toBe(true); + }); + }); + + describe('authenticate', () => { + it('should return GenericError with NotAllowedError code when user cancels', async () => { + const mockGet = vi + .fn() + .mockRejectedValue(new DOMException('User canceled', 'NotAllowedError')); + Object.defineProperty(navigator, 'credentials', { + value: { get: mockGet }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.authenticate(mockAuthenticationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('NotAllowedError'); + expect(result.error).toBe('authentication_error'); + expect(result.type).toBe('fido_error'); + expect(result.message).toContain('NotAllowedError'); + } + }); + + it('should return GenericError with AbortError code when operation is aborted', async () => { + const mockGet = vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')); + Object.defineProperty(navigator, 'credentials', { + value: { get: mockGet }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.authenticate(mockAuthenticationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('AbortError'); + expect(result.error).toBe('authentication_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with InvalidStateError code when authenticator not found', async () => { + const mockGet = vi.fn().mockRejectedValue(new DOMException('Not found', 'InvalidStateError')); + Object.defineProperty(navigator, 'credentials', { + value: { get: mockGet }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.authenticate(mockAuthenticationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('InvalidStateError'); + expect(result.error).toBe('authentication_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with NotSupportedError code when not supported', async () => { + const mockGet = vi + .fn() + .mockRejectedValue(new DOMException('Not supported', 'NotSupportedError')); + Object.defineProperty(navigator, 'credentials', { + value: { get: mockGet }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.authenticate(mockAuthenticationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('NotSupportedError'); + expect(result.error).toBe('authentication_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with SecurityError code when RP ID mismatch', async () => { + const mockGet = vi + .fn() + .mockRejectedValue(new DOMException('Security error', 'SecurityError')); + Object.defineProperty(navigator, 'credentials', { + value: { get: mockGet }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.authenticate(mockAuthenticationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('SecurityError'); + expect(result.error).toBe('authentication_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with TimeoutError code when operation times out', async () => { + const mockGet = vi.fn().mockRejectedValue(new DOMException('Timeout', 'TimeoutError')); + Object.defineProperty(navigator, 'credentials', { + value: { get: mockGet }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.authenticate(mockAuthenticationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('TimeoutError'); + expect(result.error).toBe('authentication_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with UnknownError code for unrecognized errors', async () => { + const mockGet = vi.fn().mockRejectedValue(new Error('Something unexpected')); + Object.defineProperty(navigator, 'credentials', { + value: { get: mockGet }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.authenticate(mockAuthenticationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('UnknownError'); + expect(result.error).toBe('authentication_error'); + expect(result.type).toBe('fido_error'); + } + }); + + it('should return GenericError with UnknownError code when assertion is null', async () => { + const mockGet = vi.fn().mockResolvedValue(null); + Object.defineProperty(navigator, 'credentials', { + value: { get: mockGet }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.authenticate(mockAuthenticationOptions); + + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.code).toBe('UnknownError'); + expect(result.error).toBe('authentication_error'); + expect(result.type).toBe('fido_error'); + expect(result.message).toContain('No credential returned'); + } + }); + + it('should return success value when authentication succeeds', async () => { + const mockAssertion = { + id: 'test-credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + authenticatorAttachment: 'platform', + response: { + clientDataJSON: new ArrayBuffer(8), + authenticatorData: new ArrayBuffer(8), + signature: new ArrayBuffer(8), + userHandle: new ArrayBuffer(8), + }, + }; + const mockGet = vi.fn().mockResolvedValue(mockAssertion); + Object.defineProperty(navigator, 'credentials', { + value: { get: mockGet }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.authenticate(mockAuthenticationOptions); + + expect(isGenericError(result)).toBe(false); + expect('assertionValue' in result).toBe(true); + }); + }); + + describe('error detection pattern', () => { + it('should allow consumers to detect errors using "error" in result', async () => { + const mockCreate = vi + .fn() + .mockRejectedValue(new DOMException('User canceled', 'NotAllowedError')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + // This is the recommended pattern for consumers to detect errors + if ('error' in result) { + // TypeScript should narrow this to GenericError + expect(result.error).toBe('registration_error'); + expect(result.code).toBe('NotAllowedError'); + } else { + // TypeScript should narrow this to FidoRegistrationInputValue + expect.fail('Expected an error result'); + } + }); + }); + + describe('logger configuration', () => { + it('should accept optional logger configuration', async () => { + const mockCreate = vi + .fn() + .mockRejectedValue(new DOMException('User canceled', 'NotAllowedError')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + // Should not throw when logger config is provided (uses 'none' to suppress output) + const fidoClient = fido(silentConfig); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + }); + + it('should work without logger configuration', async () => { + const mockCreate = vi + .fn() + .mockRejectedValue(new DOMException('User canceled', 'NotAllowedError')); + Object.defineProperty(navigator, 'credentials', { + value: { create: mockCreate }, + writable: true, + configurable: true, + }); + + // Should not throw when no config is provided + const fidoClient = fido(); + const result = await fidoClient.register(mockRegistrationOptions); + + expect(isGenericError(result)).toBe(true); + }); + }); +}); diff --git a/packages/davinci-client/src/lib/fido/fido.ts b/packages/davinci-client/src/lib/fido/fido.ts index 3207343622..12c176b8fc 100644 --- a/packages/davinci-client/src/lib/fido/fido.ts +++ b/packages/davinci-client/src/lib/fido/fido.ts @@ -6,13 +6,16 @@ */ import { Micro } from 'effect'; import { exitIsFail, exitIsSuccess } from 'effect/Micro'; +import { logger as loggerFn } from '@forgerock/sdk-logger'; import { transformAssertion, transformAuthenticationOptions, transformPublicKeyCredential, transformRegistrationOptions, } from './fido.utils.js'; +import { createFidoError, toFidoErrorCode } from './fido.types.js'; +import type { LogLevel, CustomLogger } from '@forgerock/sdk-logger'; import type { GenericError } from '@forgerock/sdk-types'; import type { FidoAuthenticationInputValue, @@ -20,12 +23,23 @@ import type { } from '../collector.types.js'; import type { FidoAuthenticationOptions, FidoRegistrationOptions } from '../davinci.types.js'; +/** + * Configuration options for the FIDO client. + */ +export interface FidoClientConfig { + /** Logger configuration for debugging and error reporting */ + logger?: { + level: LogLevel; + custom?: CustomLogger; + }; +} + export interface FidoClient { /** * Create a keypair and get the public key credential to send back to DaVinci for registration * @function register * @param { FidoRegistrationOptions } options - DaVinci FIDO registration options - * @returns { Promise } - The formatted credential for DaVinci or an error + * @returns { Promise } - The formatted credential for DaVinci or an error with WebAuthn error code in `code` field */ register: ( options: FidoRegistrationOptions, @@ -34,7 +48,7 @@ export interface FidoClient { * Get an assertion to send back to DaVinci for authentication * @function authenticate * @param { FidoAuthenticationOptions } options - DaVinci FIDO authentication options - * @returns { Promise } - The formatted assertion for DaVinci or an error + * @returns { Promise } - The formatted assertion for DaVinci or an error with WebAuthn error code in `code` field */ authenticate: ( options: FidoAuthenticationOptions, @@ -45,9 +59,12 @@ export interface FidoClient { * A client function that returns a set of methods for transforming DaVinci data and * interacting with the WebAuthn API for registration and authentication * @function fido + * @param { FidoClientConfig } config - Optional configuration for logging * @returns {FidoClient} - A set of methods for FIDO registration and authentication */ -export function fido(): FidoClient { +export function fido(config?: FidoClientConfig): FidoClient { + const log = loggerFn({ level: config?.logger?.level || 'error', custom: config?.logger?.custom }); + return { /** * Call WebAuthn API to create keypair and get public key credential @@ -63,22 +80,18 @@ export function fido(): FidoClient { publicKey: publicKeyCredentialCreationOptions, }), catch: (error) => { - console.error('Failed to create keypair: ', error); - return { - error: 'registration_error', - message: 'FIDO registration failed', - type: 'fido_error', - } as GenericError; + const code = toFidoErrorCode(error); + const message = `FIDO registration failed: ${code}`; + log.error(message); + return createFidoError(code, 'registration_error', message); }, }), ), Micro.flatMap((credential) => { if (!credential) { - return Micro.fail({ - error: 'registration_error', - message: 'FIDO registration failed: No credential returned', - type: 'fido_error', - } as GenericError); + const message = 'FIDO registration failed: No credential returned'; + log.error(message); + return Micro.fail(createFidoError('UnknownError', 'registration_error', message)); } else { const formattedCredential = transformPublicKeyCredential( credential as PublicKeyCredential, @@ -95,11 +108,9 @@ export function fido(): FidoClient { } else if (exitIsFail(result)) { return result.cause.error; } else { - return { - error: 'fido_registration_error', - message: result.cause.message, - type: 'unknown_error', - }; + const message = 'FIDO registration failed: Unknown error'; + log.error(message); + return createFidoError('UnknownError', 'registration_error', message); } }, /** @@ -116,22 +127,18 @@ export function fido(): FidoClient { publicKey: publicKeyCredentialRequestOptions, }), catch: (error) => { - console.error('Failed to authenticate: ', error); - return { - error: 'authentication_error', - message: 'FIDO authentication failed', - type: 'fido_error', - } as GenericError; + const code = toFidoErrorCode(error); + const message = `FIDO authentication failed: ${code}`; + log.error(message); + return createFidoError(code, 'authentication_error', message); }, }), ), Micro.flatMap((assertion) => { if (!assertion) { - return Micro.fail({ - error: 'authentication_error', - message: 'FIDO authentication failed: No credential returned', - type: 'fido_error', - } as GenericError); + const message = 'FIDO authentication failed: No credential returned'; + log.error(message); + return Micro.fail(createFidoError('UnknownError', 'authentication_error', message)); } else { const formattedAssertion = transformAssertion(assertion as PublicKeyCredential); return Micro.succeed(formattedAssertion); @@ -146,11 +153,9 @@ export function fido(): FidoClient { } else if (exitIsFail(result)) { return result.cause.error; } else { - return { - error: 'fido_authentication_error', - message: result.cause.message, - type: 'unknown_error', - }; + const message = 'FIDO authentication failed: Unknown error'; + log.error(message); + return createFidoError('UnknownError', 'authentication_error', message); } }, }; diff --git a/packages/davinci-client/src/lib/fido/fido.types.test.ts b/packages/davinci-client/src/lib/fido/fido.types.test.ts new file mode 100644 index 0000000000..8e015187a9 --- /dev/null +++ b/packages/davinci-client/src/lib/fido/fido.types.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { describe, it, expect } from 'vitest'; +import { toFidoErrorCode } from './fido.types'; + +describe('toFidoErrorCode', () => { + it('should return NotAllowedError for DOMException with name NotAllowedError', () => { + const error = new DOMException('User canceled', 'NotAllowedError'); + expect(toFidoErrorCode(error)).toBe('NotAllowedError'); + }); + + it('should return AbortError for DOMException with name AbortError', () => { + const error = new DOMException('Operation aborted', 'AbortError'); + expect(toFidoErrorCode(error)).toBe('AbortError'); + }); + + it('should return InvalidStateError for DOMException with name InvalidStateError', () => { + const error = new DOMException('Invalid state', 'InvalidStateError'); + expect(toFidoErrorCode(error)).toBe('InvalidStateError'); + }); + + it('should return NotSupportedError for DOMException with name NotSupportedError', () => { + const error = new DOMException('Not supported', 'NotSupportedError'); + expect(toFidoErrorCode(error)).toBe('NotSupportedError'); + }); + + it('should return SecurityError for DOMException with name SecurityError', () => { + const error = new DOMException('Security error', 'SecurityError'); + expect(toFidoErrorCode(error)).toBe('SecurityError'); + }); + + it('should return TimeoutError for DOMException with name TimeoutError', () => { + const error = new DOMException('Timeout', 'TimeoutError'); + expect(toFidoErrorCode(error)).toBe('TimeoutError'); + }); + + it('should return UnknownError for standard Error', () => { + const error = new Error('Something went wrong'); + expect(toFidoErrorCode(error)).toBe('UnknownError'); + }); + + it('should return UnknownError for non-Error values', () => { + expect(toFidoErrorCode('string error')).toBe('UnknownError'); + expect(toFidoErrorCode(null)).toBe('UnknownError'); + expect(toFidoErrorCode(undefined)).toBe('UnknownError'); + expect(toFidoErrorCode(42)).toBe('UnknownError'); + expect(toFidoErrorCode({})).toBe('UnknownError'); + }); + + it('should return UnknownError for DOMException with unrecognized name', () => { + const error = new DOMException('Network failed', 'NetworkError'); + expect(toFidoErrorCode(error)).toBe('UnknownError'); + }); +}); diff --git a/packages/davinci-client/src/lib/fido/fido.types.ts b/packages/davinci-client/src/lib/fido/fido.types.ts new file mode 100644 index 0000000000..f4f41e19ce --- /dev/null +++ b/packages/davinci-client/src/lib/fido/fido.types.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import type { GenericError } from '@forgerock/sdk-types'; + +/** + * WebAuthn error codes that can occur during FIDO operations. + * These align with standard DOMException names from the WebAuthn specification. + * Used in the `code` field of GenericError when a FIDO operation fails. + */ +export type FidoErrorCode = + | 'NotAllowedError' + | 'AbortError' + | 'InvalidStateError' + | 'NotSupportedError' + | 'SecurityError' + | 'TimeoutError' + | 'UnknownError'; + +const VALID_FIDO_ERROR_CODES: ReadonlySet = new Set([ + 'NotAllowedError', + 'AbortError', + 'InvalidStateError', + 'NotSupportedError', + 'SecurityError', + 'TimeoutError', +]); + +function isErrorWithName(error: unknown): error is { name: string } { + return ( + typeof error === 'object' && + error !== null && + 'name' in error && + typeof (error as { name: unknown }).name === 'string' + ); +} + +function isFidoErrorCode(name: string): name is FidoErrorCode { + return VALID_FIDO_ERROR_CODES.has(name as FidoErrorCode); +} + +/** + * Maps an error to a FidoErrorCode. + * @param error - The error from WebAuthn API + * @returns The corresponding FidoErrorCode + */ +export function toFidoErrorCode(error: unknown): FidoErrorCode { + if (isErrorWithName(error) && isFidoErrorCode(error.name)) { + return error.name; + } + return 'UnknownError'; +} + +/** + * Creates a GenericError for FIDO operations with proper typing. + * @param code - The WebAuthn error code + * @param errorType - The error category (e.g., 'registration_error', 'authentication_error') + * @param message - Human-readable error message + * @returns A properly typed GenericError + */ +export function createFidoError( + code: FidoErrorCode, + errorType: string, + message: string, +): GenericError { + return { + code, + error: errorType, + message, + type: 'fido_error', + }; +} diff --git a/packages/davinci-client/src/types.ts b/packages/davinci-client/src/types.ts index 691be0679e..6a1f3c5583 100644 --- a/packages/davinci-client/src/types.ts +++ b/packages/davinci-client/src/types.ts @@ -5,7 +5,8 @@ */ import 'immer'; // Side-effect needed only for getting types in workspace -import type { FidoClient } from './lib/fido/fido.js'; +import type { FidoClient, FidoClientConfig } from './lib/fido/fido.js'; +import type { FidoErrorCode } from './lib/fido/fido.types.js'; import type * as collectors from './lib/collector.types.js'; import type * as config from './lib/config.types.js'; import type * as nodes from './lib/node.types.js'; @@ -55,4 +56,4 @@ export type FidoAuthenticationCollector = collectors.FidoAuthenticationCollector export type InternalErrorResponse = client.InternalErrorResponse; export type { RequestMiddleware, ActionTypes } from '@forgerock/sdk-request-middleware'; -export type { FidoClient }; +export type { FidoClient, FidoClientConfig, FidoErrorCode };