diff --git a/.changeset/thirty-badgers-lick.md b/.changeset/thirty-badgers-lick.md new file mode 100644 index 0000000000..0981418c73 --- /dev/null +++ b/.changeset/thirty-badgers-lick.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': minor +--- + +Catch FIDO/WebAuthn DOMExeceptions and send them to DaVinci diff --git a/e2e/davinci-app/components/fido.ts b/e2e/davinci-app/components/fido.ts index 6c68e1bafc..d21f6ae7a8 100644 --- a/e2e/davinci-app/components/fido.ts +++ b/e2e/davinci-app/components/fido.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 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. @@ -32,13 +32,13 @@ export default function fidoComponent( console.log('fido.register response:', response); if ('error' in response) { console.error(response); + } + + const error = updater(response); + if (error && 'error' in error) { + console.error(error.error.message); } else { - const error = updater(response); - if (error && 'error' in error) { - console.error(error.error.message); - } else { - await submitForm(); - } + await submitForm(); } }; } else if (collector.type === 'FidoAuthenticationCollector') { @@ -54,13 +54,13 @@ export default function fidoComponent( console.log('fido.authenticate response:', response); if ('error' in response) { console.error(response); + } + + const error = updater(response); + if (error && 'error' in error) { + console.error(error.error.message); } else { - const error = updater(response); - if (error && 'error' in error) { - console.error(error.error.message); - } else { - await submitForm(); - } + await submitForm(); } }; } diff --git a/e2e/davinci-suites/src/fido.test.ts b/e2e/davinci-suites/src/fido.test.ts index e1a38cb486..743b303988 100644 --- a/e2e/davinci-suites/src/fido.test.ts +++ b/e2e/davinci-suites/src/fido.test.ts @@ -1,3 +1,9 @@ +/* + * Copyright (c) 2025 - 2026 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 { test, expect, CDPSession } from '@playwright/test'; import { asyncEvents } from './utils/async-events.js'; @@ -10,6 +16,7 @@ test.use({ browserName: 'chromium' }); // ensure CDP/WebAuthn is available test.beforeEach(async ({ context, page }) => { cdp = await context.newCDPSession(page); + await expect(cdp).toBeDefined(); await cdp.send('WebAuthn.enable'); // A "platform" authenticator (aka internal) with UV+RK enabled is the usual default for passkeys. @@ -27,8 +34,10 @@ test.beforeEach(async ({ context, page }) => { }); test.afterEach(async () => { - await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); - await cdp.send('WebAuthn.disable'); + if (authenticatorId) { + await cdp?.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); + } + await cdp?.send('WebAuthn.disable'); }); test.describe('FIDO/WebAuthn Tests', () => { @@ -48,6 +57,10 @@ test.describe('FIDO/WebAuthn Tests', () => { await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Sign On' }).click(); + if (!cdp || !authenticatorId) { + throw new Error('Missing virtual authenticator'); + } + // Register WebAuthn credential const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId, @@ -103,6 +116,13 @@ test.describe('FIDO/WebAuthn Tests', () => { await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Sign On' }).click(); + await expect(cdp).toBeDefined; + await expect(authenticatorId).toBeDefined(); + + if (!cdp || !authenticatorId) { + throw new Error('Missing virtual authenticator'); + } + // Register WebAuthn credential const { credentials: initialCredentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId, diff --git a/e2e/davinci-suites/src/password-policy.test.ts b/e2e/davinci-suites/src/password-policy.test.ts index 0204d6add9..ae373a530d 100644 --- a/e2e/davinci-suites/src/password-policy.test.ts +++ b/e2e/davinci-suites/src/password-policy.test.ts @@ -78,7 +78,7 @@ test.describe('ValidatedPasswordCollector — password policy (Example - Registr try { await deleteTestUser(page, email); } catch (err) { - console.error(`[cleanup] Failed to delete test user ${email}:`, err); + throw new Error(`[cleanup] Failed to delete test user ${email}: ${JSON.stringify(err)}`); } } }); @@ -146,26 +146,17 @@ test.describe('ValidatedPasswordCollector — password policy (Example - Registr await page.locator('#userPassword').fill(password); // Submit the form by calling submit() on the form element - await page.locator('form').evaluate((form: HTMLFormElement) => form.submit()); + await page.getByRole('button', { name: 'Submit' }).click({ timeout: 10000 }); // Wait for the page to navigate to the next step - // The heading should change from "Example - Registration 1" to something else - await page.waitForFunction( - () => { - const heading = document.querySelector('h2'); - return heading && !heading.textContent?.includes('Example - Registration'); - }, - { timeout: 10000 }, - ); - - // Verify we've moved to the next step - const heading = page.locator('h2').first(); - await expect(heading).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Example - Registration' })).not.toBeVisible(); // If the flow shows a "Continue" button, click through to complete it const continueBtn = page.getByRole('button', { name: 'Continue' }); if (await continueBtn.isVisible()) { await continueBtn.click(); } + + await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible(); }); }); diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index 10ea6f0c31..a0d81fcb60 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -12,6 +12,7 @@ import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import type { FetchBaseQueryMeta } from '@reduxjs/toolkit/query'; import { GenericError } from '@forgerock/sdk-types'; import { LogLevel } from '@forgerock/sdk-logger'; +import type { LogLevel as LogLevel_2 } from '@forgerock/sdk-types'; import type { MutationResultSelectorResult } from '@reduxjs/toolkit/query'; import { QueryStatus } from '@reduxjs/toolkit/query'; import { Reducer } from '@reduxjs/toolkit'; @@ -208,9 +209,9 @@ export type CollectorValueType = T extends { type: 'PhoneNumberExtensionCollector'; } ? PhoneNumberExtensionInputValue : T extends { type: 'FidoRegistrationCollector'; -} ? FidoRegistrationInputValue : T extends { +} ? FidoRegistrationInputValue | GenericError : T extends { type: 'FidoAuthenticationCollector'; -} ? FidoAuthenticationInputValue : T extends { +} ? FidoAuthenticationInputValue | GenericError : T extends { type: 'MetadataCollector'; } ? Record | MetadataError : T extends { category: 'SingleValueCollector'; @@ -227,7 +228,7 @@ export type CollectorValueType = T extends { } ? never : CollectorValueTypes; // @public -export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError; +export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError | GenericError; // @public (undocumented) export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField | MetadataField; @@ -285,38 +286,38 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; getMetadataError: (errorDetails: MetadataError) => MetadataError; getClient: () => { + status: "start"; + } | { action: string; collectors: Collectors[]; description?: string; name?: string; status: "continue"; - } | { - status: "start"; } | { action: string; collectors: Collectors[]; description?: string; name?: string; status: "error"; - } | { - status: "failure"; } | { authorization?: { code?: string; state?: string; }; status: "success"; + } | { + status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; getServer: () => { _links?: Links; id?: string; @@ -337,20 +338,20 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; + href?: string; + session?: string; + status: "success"; } | { _links?: Links; eventName?: string; + href?: string; id?: string; interactionId?: string; interactionToken?: string; - href?: string; - session?: string; - status: "success"; + status: "failure"; } | null; cache: { getLatestResponse: () => ({ @@ -640,7 +641,7 @@ export interface DaVinciRequest { } // @public -export type DaVinciRequestValueTypes = string | number | boolean | (string | number | boolean)[] | DeviceValue | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError; +export type DaVinciRequestValueTypes = string | number | boolean | (string | number | boolean)[] | DeviceValue | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError | GenericError; // @public (undocumented) export interface DaVinciSuccessResponse extends DaVinciBaseResponse { @@ -836,10 +837,10 @@ export interface FailureNode { } // @public -export function fido(): FidoClient; +export function fido(config?: FidoClientConfig): FidoClient; // @public (undocumented) -export type FidoAuthenticationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoAuthenticationCollector', FidoAuthenticationInputValue, FidoAuthenticationOutputValue>; +export type FidoAuthenticationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoAuthenticationCollector', FidoAuthenticationInputValue | GenericError, FidoAuthenticationOutputValue>; // @public (undocumented) export type FidoAuthenticationField = { @@ -887,7 +888,19 @@ export interface FidoClient { } // @public (undocumented) -export type FidoRegistrationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoRegistrationCollector', FidoRegistrationInputValue, FidoRegistrationOutputValue>; +export interface FidoClientConfig { + // (undocumented) + logger?: { + level?: LogLevel_2; + custom?: CustomLogger; + }; +} + +// @public +export type FidoErrorCode = 'NotAllowedError' | 'AbortError' | 'InvalidStateError' | 'NotSupportedError' | 'SecurityError' | 'TimeoutError' | 'UnknownError'; + +// @public (undocumented) +export type FidoRegistrationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoRegistrationCollector', FidoRegistrationInputValue | GenericError, FidoRegistrationOutputValue>; // @public (undocumented) export type FidoRegistrationField = { diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index 4544f78c26..78d88b88e5 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -12,6 +12,7 @@ import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import type { FetchBaseQueryMeta } from '@reduxjs/toolkit/query'; import { GenericError } from '@forgerock/sdk-types'; import { LogLevel } from '@forgerock/sdk-logger'; +import type { LogLevel as LogLevel_2 } from '@forgerock/sdk-types'; import type { MutationResultSelectorResult } from '@reduxjs/toolkit/query'; import { QueryStatus } from '@reduxjs/toolkit/query'; import { Reducer } from '@reduxjs/toolkit'; @@ -208,9 +209,9 @@ export type CollectorValueType = T extends { type: 'PhoneNumberExtensionCollector'; } ? PhoneNumberExtensionInputValue : T extends { type: 'FidoRegistrationCollector'; -} ? FidoRegistrationInputValue : T extends { +} ? FidoRegistrationInputValue | GenericError : T extends { type: 'FidoAuthenticationCollector'; -} ? FidoAuthenticationInputValue : T extends { +} ? FidoAuthenticationInputValue | GenericError : T extends { type: 'MetadataCollector'; } ? Record | MetadataError : T extends { category: 'SingleValueCollector'; @@ -227,7 +228,7 @@ export type CollectorValueType = T extends { } ? never : CollectorValueTypes; // @public -export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError; +export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError | GenericError; // @public (undocumented) export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField | MetadataField; @@ -285,38 +286,38 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; getMetadataError: (errorDetails: MetadataError) => MetadataError; getClient: () => { + status: "start"; + } | { action: string; collectors: Collectors[]; description?: string; name?: string; status: "continue"; - } | { - status: "start"; } | { action: string; collectors: Collectors[]; description?: string; name?: string; status: "error"; - } | { - status: "failure"; } | { authorization?: { code?: string; state?: string; }; status: "success"; + } | { + status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; getServer: () => { _links?: Links; id?: string; @@ -337,20 +338,20 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; + href?: string; + session?: string; + status: "success"; } | { _links?: Links; eventName?: string; + href?: string; id?: string; interactionId?: string; interactionToken?: string; - href?: string; - session?: string; - status: "success"; + status: "failure"; } | null; cache: { getLatestResponse: () => ({ @@ -640,7 +641,7 @@ export interface DaVinciRequest { } // @public -export type DaVinciRequestValueTypes = string | number | boolean | (string | number | boolean)[] | DeviceValue | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError; +export type DaVinciRequestValueTypes = string | number | boolean | (string | number | boolean)[] | DeviceValue | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError | GenericError; // @public (undocumented) export interface DaVinciSuccessResponse extends DaVinciBaseResponse { @@ -836,7 +837,7 @@ export interface FailureNode { } // @public (undocumented) -export type FidoAuthenticationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoAuthenticationCollector', FidoAuthenticationInputValue, FidoAuthenticationOutputValue>; +export type FidoAuthenticationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoAuthenticationCollector', FidoAuthenticationInputValue | GenericError, FidoAuthenticationOutputValue>; // @public (undocumented) export type FidoAuthenticationField = { @@ -884,7 +885,19 @@ export interface FidoClient { } // @public (undocumented) -export type FidoRegistrationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoRegistrationCollector', FidoRegistrationInputValue, FidoRegistrationOutputValue>; +export interface FidoClientConfig { + // (undocumented) + logger?: { + level?: LogLevel_2; + custom?: CustomLogger; + }; +} + +// @public +export type FidoErrorCode = 'NotAllowedError' | 'AbortError' | 'InvalidStateError' | 'NotSupportedError' | 'SecurityError' | 'TimeoutError' | 'UnknownError'; + +// @public (undocumented) +export type FidoRegistrationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoRegistrationCollector', FidoRegistrationInputValue | GenericError, FidoRegistrationOutputValue>; // @public (undocumented) export type FidoRegistrationField = { diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index 8fdc02b53c..d4f0918466 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.ts @@ -46,7 +46,8 @@ export type CollectorValueTypes = | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue - | MetadataError; + | MetadataError + | GenericError; /** * Allowed value types for DaVinci formData request bodies. This differs from `CollectorValueTypes` because input values may be transformed for DaVinci. @@ -60,7 +61,8 @@ export type DaVinciRequestValueTypes = | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue - | MetadataError; + | MetadataError + | GenericError; /** * Maps collector types to the specific value type they accept. @@ -102,9 +104,9 @@ export type CollectorValueType = : T extends { type: 'PhoneNumberExtensionCollector' } ? PhoneNumberExtensionInputValue : T extends { type: 'FidoRegistrationCollector' } - ? FidoRegistrationInputValue + ? FidoRegistrationInputValue | GenericError : T extends { type: 'FidoAuthenticationCollector' } - ? FidoAuthenticationInputValue + ? FidoAuthenticationInputValue | GenericError : T extends { type: 'MetadataCollector' } ? Record | MetadataError : // category catch-alls diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index 038a5e5d77..db1c25f072 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -5,6 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ +import type { GenericError } from '@forgerock/sdk-types'; import type { FidoAuthenticationOptions, FidoRegistrationOptions, @@ -843,13 +844,13 @@ export type ProtectCollector = AutoCollector< export type FidoRegistrationCollector = AutoCollector< 'ObjectValueAutoCollector', 'FidoRegistrationCollector', - FidoRegistrationInputValue, + FidoRegistrationInputValue | GenericError, FidoRegistrationOutputValue >; export type FidoAuthenticationCollector = AutoCollector< 'ObjectValueAutoCollector', 'FidoAuthenticationCollector', - FidoAuthenticationInputValue, + FidoAuthenticationInputValue | GenericError, FidoAuthenticationOutputValue >; export type MetadataCollector = AutoCollector< diff --git a/packages/davinci-client/src/lib/davinci.api.ts b/packages/davinci-client/src/lib/davinci.api.ts index 5d6f270e13..05e94872ef 100644 --- a/packages/davinci-client/src/lib/davinci.api.ts +++ b/packages/davinci-client/src/lib/davinci.api.ts @@ -38,6 +38,8 @@ import type { StartOptions, ThrownQueryError, } from './davinci.types.js'; +import type { GenericError } from '@forgerock/sdk-types'; +import type { FidoRegistrationCollector, FidoAuthenticationCollector } from './collector.types.js'; import type { ContinueNode } from './node.types.js'; import type { StartNode } from '../types.js'; @@ -171,9 +173,29 @@ export const davinciApi = createApi({ const hasMetadataCollector = state.node.client?.collectors?.some( (collector) => collector.type === 'MetadataCollector', ); - requestBody = hasMetadataCollector - ? transformActionRequest(state.node, state.node.client?.action, logger) - : transformSubmitRequest(state.node, logger); + const fidoErrorCollector = state.node.client?.collectors?.find( + ( + collector, + ): collector is (FidoRegistrationCollector | FidoAuthenticationCollector) & { + input: { value: GenericError }; + } => + (collector.type === 'FidoRegistrationCollector' || + collector.type === 'FidoAuthenticationCollector') && + 'type' in collector.input.value && + collector.input.value.type === 'fido_error', + ); + + if (hasMetadataCollector) { + requestBody = transformActionRequest(state.node, state.node.client?.action, logger); + } else if (fidoErrorCollector) { + requestBody = transformActionRequest( + state.node, + String(fidoErrorCollector.input.value.code ?? 'UnknownError'), + logger, + ); + } else { + requestBody = transformSubmitRequest(state.node, logger); + } } else { requestBody = body; } diff --git a/packages/davinci-client/src/lib/davinci.utils.test.ts b/packages/davinci-client/src/lib/davinci.utils.test.ts index 094d4d945c..36c6c699dd 100644 --- a/packages/davinci-client/src/lib/davinci.utils.test.ts +++ b/packages/davinci-client/src/lib/davinci.utils.test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 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. @@ -243,6 +243,7 @@ describe('transformActionRequest', () => { eventType: 'action', data: { actionKey: 'TEST_ACTION', + formData: {}, }, }, }; diff --git a/packages/davinci-client/src/lib/davinci.utils.ts b/packages/davinci-client/src/lib/davinci.utils.ts index db14770593..7ff83d5ed8 100644 --- a/packages/davinci-client/src/lib/davinci.utils.ts +++ b/packages/davinci-client/src/lib/davinci.utils.ts @@ -99,6 +99,8 @@ export function transformActionRequest( collector.category === 'ObjectValueAutoCollector', ); + // While most action events don't have formData to send back to DaVinci, + // the MetadataCollector will always return data in the shape of Record const formData = collectors?.reduce<{ [key: string]: DaVinciRequestValueTypes | Record; }>((acc, collector) => { @@ -107,7 +109,6 @@ export function transformActionRequest( }, {}); logger.debug('Transforming action request', { node, action }); - return { id: node.server.id || '', eventName: node.server.eventName || '', @@ -116,7 +117,7 @@ export function transformActionRequest( eventType: 'action', data: { actionKey: action || node.client?.action || '', - ...(Object.keys(formData ?? {}).length && { formData: formData }), + formData: 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..4333344b51 --- /dev/null +++ b/packages/davinci-client/src/lib/fido/fido.test.ts @@ -0,0 +1,487 @@ +/* + * Copyright (c) 2025 - 2026 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); + + if ('error' in result) { + expect(result.error).toBe('registration_error'); + expect(result.code).toBe('NotAllowedError'); + } else { + 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, + }); + + 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, + }); + + 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 a48a9f63f9..ad6b54498a 100644 --- a/packages/davinci-client/src/lib/fido/fido.ts +++ b/packages/davinci-client/src/lib/fido/fido.ts @@ -1,12 +1,16 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 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 { Micro } from 'effect'; import { exitIsFail, exitIsSuccess } from 'effect/Micro'; + +import { logger as loggerFn } from '@forgerock/sdk-logger'; import { + toFidoErrorCode, + createFidoError, transformAssertion, transformAuthenticationOptions, transformPublicKeyCredential, @@ -14,40 +18,25 @@ import { } from './fido.utils.js'; import type { GenericError } from '@forgerock/sdk-types'; +import type { FidoClient, FidoClientConfig } from './fido.types.js'; + +export type { FidoClientConfig }; import type { FidoAuthenticationInputValue, FidoRegistrationInputValue, } from '../collector.types.js'; import type { FidoAuthenticationOptions, FidoRegistrationOptions } from '../davinci.types.js'; -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 - */ - register: ( - options: FidoRegistrationOptions, - ) => Promise; - /** - * 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 - */ - authenticate: ( - options: FidoAuthenticationOptions, - ) => Promise; -} - /** * 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 the FIDO client * @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 @@ -56,11 +45,11 @@ export function fido(): FidoClient { options: FidoRegistrationOptions, ): Promise { if (!options) { - return { - error: 'registration_error', - message: 'FIDO registration failed: No options available', - type: 'fido_error', - } as GenericError; + return createFidoError( + 'UnknownError', + 'registration_error', + 'FIDO registration failed: No options available', + ); } const createCredentialµ = Micro.sync(() => transformRegistrationOptions(options)).pipe( @@ -71,28 +60,27 @@ 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); + log.error('Failed to create keypair: ', code); + return createFidoError( + code, + 'registration_error', + `FIDO registration failed: ${code}`, + ); }, }), ), Micro.flatMap((credential) => { if (!credential) { - return Micro.fail({ - error: 'registration_error', - message: 'FIDO registration failed: No credential returned', - type: 'fido_error', - } as GenericError); - } else { - const formattedCredential = transformPublicKeyCredential( - credential as PublicKeyCredential, + return Micro.fail( + createFidoError( + 'UnknownError', + 'registration_error', + 'FIDO registration failed: No credential returned', + ), ); - return Micro.succeed(formattedCredential); } + return Micro.succeed(transformPublicKeyCredential(credential as PublicKeyCredential)); }), ); @@ -103,13 +91,10 @@ 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', - }; + return createFidoError('UnknownError', 'registration_error', result.cause.message); } }, + /** * Call WebAuthn API to get assertion */ @@ -117,11 +102,11 @@ export function fido(): FidoClient { options: FidoAuthenticationOptions, ): Promise { if (!options) { - return { - error: 'authentication_error', - message: 'FIDO authentication failed: No options available', - type: 'fido_error', - } as GenericError; + return createFidoError( + 'UnknownError', + 'authentication_error', + 'FIDO authentication failed: No options available', + ); } const getAssertionµ = Micro.sync(() => transformAuthenticationOptions(options)).pipe( @@ -132,26 +117,27 @@ 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); + log.error('Failed to authenticate: ', code); + return createFidoError( + code, + 'authentication_error', + `FIDO authentication failed: ${code}`, + ); }, }), ), Micro.flatMap((assertion) => { if (!assertion) { - return Micro.fail({ - error: 'authentication_error', - message: 'FIDO authentication failed: No credential returned', - type: 'fido_error', - } as GenericError); - } else { - const formattedAssertion = transformAssertion(assertion as PublicKeyCredential); - return Micro.succeed(formattedAssertion); + return Micro.fail( + createFidoError( + 'UnknownError', + 'authentication_error', + 'FIDO authentication failed: No credential returned', + ), + ); } + return Micro.succeed(transformAssertion(assertion as PublicKeyCredential)); }), ); @@ -162,11 +148,7 @@ 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', - }; + return createFidoError('UnknownError', 'authentication_error', result.cause.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..72b1694e6d --- /dev/null +++ b/packages/davinci-client/src/lib/fido/fido.types.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 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.utils'; + +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..018db473a3 --- /dev/null +++ b/packages/davinci-client/src/lib/fido/fido.types.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 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 { CustomLogger } from '@forgerock/sdk-logger'; +import type { GenericError, LogLevel } from '@forgerock/sdk-types'; +import type { + FidoRegistrationInputValue, + FidoAuthenticationInputValue, +} from '../collector.types.js'; +import type { FidoRegistrationOptions, FidoAuthenticationOptions } from '../davinci.types.js'; + +export interface FidoClientConfig { + 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 with WebAuthn error code in `code` field + */ + register: ( + options: FidoRegistrationOptions, + ) => Promise; + /** + * 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 with WebAuthn error code in `code` field + */ + authenticate: ( + options: FidoAuthenticationOptions, + ) => Promise; +} + +/** + * 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'; diff --git a/packages/davinci-client/src/lib/fido/fido.utils.ts b/packages/davinci-client/src/lib/fido/fido.utils.ts index 304fc60ca8..7a9a5afec3 100644 --- a/packages/davinci-client/src/lib/fido/fido.utils.ts +++ b/packages/davinci-client/src/lib/fido/fido.utils.ts @@ -1,14 +1,16 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 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'; import type { FidoAuthenticationInputValue, FidoRegistrationInputValue, } from '../collector.types.js'; import type { FidoAuthenticationOptions, FidoRegistrationOptions } from '../davinci.types.js'; +import type { FidoErrorCode } from './fido.types.js'; function convertArrayToBuffer(arr: number[]): ArrayBuffer { return new Int8Array(arr).buffer; @@ -139,3 +141,57 @@ export function transformAssertion(credential: PublicKeyCredential): FidoAuthent }, }; } + +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/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index ea9a09bf98..3892ab8e6c 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -28,6 +28,7 @@ import type { BooleanCollector, ValidatedBooleanCollector, } from './collector.types.js'; +import type { GenericError } from '@forgerock/sdk-types'; import type { FidoAuthenticationOptions, FidoRegistrationOptions } from './davinci.types.js'; describe('The node collector reducer', () => { @@ -1266,6 +1267,85 @@ describe('The node collector reducer with ProtectFieldValue', () => { }); describe('The node collector reducer with FidoRegistrationFieldValue', () => { + it('should store a GenericError on collector.error when a FIDO error is passed as value', () => { + const fidoError: GenericError = { + code: 'NotAllowedError', + error: 'registration_error', + message: 'FIDO registration failed: NotAllowedError', + type: 'fido_error', + }; + const publicKeyCredentialCreationOptions: FidoRegistrationOptions = { + rp: { name: 'Example RP', id: 'example.com' }, + user: { id: [1], displayName: 'Test User', name: 'testuser' }, + challenge: [1, 2, 3, 4], + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + timeout: 60000, + authenticatorSelection: { + residentKey: 'required', + requireResidentKey: true, + userVerification: 'required', + }, + attestation: 'none', + extensions: { credProps: true, hmacCreateSecret: true }, + }; + const action = { + type: 'node/update', + payload: { + id: 'fido2-registration-0', + value: fidoError, + }, + }; + const state: FidoRegistrationCollector[] = [ + { + category: 'ObjectValueAutoCollector', + error: null, + type: 'FidoRegistrationCollector', + id: 'fido2-registration-0', + name: 'fido2-registration', + input: { + key: 'fido2-registration', + value: {}, + type: 'FIDO2', + validation: null, + }, + output: { + key: 'fido2-registration', + type: 'FIDO2', + config: { + publicKeyCredentialCreationOptions, + action: 'REGISTER', + trigger: 'BUTTON', + }, + }, + }, + ]; + + expect(nodeCollectorReducer(state, action)).toStrictEqual([ + { + category: 'ObjectValueAutoCollector', + error: null, + type: 'FidoRegistrationCollector', + id: 'fido2-registration-0', + name: 'fido2-registration', + input: { + key: 'fido2-registration', + value: fidoError, + type: 'FIDO2', + validation: null, + }, + output: { + key: 'fido2-registration', + type: 'FIDO2', + config: { + publicKeyCredentialCreationOptions, + action: 'REGISTER', + trigger: 'BUTTON', + }, + }, + }, + ]); + }); + it('should handle collector updates ', () => { // todo: declare inputValue type as FidoRegistrationInputValue const mockInputValue = { @@ -2095,6 +2175,78 @@ describe('The node collector reducer with BooleanCollector', () => { }); describe('The node collector reducer with FidoAuthenticationFieldValue', () => { + it('should store a GenericError on collector.error when a FIDO error is passed as value', () => { + const fidoError: GenericError = { + code: 'TimeoutError', + error: 'authentication_error', + message: 'FIDO authentication failed: TimeoutError', + type: 'fido_error', + }; + const publicKeyCredentialRequestOptions: FidoAuthenticationOptions = { + challenge: [1, 2, 3, 4], + timeout: 60000, + rpId: 'example.com', + allowCredentials: [{ type: 'public-key', id: [1, 2, 3, 4] }], + userVerification: 'preferred', + }; + const action = { + type: 'node/update', + payload: { + id: 'fido2-authentication-0', + value: fidoError, + }, + }; + const state: FidoAuthenticationCollector[] = [ + { + category: 'ObjectValueAutoCollector', + error: null, + type: 'FidoAuthenticationCollector', + id: 'fido2-authentication-0', + name: 'fido2-authentication', + input: { + key: 'fido2-authentication', + value: {}, + type: 'FIDO2', + validation: null, + }, + output: { + key: 'fido2-authentication', + type: 'FIDO2', + config: { + publicKeyCredentialRequestOptions, + action: 'AUTHENTICATE', + trigger: 'BUTTON', + }, + }, + }, + ]; + + expect(nodeCollectorReducer(state, action)).toStrictEqual([ + { + category: 'ObjectValueAutoCollector', + error: null, + type: 'FidoAuthenticationCollector', + id: 'fido2-authentication-0', + name: 'fido2-authentication', + input: { + key: 'fido2-authentication', + value: fidoError, + type: 'FIDO2', + validation: null, + }, + output: { + key: 'fido2-authentication', + type: 'FIDO2', + config: { + publicKeyCredentialRequestOptions, + action: 'AUTHENTICATE', + trigger: 'BUTTON', + }, + }, + }, + ]); + }); + it('should handle collector updates ', () => { // todo: declare inputValue type as FidoAuthenticationInputValue const mockInputValue = { diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index 47855a63a8..a1ad00c1f7 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -36,8 +36,14 @@ import { returnQrCodeCollector, returnImageCollector, } from './collector.utils.js'; +import type { GenericError } from '@forgerock/sdk-types'; import type { DaVinciField, UnknownField } from './davinci.types.js'; -import type { PhoneNumberOutputValue, PhoneNumberExtensionOutputValue } from './collector.types.js'; +import type { + FidoRegistrationInputValue, + FidoAuthenticationInputValue, + PhoneNumberOutputValue, + PhoneNumberExtensionOutputValue, +} from './collector.types.js'; import type { CollectorValueTypes } from './client.types.js'; import type { Collectors } from './node.types.js'; @@ -320,10 +326,12 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build if (typeof action.payload.value !== 'object') { throw new Error('Value argument must be an object'); } - if (!('attestationValue' in action.payload.value)) { + const isFidoError = + 'type' in action.payload.value && action.payload.value.type === 'fido_error'; + if (!isFidoError && !('attestationValue' in action.payload.value)) { throw new Error('Value argument must contain an attestationValue property'); } - collector.input.value = action.payload.value; + collector.input.value = action.payload.value as FidoRegistrationInputValue | GenericError; } if (collector.type === 'FidoAuthenticationCollector') { @@ -333,10 +341,12 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build if (typeof action.payload.value !== 'object') { throw new Error('Value argument must be an object'); } - if (!('assertionValue' in action.payload.value)) { + const isFidoError = + 'type' in action.payload.value && action.payload.value.type === 'fido_error'; + if (!isFidoError && !('assertionValue' in action.payload.value)) { throw new Error('Value argument must contain an assertionValue property'); } - collector.input.value = action.payload.value; + collector.input.value = action.payload.value as FidoAuthenticationInputValue | GenericError; } if (collector.type === 'MetadataCollector') { diff --git a/packages/davinci-client/src/types.ts b/packages/davinci-client/src/types.ts index eef29fda63..ee3d60e93e 100644 --- a/packages/davinci-client/src/types.ts +++ b/packages/davinci-client/src/types.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2025 Ping Identity Corporation. All rights reserved. +/* Copyright (c) 2025 - 2026 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. @@ -24,7 +24,7 @@ export * from './lib/node.types.js'; export * from './lib/collector.types.js'; // Fido types -export type { FidoClient } from './lib/fido/fido.js'; +export * from './lib/fido/fido.types.js'; // Node slice and reducer exports needed to resolve DavinciClient export {