diff --git a/.changeset/real-swans-leave.md b/.changeset/real-swans-leave.md new file mode 100644 index 0000000000..3e415a19ec --- /dev/null +++ b/.changeset/real-swans-leave.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': minor +--- + +Add MetadataCollector and error helper on client 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-app/components/metadata.ts b/e2e/davinci-app/components/metadata.ts new file mode 100644 index 0000000000..b69a8bd091 --- /dev/null +++ b/e2e/davinci-app/components/metadata.ts @@ -0,0 +1,36 @@ +/* + * 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 { MetadataCollector, MetadataError, Updater } from '@forgerock/davinci-client/types'; + +export default function metadataComponent( + formEl: HTMLFormElement, + updater: Updater, + getMetadataError: (errorDetails: MetadataError) => MetadataError, + submitForm: () => Promise, +) { + const successBtn = document.createElement('button'); + successBtn.type = 'button'; + successBtn.innerHTML = 'Metadata Success'; + + const errorBtn = document.createElement('button'); + errorBtn.type = 'button'; + errorBtn.innerHTML = 'Metadata Error'; + + formEl.appendChild(successBtn); + formEl.appendChild(errorBtn); + + successBtn.onclick = async () => { + updater({ status: 'succeeded' }); + await submitForm(); + }; + + errorBtn.onclick = async () => { + const metadataError = getMetadataError({ code: 'ERROR_CODE', message: 'Operation cancelled' }); + updater(metadataError); + await submitForm(); + }; +} diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index 36a3416ad0..15e017bdea 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -38,6 +38,7 @@ import qrCodeComponent from './components/qr-code.js'; import formImageComponent from './components/form-image.js'; import pollingComponent from './components/polling.js'; import booleanComponent from './components/boolean.js'; +import metadataComponent from './components/metadata.js'; const loggerFn = { error: () => { @@ -289,6 +290,13 @@ const urlParams = new URLSearchParams(window.location.search); davinciClient.update(collector), // Returns an update function for this collector submitForm, ); + } else if (collector.type === 'MetadataCollector') { + metadataComponent( + formEl, // You can ignore this; it's just for rendering + davinciClient.update(collector), // Returns an update function for this collector + davinciClient.getMetadataError, + submitForm, + ); } else if (collector.type === 'BooleanCollector') { booleanComponent(formEl, collector, davinciClient.update(collector)); } else if (collector.type === 'ValidatedBooleanCollector') { diff --git a/e2e/davinci-app/server-configs.ts b/e2e/davinci-app/server-configs.ts index b169c68480..54da3cf1e8 100644 --- a/e2e/davinci-app/server-configs.ts +++ b/e2e/davinci-app/server-configs.ts @@ -93,7 +93,7 @@ export const serverConfigs: Record = { }, }, /** - * Polling + * AutoCollectors: Polling, Metadata */ '31a587ce-9aa4-4f36-a09f-78cd8a0a74a0': { clientId: '31a587ce-9aa4-4f36-a09f-78cd8a0a74a0', 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/metadata.test.ts b/e2e/davinci-suites/src/metadata.test.ts new file mode 100644 index 0000000000..e765b30fcb --- /dev/null +++ b/e2e/davinci-suites/src/metadata.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { expect, test } from '@playwright/test'; +import { asyncEvents } from './utils/async-events.js'; + +test.describe('Metadata Collector', () => { + const clientId = '31a587ce-9aa4-4f36-a09f-78cd8a0a74a0'; + const davinciPolicy = '7793be21a14dd80c1d26b367e81ea985'; + + test('should submit with success status when metadata collection succeeds', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate(`/?clientId=${clientId}&acr_values=${davinciPolicy}`); + + await page.getByRole('button', { name: 'Sign On' }).click(); + await expect(page.getByRole('button', { name: 'Metadata Success' })).toBeVisible(); + + await page.getByRole('button', { name: 'Metadata Success' }).click(); + + await expect(page.getByRole('heading', { name: 'Message' })).toBeVisible(); + await expect(page.getByText('"status":"succeeded"')).toBeVisible(); + }); + + test('should submit with error details when metadata collection fails', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate(`/?clientId=${clientId}&acr_values=${davinciPolicy}`); + + await page.getByRole('button', { name: 'Sign On' }).click(); + await expect(page.getByRole('button', { name: 'Metadata Error' })).toBeVisible(); + + await page.getByRole('button', { name: 'Metadata Error' }).click(); + + await expect(page.getByRole('heading', { name: 'Message' })).toBeVisible(); + await expect(page.getByText('"code":"ERROR_CODE"')).toBeVisible(); + await expect(page.getByText('"message":"Operation cancelled"')).toBeVisible(); + }); +}); 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 1b952496d5..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'; @@ -140,7 +141,7 @@ export interface AutoCollector | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | ImageCollector | UnknownCollector; +export type Collectors = FlowCollector | MetadataCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | BooleanCollector | ValidatedBooleanCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | ImageCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -208,9 +209,11 @@ 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'; } ? string : T extends { category: 'ValidatedSingleValueCollector'; @@ -225,10 +228,10 @@ export type CollectorValueType = T extends { } ? never : CollectorValueTypes; // @public -export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError | GenericError; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField | MetadataField; // @public (undocumented) export interface ContinueNode { @@ -283,11 +286,14 @@ 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; @@ -299,21 +305,19 @@ export function davinci(input: { description?: string; name?: string; status: "error"; - } | { - status: "failure"; - } | { - status: "start"; } | { authorization?: { code?: string; state?: string; }; status: "success"; + } | { + status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; getServer: () => { _links?: Links; id?: string; @@ -322,6 +326,8 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -332,22 +338,20 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; - } | { - status: "start"; + 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: () => ({ @@ -355,14 +359,14 @@ export function davinci(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required; +export type FidoAuthenticationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoAuthenticationCollector', FidoAuthenticationInputValue | GenericError, FidoAuthenticationOutputValue>; // @public (undocumented) export type FidoAuthenticationField = { @@ -881,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 = { @@ -968,7 +987,7 @@ export type ImageField = { export type InferActionCollectorType = T extends 'IdpCollector' ? IdpCollector : T extends 'SubmitCollector' ? SubmitCollector : T extends 'FlowCollector' ? FlowCollector : ActionCollectorWithUrl<'ActionCollector'> | ActionCollectorNoUrl<'ActionCollector'>; // @public -export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector : T extends 'PollingCollector' ? PollingCollector : T extends 'FidoRegistrationCollector' ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector : T extends 'ObjectValueAutoCollector' ? ObjectValueAutoCollector : SingleValueAutoCollector; +export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector : T extends 'PollingCollector' ? PollingCollector : T extends 'FidoRegistrationCollector' ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector : T extends 'MetadataCollector' ? MetadataCollector : T extends 'ObjectValueAutoCollector' ? ObjectValueAutoCollector : SingleValueAutoCollector; // @public export type InferMultiValueCollectorType = T extends 'MultiSelectCollector' ? MultiValueCollectorWithValue<'MultiSelectCollector'> : MultiValueCollectorWithValue<'MultiValueCollector'> | MultiValueCollectorNoValue<'MultiValueCollector'>; @@ -1005,6 +1024,24 @@ export interface Links { export { LogLevel } +// @public (undocumented) +export type MetadataCollector = AutoCollector<'ObjectValueAutoCollector', 'MetadataCollector', Record, Record>; + +// @public (undocumented) +export interface MetadataError { + // (undocumented) + code: string; + // (undocumented) + message: string; +} + +// @public (undocumented) +export type MetadataField = { + type: 'METADATA'; + key: string; + payload: Record; +}; + // @public (undocumented) export type MultiSelectCollector = MultiValueCollectorWithValue<'MultiSelectCollector'>; @@ -1222,7 +1259,7 @@ export interface ObjectOptionsCollectorWithStringValue>; // @public (undocumented) -export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' | 'FidoAuthenticationCollector'; +export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' | 'FidoAuthenticationCollector' | 'MetadataCollector'; // @public (undocumented) export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; 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 91a9488c76..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'; @@ -140,7 +141,7 @@ export interface AutoCollector | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | ImageCollector | UnknownCollector; +export type Collectors = FlowCollector | MetadataCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | BooleanCollector | ValidatedBooleanCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | ImageCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -208,9 +209,11 @@ 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'; } ? string : T extends { category: 'ValidatedSingleValueCollector'; @@ -225,10 +228,10 @@ export type CollectorValueType = T extends { } ? never : CollectorValueTypes; // @public -export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +export type CollectorValueTypes = string | string[] | boolean | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue | MetadataError | GenericError; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField | MetadataField; // @public (undocumented) export interface ContinueNode { @@ -283,11 +286,14 @@ 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; @@ -299,21 +305,19 @@ export function davinci(input: { description?: string; name?: string; status: "error"; - } | { - status: "failure"; - } | { - status: "start"; } | { authorization?: { code?: string; state?: string; }; status: "success"; + } | { + status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; getServer: () => { _links?: Links; id?: string; @@ -322,6 +326,8 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -332,22 +338,20 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; - } | { - status: "start"; + 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: () => ({ @@ -355,14 +359,14 @@ export function davinci(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required; +export type FidoAuthenticationCollector = AutoCollector<'ObjectValueAutoCollector', 'FidoAuthenticationCollector', FidoAuthenticationInputValue | GenericError, FidoAuthenticationOutputValue>; // @public (undocumented) export type FidoAuthenticationField = { @@ -878,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 = { @@ -965,7 +984,7 @@ export type ImageField = { export type InferActionCollectorType = T extends 'IdpCollector' ? IdpCollector : T extends 'SubmitCollector' ? SubmitCollector : T extends 'FlowCollector' ? FlowCollector : ActionCollectorWithUrl<'ActionCollector'> | ActionCollectorNoUrl<'ActionCollector'>; // @public -export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector : T extends 'PollingCollector' ? PollingCollector : T extends 'FidoRegistrationCollector' ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector : T extends 'ObjectValueAutoCollector' ? ObjectValueAutoCollector : SingleValueAutoCollector; +export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector : T extends 'PollingCollector' ? PollingCollector : T extends 'FidoRegistrationCollector' ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector : T extends 'MetadataCollector' ? MetadataCollector : T extends 'ObjectValueAutoCollector' ? ObjectValueAutoCollector : SingleValueAutoCollector; // @public export type InferMultiValueCollectorType = T extends 'MultiSelectCollector' ? MultiValueCollectorWithValue<'MultiSelectCollector'> : MultiValueCollectorWithValue<'MultiValueCollector'> | MultiValueCollectorNoValue<'MultiValueCollector'>; @@ -1002,6 +1021,24 @@ export interface Links { export { LogLevel } +// @public (undocumented) +export type MetadataCollector = AutoCollector<'ObjectValueAutoCollector', 'MetadataCollector', Record, Record>; + +// @public (undocumented) +export interface MetadataError { + // (undocumented) + code: string; + // (undocumented) + message: string; +} + +// @public (undocumented) +export type MetadataField = { + type: 'METADATA'; + key: string; + payload: Record; +}; + // @public (undocumented) export type MultiSelectCollector = MultiValueCollectorWithValue<'MultiSelectCollector'>; @@ -1219,7 +1256,7 @@ export interface ObjectOptionsCollectorWithStringValue>; // @public (undocumented) -export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' | 'FidoAuthenticationCollector'; +export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' | 'FidoAuthenticationCollector' | 'MetadataCollector'; // @public (undocumented) export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index a95401dd91..5e16d42c39 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -52,6 +52,7 @@ import type { Validator, Poller, CollectorValueTypes, + MetadataError, } from './client.types.js'; import { returnValidator } from './collector.utils.js'; import { returnPasswordPolicyValidator } from './password-policy.rules.js'; @@ -455,6 +456,18 @@ export async function davinci({ }; }, + /** + * @method getMetadataError - Constructs a structured error object from a code and message. + * @param {MetadataError} errorDetails - An error code and description. + * @returns {MetadataError} The structured error object. + */ + getMetadataError: (errorDetails: MetadataError): MetadataError => { + return { + code: errorDetails.code, + message: errorDetails.message, + }; + }, + /** * @method client - Selector to get the node.client from state * @returns {Node.client} - the client property from the current node diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index 50125904a9..d4f0918466 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.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. @@ -17,6 +17,7 @@ import type { ValidatedTextCollector, ValidatedBooleanCollector, ValidatedPasswordCollector, + DeviceValue, } from './collector.types.js'; import type { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js'; @@ -27,6 +28,11 @@ export interface InternalErrorResponse { type: 'internal_error'; } +export interface MetadataError { + code: string; + message: string; +} + export type InitFlow = () => Promise; /** @@ -39,7 +45,24 @@ export type CollectorValueTypes = | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue - | FidoAuthenticationInputValue; + | FidoAuthenticationInputValue + | MetadataError + | GenericError; + +/** + * Allowed value types for DaVinci formData request bodies. This differs from `CollectorValueTypes` because input values may be transformed for DaVinci. + */ +export type DaVinciRequestValueTypes = + | string + | number + | boolean + | (string | number | boolean)[] + | DeviceValue + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue + | MetadataError + | GenericError; /** * Maps collector types to the specific value type they accept. @@ -81,24 +104,26 @@ export type CollectorValueType = : T extends { type: 'PhoneNumberExtensionCollector' } ? PhoneNumberExtensionInputValue : T extends { type: 'FidoRegistrationCollector' } - ? FidoRegistrationInputValue + ? FidoRegistrationInputValue | GenericError : T extends { type: 'FidoAuthenticationCollector' } - ? FidoAuthenticationInputValue - : // category catch-alls - // fallbacks for collectors that don't match on `type` - T extends { category: 'SingleValueCollector' } - ? string - : T extends { category: 'ValidatedSingleValueCollector' } + ? FidoAuthenticationInputValue | GenericError + : T extends { type: 'MetadataCollector' } + ? Record | MetadataError + : // category catch-alls + // fallbacks for collectors that don't match on `type` + T extends { category: 'SingleValueCollector' } ? string - : T extends { category: 'SingleValueAutoCollector' } + : T extends { category: 'ValidatedSingleValueCollector' } ? string - : T extends { category: 'MultiValueCollector' } - ? string[] - : T extends { category: 'ActionCollector' } - ? never - : T extends { category: 'NoValueCollector' } + : T extends { category: 'SingleValueAutoCollector' } + ? string + : T extends { category: 'MultiValueCollector' } + ? string[] + : T extends { category: 'ActionCollector' } ? never - : CollectorValueTypes; + : T extends { category: 'NoValueCollector' } + ? never + : CollectorValueTypes; /** * A function type that updates a collector's value. Accepts values appropriate for the collector type. diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index aa61c239e4..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, @@ -806,7 +807,8 @@ export type SingleValueAutoCollectorTypes = export type ObjectValueAutoCollectorTypes = | 'ObjectValueAutoCollector' | 'FidoRegistrationCollector' - | 'FidoAuthenticationCollector'; + | 'FidoAuthenticationCollector' + | 'MetadataCollector'; export type AutoCollectorTypes = SingleValueAutoCollectorTypes | ObjectValueAutoCollectorTypes; export interface AutoCollector< @@ -842,15 +844,21 @@ 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< + 'ObjectValueAutoCollector', + 'MetadataCollector', + Record, + Record +>; export type PollingCollector = AutoCollector< 'SingleValueAutoCollector', 'PollingCollector', @@ -872,6 +880,7 @@ export type AutoCollectors = | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector + | MetadataCollector | PollingCollector | SingleValueAutoCollector | ObjectValueAutoCollector; @@ -891,10 +900,12 @@ export type InferAutoCollectorType = T extends 'Pr ? FidoRegistrationCollector : T extends 'FidoAuthenticationCollector' ? FidoAuthenticationCollector - : T extends 'ObjectValueAutoCollector' - ? ObjectValueAutoCollector - : /** - * At this point, we have not passed in a collector type - * so we can return a SingleValueAutoCollector - **/ - SingleValueAutoCollector; + : T extends 'MetadataCollector' + ? MetadataCollector + : T extends 'ObjectValueAutoCollector' + ? ObjectValueAutoCollector + : /** + * At this point, we have not passed in a collector type + * so we can return a SingleValueAutoCollector + **/ + SingleValueAutoCollector; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index 0934081701..3ac6393d74 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -21,6 +21,7 @@ import { returnNoValueCollector, returnObjectSelectCollector, returnObjectValueCollector, + returnMetadataCollector, returnSingleValueAutoCollector, returnObjectValueAutoCollector, returnImageCollector, @@ -35,6 +36,7 @@ import type { DeviceAuthenticationField, DeviceRegistrationField, ImageField, + MetadataField, PasswordField, FidoAuthenticationField, FidoRegistrationField, @@ -2110,3 +2112,39 @@ describe('returnBooleanCollector', () => { expect(result.output).not.toHaveProperty('richContent'); }); }); + +describe('returnMetadataCollector', () => { + const mockField: MetadataField = { + type: 'METADATA', + key: 'metadata-key', + payload: { + sessionToken: 'abc123', + userId: 'user-456', + }, + }; + + it('should create a valid MetadataCollector with payload in output', () => { + const result = returnMetadataCollector(mockField, 0); + expect(result).toEqual({ + category: 'ObjectValueAutoCollector', + error: null, + type: 'MetadataCollector', + id: 'metadata-key-0', + name: 'metadata-key', + input: { + key: 'metadata-key', + value: {}, + type: 'METADATA', + validation: null, + }, + output: { + key: 'metadata-key', + type: 'METADATA', + config: { + sessionToken: 'abc123', + userId: 'user-456', + }, + }, + }); + }); +}); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 9c591999e2..c8c690a93d 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -47,6 +47,7 @@ import type { FidoAuthenticationField, FidoRegistrationField, ImageField, + MetadataField, MultiSelectField, PasswordField, PhoneNumberField, @@ -497,7 +498,7 @@ export function returnSingleValueAutoCollector< * @returns {AutoCollector} The constructed AutoCollector object. */ export function returnObjectValueAutoCollector< - Field extends FidoRegistrationField | FidoAuthenticationField, + Field extends FidoRegistrationField | FidoAuthenticationField | MetadataField, CollectorType extends ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector', >(field: Field, idx: number, collectorType: CollectorType) { let error = ''; @@ -517,7 +518,26 @@ export function returnObjectValueAutoCollector< }); } - if (field.action === 'REGISTER') { + if (field.type === 'METADATA') { + return { + category: 'ObjectValueAutoCollector', + error: error || null, + type: collectorType, + id: `${field.key}-${idx}`, + name: field.key, + input: { + key: field.key, + value: {}, + type: field.type, + validation: validationArray.length ? validationArray : null, + }, + output: { + key: field.key, + type: field.type, + config: field.payload, + }, + } as InferAutoCollectorType<'MetadataCollector'>; + } else if (field.action === 'REGISTER') { return { category: 'ObjectValueAutoCollector', error: error || null, @@ -789,6 +809,8 @@ export function returnObjectCollector< }); } + const label = 'label' in field ? field.label : ''; + let options; let defaultValue; let extensionLabel: string | null = null; @@ -884,7 +906,7 @@ export function returnObjectCollector< }, output: { key: field.key, - label: field.label, + label: label, type: field.type, ...(options && { options: options || [] }), ...(extensionLabel !== null && { extensionLabel }), @@ -912,6 +934,16 @@ export function returnObjectSelectCollector( ); } +/** + * @function returnMetadataCollector - Creates a MetadataCollector from a METADATA field. + * @param {MetadataField} field - The METADATA field from the API response. + * @param {number} idx - The index used in the collector id/name. + * @returns {MetadataCollector} The constructed MetadataCollector. + */ +export function returnMetadataCollector(field: MetadataField, idx: number) { + return returnObjectValueAutoCollector(field, idx, 'MetadataCollector'); +} + export function returnObjectValueCollector( field: PhoneNumberField | PhoneNumberExtensionField, idx: number, diff --git a/packages/davinci-client/src/lib/davinci.api.ts b/packages/davinci-client/src/lib/davinci.api.ts index c8a1915dc4..05e94872ef 100644 --- a/packages/davinci-client/src/lib/davinci.api.ts +++ b/packages/davinci-client/src/lib/davinci.api.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. @@ -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'; @@ -168,7 +170,32 @@ export const davinciApi = createApi({ } if (!body) { - requestBody = transformSubmitRequest(state.node, logger); + const hasMetadataCollector = state.node.client?.collectors?.some( + (collector) => collector.type === 'MetadataCollector', + ); + 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.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index b94244171a..63a092b703 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -327,6 +327,12 @@ export type PollingField = { challenge?: string; }; +export type MetadataField = { + type: 'METADATA'; + key: string; + payload: Record; +}; + export type UnknownField = Record; export type ComplexValueFields = @@ -336,7 +342,8 @@ export type ComplexValueFields = | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField - | PollingField; + | PollingField + | MetadataField; export type MultiValueFields = MultiSelectField; export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField | ImageField; export type RedirectFields = RedirectField; 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 f3271d1017..7ff83d5ed8 100644 --- a/packages/davinci-client/src/lib/davinci.utils.ts +++ b/packages/davinci-client/src/lib/davinci.utils.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. @@ -22,12 +22,8 @@ import type { DaVinciSuccessResponse, } from './davinci.types.js'; import type { ContinueNode } from './node.types.js'; -import { - DeviceValue, - FidoAuthenticationInputValue, - FidoRegistrationInputValue, - PhoneNumberInputValue, -} from './collector.types.js'; +import { DaVinciRequestValueTypes } from './client.types.js'; + /** * @function transformSubmitRequest - Transforms a NextNode into a DaVinciRequest for form submissions * @param {ContinueNode} node - The node to transform into a DaVinciRequest @@ -50,15 +46,7 @@ export function transformSubmitRequest( ); const formData = collectors?.reduce<{ - [key: string]: - | string - | number - | boolean - | (string | number | boolean)[] - | DeviceValue - | PhoneNumberInputValue - | FidoRegistrationInputValue - | FidoAuthenticationInputValue; + [key: string]: DaVinciRequestValueTypes; }>((acc, collector) => { acc[collector.input.key] = collector.input.value; return acc; @@ -100,6 +88,26 @@ export function transformActionRequest( action: string, logger: ReturnType, ): DaVinciRequest { + // Filter out ActionCollectors as they do not collect values + const collectors = node.client?.collectors?.filter( + (collector) => + collector.category === 'MultiValueCollector' || + collector.category === 'SingleValueCollector' || + collector.category === 'ValidatedSingleValueCollector' || + collector.category === 'ObjectValueCollector' || + collector.category === 'SingleValueAutoCollector' || + 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) => { + acc[collector.input.key] = collector.input.value; + return acc; + }, {}); + logger.debug('Transforming action request', { node, action }); return { id: node.server.id || '', @@ -108,7 +116,8 @@ export function transformActionRequest( parameters: { eventType: 'action', data: { - actionKey: action, + actionKey: action || node.client?.action || '', + 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 00a8804efc..3892ab8e6c 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -12,6 +12,7 @@ import type { DeviceRegistrationCollector, FidoAuthenticationCollector, FidoRegistrationCollector, + MetadataCollector, MultiSelectCollector, PasswordCollector, ValidatedPasswordCollector, @@ -27,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', () => { @@ -1265,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 = { @@ -2094,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 = { @@ -2181,3 +2334,86 @@ describe('The node collector reducer with FidoAuthenticationFieldValue', () => { ]); }); }); + +describe('The node collector reducer with MetadataField', () => { + it('should create a MetadataCollector from a METADATA field', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'METADATA', + key: 'metadata-key', + payload: { sessionToken: 'abc123' }, + }, + ], + formData: {}, + }, + }; + + const result = nodeCollectorReducer(undefined, action); + expect(result[0]).toEqual({ + category: 'ObjectValueAutoCollector', + error: null, + type: 'MetadataCollector', + id: 'metadata-key-0', + name: 'metadata-key', + input: { + key: 'metadata-key', + value: {}, + type: 'METADATA', + validation: null, + }, + output: { + key: 'metadata-key', + type: 'METADATA', + config: { sessionToken: 'abc123' }, + }, + } satisfies MetadataCollector); + }); + + it('should update a MetadataCollector input value', () => { + const state: MetadataCollector[] = [ + { + category: 'ObjectValueAutoCollector', + error: null, + type: 'MetadataCollector', + id: 'metadata-key-0', + name: 'metadata-key', + input: { key: 'metadata-key', value: {}, type: 'METADATA', validation: null }, + output: { + key: 'metadata-key', + type: 'METADATA', + config: { sessionToken: 'abc123' }, + }, + }, + ]; + + const action = { + type: 'node/update', + payload: { id: 'metadata-key-0', value: { result: 'ok' } }, + }; + const result = nodeCollectorReducer(state, action); + expect((result[0] as MetadataCollector).input.value).toEqual({ result: 'ok' }); + }); + + it('should throw when updating a MetadataCollector with a non-object value', () => { + const state: MetadataCollector[] = [ + { + category: 'ObjectValueAutoCollector', + error: null, + type: 'MetadataCollector', + id: 'metadata-key-0', + name: 'metadata-key', + input: { key: 'metadata-key', value: {}, type: 'METADATA', validation: null }, + output: { key: 'metadata-key', type: 'METADATA', config: {} }, + }, + ]; + + const action = { + type: 'node/update', + payload: { id: 'metadata-key-0', value: 'not-an-object' }, + }; + expect(() => nodeCollectorReducer(state, action)).toThrow('Value argument must be an object'); + }); +}); diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index c35e9106ca..a1ad00c1f7 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -15,6 +15,7 @@ import { createAction, createReducer } from '@reduxjs/toolkit'; import { returnActionCollector, returnFlowCollector, + returnMetadataCollector, returnPasswordCollector, returnValidatedPasswordCollector, returnIdpCollector, @@ -35,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'; @@ -169,6 +176,9 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build } break; } + case 'METADATA': { + return returnMetadataCollector(field, idx); + } default: // Default is handled below } @@ -200,6 +210,7 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build if (collector.category === 'NoValueCollector') { throw new Error('NoValueCollectors, like ReadOnlyCollectors, are read-only'); } + if (action.payload.value === undefined) { throw new Error('Value argument cannot be undefined'); } @@ -315,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') { @@ -328,10 +341,23 @@ 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') { + if ( + typeof action.payload.value !== 'object' || + action.payload.value === null || + Array.isArray(action.payload.value) + ) { + throw new Error('Value argument must be an object'); + } + collector.input.value = action.payload.value as Record; } }) /** diff --git a/packages/davinci-client/src/lib/node.slice.ts b/packages/davinci-client/src/lib/node.slice.ts index 29d91342bd..5cc3c2907d 100644 --- a/packages/davinci-client/src/lib/node.slice.ts +++ b/packages/davinci-client/src/lib/node.slice.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. @@ -20,7 +20,7 @@ import { getCollectorErrors } from './node.utils.js'; */ import type { Draft, PayloadAction } from '@reduxjs/toolkit'; -import type { SubmitCollector } from './collector.types.js'; +import type { MetadataCollector, SubmitCollector } from './collector.types.js'; import type { DavinciErrorResponse, DaVinciFailureResponse, @@ -192,12 +192,16 @@ export const nodeSlice = createSlice({ (collector): collector is SubmitCollector => collector.type === 'SubmitCollector', )[0]; + const metadataCollector = collectors.filter( + (collector): collector is MetadataCollector => collector.type === 'MetadataCollector', + )[0]; + newState.cache = { key: action.payload.requestId, }; newState.client = { - action: submitCollector?.output.key, + action: submitCollector?.output.key || metadataCollector?.output.key, description: action.payload.data?.form?.description, collectors, name: action.payload.data.form?.name, diff --git a/packages/davinci-client/src/lib/node.types.test-d.ts b/packages/davinci-client/src/lib/node.types.test-d.ts index 1c185a8da5..8e3e02a7aa 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -19,6 +19,7 @@ import type { ErrorDetail, Links } from './davinci.types.js'; import type { ActionCollector, FlowCollector, + MetadataCollector, MultiSelectCollector, PasswordCollector, ValidatedPasswordCollector, @@ -228,6 +229,7 @@ describe('Node Types', () => { it('should validate Collectors union type', () => { expectTypeOf().toMatchTypeOf< | TextCollector + | MetadataCollector | PasswordCollector | ValidatedPasswordCollector | FlowCollector diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index 58e3257c97..ff916c4919 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -8,6 +8,7 @@ import { GenericError } from '@forgerock/sdk-types'; import type { FlowCollector, + MetadataCollector, PasswordCollector, ValidatedPasswordCollector, TextCollector, @@ -38,6 +39,7 @@ import type { Links } from './davinci.types.js'; export type Collectors = | FlowCollector + | MetadataCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector 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 {