diff --git a/.changeset/tired-melons-wish.md b/.changeset/tired-melons-wish.md new file mode 100644 index 0000000000..0b5fce7737 --- /dev/null +++ b/.changeset/tired-melons-wish.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': minor +--- + +Added WebAuthn/FIDO2 collectors diff --git a/package.json b/package.json index 5bebd10524..054edbcaf9 100644 --- a/package.json +++ b/package.json @@ -107,8 +107,8 @@ "shx": "^0.4.0", "swc-loader": "0.2.6", "ts-node": "10.9.2", - "tslib": "^2.5.0", "ts-patch": "3.3.0", + "tslib": "^2.5.0", "typedoc": "^0.27.4", "typedoc-github-theme": "0.2.1", "typedoc-plugin-rename-defaults": "^0.7.2", diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index d436777b81..67bac062ad 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -34,6 +34,8 @@ import type { PhoneNumberInputValue, AutoCollectors, MultiValueCollectors, + FidoRegistrationInputValue, + FidoAuthenticationInputValue, } from './collector.types.js'; import type { InitFlow, @@ -266,16 +268,25 @@ export async function davinci({ collectorToUpdate.category !== 'SingleValueCollector' && collectorToUpdate.category !== 'ValidatedSingleValueCollector' && collectorToUpdate.category !== 'ObjectValueCollector' && - collectorToUpdate.category !== 'SingleValueAutoCollector' + collectorToUpdate.category !== 'SingleValueAutoCollector' && + collectorToUpdate.category !== 'ObjectValueAutoCollector' ) { return handleUpdateValidateError( - 'Collector is not a MultiValueCollector, SingleValueCollector, ValidatedSingleValueCollector, ObjectValueCollector, or SingleValueAutoCollector and cannot be updated', + 'Collector does not fall into a category that can be updated', 'state_error', log.error, ); } - return function (value: string | string[] | PhoneNumberInputValue, index?: number) { + return function ( + value: + | string + | string[] + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue, + index?: number, + ) { try { store.dispatch(nodeSlice.actions.update({ id, value, index })); return null; @@ -291,12 +302,16 @@ export async function davinci({ /** * @method validate - Method for validating the value against validation rules - * @param {SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors} collector - the collector to validate + * @param {SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors} collector - the collector to validate * @returns {function} - a function to call for validating collector value * @throws {Error} - if the collector cannot be validated */ validate: ( - collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors, + collector: + | SingleValueCollectors + | ObjectValueCollectors + | MultiValueCollectors + | AutoCollectors, ): Validator => { if (!collector.id) { return handleUpdateValidateError( @@ -323,10 +338,11 @@ export async function davinci({ if ( collectorToUpdate.category !== 'ValidatedSingleValueCollector' && collectorToUpdate.category !== 'ObjectValueCollector' && - collectorToUpdate.category !== 'MultiValueCollector' + collectorToUpdate.category !== 'MultiValueCollector' && + collectorToUpdate.category !== 'ObjectValueAutoCollector' ) { return handleUpdateValidateError( - 'Collector is not a SingleValueCollector, ObjectValueCollector, or MultiValueCollector and cannot be validated', + 'Collector does not fall into a category that can be validated', 'state_error', log.error, ); diff --git a/packages/davinci-client/src/lib/collector.types.test-d.ts b/packages/davinci-client/src/lib/collector.types.test-d.ts index c9a13b5b76..deafe24f8c 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -302,6 +302,7 @@ describe('Collector Types', () => { key: '', value: [''], type: '', + validation: null, }, output: { key: '', diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index 45a5df400e..f05273b3c5 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -202,7 +202,7 @@ export interface MultiValueCollectorWithValue key: string; value: string[]; type: string; - validation?: ValidationRequired[]; + validation: ValidationRequired[] | null; }; output: { key: string; @@ -302,6 +302,14 @@ export interface PhoneNumberOutputValue { phoneNumber?: string; } +export interface FidoRegistrationInputValue { + attestationValue?: PublicKeyCredential; +} + +export interface FidoAuthenticationInputValue { + assertionValue?: PublicKeyCredential; +} + export interface ObjectOptionsCollectorWithStringValue< T extends ObjectValueCollectorTypes, V = string, @@ -315,7 +323,7 @@ export interface ObjectOptionsCollectorWithStringValue< key: string; value: V; type: string; - validation?: ValidationRequired[]; + validation: ValidationRequired[] | null; }; output: { key: string; @@ -339,7 +347,7 @@ export interface ObjectOptionsCollectorWithObjectValue< key: string; value: V; type: string; - validation?: ValidationRequired[]; + validation: ValidationRequired[] | null; }; output: { key: string; @@ -364,7 +372,7 @@ export interface ObjectValueCollectorWithObjectValue< key: string; value: IV; type: string; - validation?: (ValidationRequired | ValidationPhoneNumber)[]; + validation: (ValidationRequired | ValidationPhoneNumber)[] | null; }; output: { key: string; @@ -536,13 +544,18 @@ export type UnknownCollector = { * @interface AutoCollector - Represents a collector that collects a value programmatically without user intervention. */ -export type AutoCollectorCategories = 'SingleValueAutoCollector'; -export type AutoCollectorTypes = AutoCollectorCategories | 'ProtectCollector'; +export type AutoCollectorCategories = 'SingleValueAutoCollector' | 'ObjectValueAutoCollector'; +export type SingleValueAutoCollectorTypes = 'SingleValueAutoCollector' | 'ProtectCollector'; +export type ObjectValueAutoCollectorTypes = + | 'ObjectValueAutoCollector' + | 'FidoRegistrationCollector' + | 'FidoAuthenticationCollector'; +export type AutoCollectorTypes = SingleValueAutoCollectorTypes | ObjectValueAutoCollectorTypes; export interface AutoCollector< C extends AutoCollectorCategories, T extends AutoCollectorTypes, - V = string, + IV = string, > { category: C; error: string | null; @@ -551,8 +564,9 @@ export interface AutoCollector< name: string; input: { key: string; - value: V; + value: IV; type: string; + validation?: ValidationRequired[] | null; }; output: { key: string; @@ -566,13 +580,33 @@ export type ProtectCollector = AutoCollector< 'ProtectCollector', string >; +export type FidoRegistrationCollector = AutoCollector< + 'ObjectValueAutoCollector', + 'FidoRegistrationCollector', + FidoRegistrationInputValue +>; +export type FidoAuthenticationCollector = AutoCollector< + 'ObjectValueAutoCollector', + 'FidoAuthenticationCollector', + FidoAuthenticationInputValue +>; export type SingleValueAutoCollector = AutoCollector< 'SingleValueAutoCollector', 'SingleValueAutoCollector', string >; +export type ObjectValueAutoCollector = AutoCollector< + 'ObjectValueAutoCollector', + 'ObjectValueAutoCollector', + Record +>; -export type AutoCollectors = ProtectCollector | SingleValueAutoCollector; +export type AutoCollectors = + | ProtectCollector + | FidoRegistrationCollector + | FidoAuthenticationCollector + | SingleValueAutoCollector + | ObjectValueAutoCollector; /** * Type to help infer the collector based on the collector type @@ -583,8 +617,14 @@ export type AutoCollectors = ProtectCollector | SingleValueAutoCollector; */ export type InferAutoCollectorType = T extends 'ProtectCollector' ? ProtectCollector - : /** - * At this point, we have not passed in a collector type - * so we can return a SingleValueAutoCollector - **/ - SingleValueAutoCollector; + : T extends 'FidoRegistrationCollector' + ? 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; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index 1e8f70d5fa..a50925d4d0 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -20,17 +20,22 @@ import { returnNoValueCollector, returnObjectSelectCollector, returnObjectValueCollector, + returnSingleValueAutoCollector, + returnObjectValueAutoCollector, } from './collector.utils.js'; import type { DaVinciField, DeviceAuthenticationField, DeviceRegistrationField, + FidoAuthenticationField, + FidoRegistrationField, PhoneNumberField, + ProtectField, ReadOnlyField, RedirectField, StandardField, } from './davinci.types.js'; -import { +import type { MultiSelectCollector, PhoneNumberCollector, PhoneNumberOutputValue, @@ -472,6 +477,7 @@ describe('Object value collectors', () => { value: 'device1-value', }, type: mockField.type, + validation: null, }, output: { key: mockField.key, @@ -622,6 +628,7 @@ describe('returnPhoneNumberCollector', () => { phoneNumber: '', }, type: mockField.type, + validation: null, }, output: { key: mockField.key, @@ -661,6 +668,7 @@ describe('returnPhoneNumberCollector', () => { phoneNumber: '', }, type: mockField.type, + validation: null, }, output: { key: mockField.key, @@ -702,6 +710,7 @@ describe('returnPhoneNumberCollector', () => { phoneNumber: prefillMock.phoneNumber, }, type: mockField.type, + validation: null, }, output: { key: mockField.key, @@ -742,6 +751,7 @@ describe('returnPhoneNumberCollector', () => { phoneNumber: prefillMock.phoneNumber, }, type: mockField.type, + validation: null, }, output: { key: mockField.key, @@ -799,6 +809,155 @@ describe('No Value Collectors', () => { }); }); +describe('returnSingleValueAutoCollector', () => { + it('should create a valid ProtectCollector', () => { + const mockField: ProtectField = { + type: 'PROTECT', + key: 'protect-key', + behavioralDataCollection: true, + universalDeviceIdentification: false, + }; + const result = returnSingleValueAutoCollector(mockField, 1, 'ProtectCollector'); + expect(result).toEqual({ + category: 'SingleValueAutoCollector', + error: null, + type: 'ProtectCollector', + id: 'protect-key-1', + name: 'protect-key', + input: { + key: mockField.key, + value: '', + type: mockField.type, + }, + output: { + key: mockField.key, + type: mockField.type, + config: { + behavioralDataCollection: mockField.behavioralDataCollection, + universalDeviceIdentification: mockField.universalDeviceIdentification, + }, + }, + }); + }); +}); + +describe('returnObjectValueAutoCollector', () => { + it('should create a valid FidoRegistrationCollector', () => { + const mockField: FidoRegistrationField = { + type: 'FIDO2', + key: 'fido2', + label: 'Register your security key', + action: 'REGISTER', + trigger: 'BUTTON', + required: true, + publicKeyCredentialCreationOptions: { + 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 result = returnObjectValueAutoCollector(mockField, 1, 'FidoRegistrationCollector'); + expect(result).toEqual({ + category: 'ObjectValueAutoCollector', + error: null, + type: 'FidoRegistrationCollector', + id: 'fido2-1', + name: 'fido2', + input: { + key: mockField.key, + value: {}, + type: mockField.type, + validation: [ + { + message: 'Value cannot be empty', + rule: true, + type: 'required', + }, + ], + }, + output: { + key: mockField.key, + type: mockField.type, + config: { + publicKeyCredentialCreationOptions: mockField.publicKeyCredentialCreationOptions, + action: mockField.action, + trigger: mockField.trigger, + }, + }, + }); + }); + + it('should create a valid FidoAuthenticationCollector', () => { + const mockField: FidoAuthenticationField = { + type: 'FIDO2', + key: 'fido2', + label: 'Authenticate with your security key', + action: 'AUTHENTICATE', + trigger: 'BUTTON', + required: false, + publicKeyCredentialRequestOptions: { + challenge: [1, 2, 3, 4], + timeout: 60000, + rpId: 'example.com', + allowCredentials: [ + { + type: 'public-key', + id: [1, 2, 3, 4], + }, + ], + userVerification: 'preferred', + }, + }; + const result = returnObjectValueAutoCollector(mockField, 1, 'FidoAuthenticationCollector'); + expect(result).toEqual({ + category: 'ObjectValueAutoCollector', + error: null, + type: 'FidoAuthenticationCollector', + id: 'fido2-1', + name: 'fido2', + input: { + key: mockField.key, + value: {}, + type: mockField.type, + validation: null, + }, + output: { + key: mockField.key, + type: mockField.type, + config: { + publicKeyCredentialRequestOptions: mockField.publicKeyCredentialRequestOptions, + action: mockField.action, + trigger: mockField.trigger, + }, + }, + }); + }); +}); + describe('Return collector validator', () => { const validatedTextCollector = { input: { @@ -835,7 +994,7 @@ describe('Return collector validator', () => { const objResult = objValidator({}); expect(objResult).toContain('This field is required'); - const multiValueResult = multiValueValidator({}); + const multiValueResult = multiValueValidator([]); expect(multiValueResult).toContain('This field is required'); }); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index b8485bfadc..68ac1c27e8 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -21,16 +21,20 @@ import type { ValidatedTextCollector, InferValueObjectCollectorType, ObjectValueCollectorTypes, - AutoCollectorTypes, UnknownCollector, InferAutoCollectorType, PhoneNumberOutputValue, MultiValueCollectors, ObjectValueCollectors, + AutoCollectors, + SingleValueAutoCollectorTypes, + ObjectValueAutoCollectorTypes, } from './collector.types.js'; import type { DeviceAuthenticationField, DeviceRegistrationField, + FidoAuthenticationField, + FidoRegistrationField, MultiSelectField, PhoneNumberField, ProtectField, @@ -260,16 +264,16 @@ export function returnSingleValueCollector< } /** - * @function returnAutoCollector - Creates an AutoCollector object based on the provided field, index, and optional collector type. + * @function returnSingleValueAutoCollector - Creates a SingleValueAutoCollector object based on the provided field, index, and optional collector type. * @param {DaVinciField} field - The field object containing key, label, type, and links. * @param {number} idx - The index to be used in the id of the AutoCollector. - * @param {AutoCollectorTypes} [collectorType] - Optional type of the AutoCollector. + * @param {SingleValueAutoCollectorTypes} [collectorType] - Optional type of the AutoCollector. * @returns {AutoCollector} The constructed AutoCollector object. */ -export function returnAutoCollector< +export function returnSingleValueAutoCollector< Field extends ProtectField, - CollectorType extends AutoCollectorTypes = 'SingleValueAutoCollector', ->(field: Field, idx: number, collectorType: CollectorType, data?: string) { + CollectorType extends SingleValueAutoCollectorTypes = 'SingleValueAutoCollector', +>(field: Field, idx: number, collectorType: CollectorType) { let error = ''; if (!('key' in field)) { error = `${error}Key is not found in the field object. `; @@ -287,7 +291,7 @@ export function returnAutoCollector< name: field.key, input: { key: field.key, - value: data || '', + value: '', type: field.type, }, output: { @@ -308,7 +312,7 @@ export function returnAutoCollector< name: field.key, input: { key: field.key, - value: data || '', + value: '', type: field.type, }, output: { @@ -319,6 +323,83 @@ export function returnAutoCollector< } } +/** + * @function returnObjectValueAutoCollector - Creates an ObjectValueAutoCollector object based on the provided field, index, and optional collector type. + * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @param {number} idx - The index to be used in the id of the AutoCollector. + * @param {ObjectValueAutoCollectorTypes} [collectorType] - Optional type of the AutoCollector. + * @returns {AutoCollector} The constructed AutoCollector object. + */ +export function returnObjectValueAutoCollector< + Field extends FidoRegistrationField | FidoAuthenticationField, + CollectorType extends ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector', +>(field: Field, idx: number, collectorType: CollectorType) { + let error = ''; + if (!('key' in field)) { + error = `${error}Key is not found in the field object. `; + } + if (!('type' in field)) { + error = `${error}Type is not found in the field object. `; + } + + const validationArray = []; + if ('required' in field && field.required === true) { + validationArray.push({ + type: 'required', + message: 'Value cannot be empty', + rule: true, + }); + } + + if (field.action === 'REGISTER') { + 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: { + publicKeyCredentialCreationOptions: field.publicKeyCredentialCreationOptions, + action: field.action, + trigger: field.trigger, + }, + }, + } as InferAutoCollectorType<'FidoRegistrationCollector'>; + } else { + 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: { + publicKeyCredentialRequestOptions: field.publicKeyCredentialRequestOptions, + action: field.action, + trigger: field.trigger, + }, + }, + } as InferAutoCollectorType<'FidoAuthenticationCollector'>; + } +} + /** * @function returnPasswordCollector - Creates a PasswordCollector object based on the provided field and index. * @param {DaVinciField} field - The field object containing key, label, type, and links. @@ -355,8 +436,28 @@ export function returnSingleSelectCollector(field: SingleSelectField, idx: numbe * @param {number} idx - The index to be used in the id of the ProtectCollector. * @returns {ProtectCollector} The constructed ProtectCollector object. */ -export function returnProtectCollector(field: ProtectField, idx: number, data: string) { - return returnAutoCollector(field, idx, 'ProtectCollector', data); +export function returnProtectCollector(field: ProtectField, idx: number) { + return returnSingleValueAutoCollector(field, idx, 'ProtectCollector'); +} + +/** + * @function returnFidoRegistrationCollector - Creates a FidoRegistrationCollector object based on the provided field and index. + * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @param {number} idx - The index to be used in the id of the FidoRegistrationCollector. + * @returns {FidoRegistrationCollector} The constructed FidoRegistrationCollector object. + */ +export function returnFidoRegistrationCollector(field: FidoRegistrationField, idx: number) { + return returnObjectValueAutoCollector(field, idx, 'FidoRegistrationCollector'); +} + +/** + * @function returnFidoAuthenticationCollector - Creates a FidoAuthenticationCollector object based on the provided field and index. + * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @param {number} idx - The index to be used in the id of the FidoAuthenticationCollector. + * @returns {FidoAuthenticationCollector} The constructed FidoAuthenticationCollector object. + */ +export function returnFidoAuthenticationCollector(field: FidoAuthenticationField, idx: number) { + return returnObjectValueAutoCollector(field, idx, 'FidoAuthenticationCollector'); } /** @@ -403,7 +504,7 @@ export function returnMultiValueCollector< key: field.key, value: data || [], type: field.type, - validation: validationArray.length ? validationArray : undefined, + validation: validationArray.length ? validationArray : null, }, output: { key: field.key, @@ -467,7 +568,7 @@ export function returnObjectCollector< error = `${error}Device options are not an array or is empty. `; } - const unmappedDefault = field.options.find((device) => device.default); + const unmappedDefault = field.options?.find((device) => device.default); defaultValue = { type: unmappedDefault ? unmappedDefault.type : '', value: unmappedDefault ? unmappedDefault.description : '', @@ -475,19 +576,19 @@ export function returnObjectCollector< }; // Map DaVinci spec to normalized SDK API - options = field.options.map((device) => ({ - type: device.type, - label: device.title, - content: device.description, - value: device.id, - key: device.id, - default: device.default, - })); + options = + field.options?.map((device) => ({ + type: device.type, + label: device.title, + content: device.description, + value: device.id, + key: device.id, + default: device.default, + })) ?? []; } else if (field.type === 'DEVICE_REGISTRATION') { if (!('options' in field)) { error = `${error}Device options are not found in the field object. `; } - if (Array.isArray(field.options) && field.options.length === 0) { error = `${error}Device options are not an array or is empty. `; } @@ -495,13 +596,14 @@ export function returnObjectCollector< defaultValue = ''; // Map DaVinci spec to normalized SDK API - options = field.options.map((device, idx) => ({ - type: device.type, - label: device.title, - content: device.description, - value: device.type, - key: `${device.type}-${idx}`, - })); + options = + field.options?.map((device, idx) => ({ + type: device.type, + label: device.title, + content: device.description, + value: device.type, + key: `${device.type}-${idx}`, + })) ?? []; } else if (field.type === 'PHONE_NUMBER') { if ('validatePhoneNumber' in field && field.validatePhoneNumber === true) { validationArray.push({ @@ -529,7 +631,7 @@ export function returnObjectCollector< key: field.key, value: defaultValue, type: field.type, - validation: validationArray.length ? validationArray : undefined, + validation: validationArray.length ? validationArray : null, }, output: { key: field.key, @@ -613,14 +715,14 @@ export function returnReadOnlyCollector(field: ReadOnlyField, idx: number) { /** * @function returnValidator - Creates a validator function based on the provided collector - * @param {ValidatedTextCollector | ObjectValueCollectors | MultiValueCollectors} collector - The collector to which the value will be validated + * @param {ValidatedTextCollector | ObjectValueCollectors | MultiValueCollectors | AutoCollectors} collector - The collector to which the value will be validated * @returns {function} - A "validator" function that validates the input value */ export function returnValidator( - collector: ValidatedTextCollector | ObjectValueCollectors | MultiValueCollectors, + collector: ValidatedTextCollector | ObjectValueCollectors | MultiValueCollectors | AutoCollectors, ) { const rules = collector.input.validation; - return (value: string | string[] | Record) => { + return (value: string | string[] | Record) => { return ( rules?.reduce((acc, next) => { if (next.type === 'required') { diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index f4a363fbe5..3de18e800c 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -149,8 +149,8 @@ export type PhoneNumberField = { key: string; label: string; defaultCountryCode: string | null; - required: boolean; // TODO: add to phone collector - validatePhoneNumber: boolean; // TODO: add to phone collector + required: boolean; + validatePhoneNumber: boolean; }; export type ProtectField = { @@ -160,12 +160,54 @@ export type ProtectField = { universalDeviceIdentification: boolean; }; +export interface FidoRegistrationOptions + extends Omit { + challenge: number[]; + user: { + id: number[]; + name: string; + displayName: string; + }; +} + +export type FidoRegistrationField = { + type: 'FIDO2'; + key: string; + label: string; + publicKeyCredentialCreationOptions: FidoRegistrationOptions; + action: 'REGISTER'; + trigger: string; + required: boolean; +}; + +export interface FidoAuthenticationOptions + extends Omit { + challenge: number[]; + allowCredentials?: { + id: number[]; + transports?: AuthenticatorTransport[]; + type: PublicKeyCredentialType; + }[]; +} + +export type FidoAuthenticationField = { + type: 'FIDO2'; + key: string; + label: string; + publicKeyCredentialRequestOptions: FidoAuthenticationOptions; + action: 'AUTHENTICATE'; + trigger: string; + required: boolean; +}; + export type UnknownField = Record; export type ComplexValueFields = | DeviceAuthenticationField | DeviceRegistrationField - | PhoneNumberField; + | PhoneNumberField + | FidoRegistrationField + | FidoAuthenticationField; export type MultiValueFields = MultiSelectField; export type ReadOnlyFields = ReadOnlyField; export type RedirectFields = RedirectField; diff --git a/packages/davinci-client/src/lib/davinci.utils.ts b/packages/davinci-client/src/lib/davinci.utils.ts index a91b202c68..474c20a460 100644 --- a/packages/davinci-client/src/lib/davinci.utils.ts +++ b/packages/davinci-client/src/lib/davinci.utils.ts @@ -22,7 +22,12 @@ import type { DaVinciSuccessResponse, } from './davinci.types.js'; import type { ContinueNode } from './node.types.js'; -import { DeviceValue, PhoneNumberInputValue } from './collector.types.js'; +import { + DeviceValue, + FidoAuthenticationInputValue, + FidoRegistrationInputValue, + PhoneNumberInputValue, +} from './collector.types.js'; /** * @function transformSubmitRequest - Transforms a NextNode into a DaVinciRequest for form submissions * @param {ContinueNode} node - The node to transform into a DaVinciRequest @@ -49,7 +54,9 @@ export function transformSubmitRequest( | boolean | (string | number | boolean)[] | DeviceValue - | PhoneNumberInputValue; + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue; }>((acc, collector) => { acc[collector.input.key] = collector.input.value; return acc; diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index b9196b5a14..d3ff78408d 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -7,14 +7,18 @@ import { describe, it, expect } from 'vitest'; import { nodeCollectorReducer } from './node.reducer.js'; -import { +import type { DeviceAuthenticationCollector, DeviceRegistrationCollector, + FidoAuthenticationCollector, + FidoRegistrationCollector, MultiSelectCollector, PhoneNumberCollector, + ProtectCollector, SubmitCollector, TextCollector, } from './collector.types.js'; +import type { FidoAuthenticationOptions, FidoRegistrationOptions } from './davinci.types.js'; describe('The node collector reducer', () => { it('should return the initial state', () => { @@ -433,6 +437,7 @@ describe('The node collector reducer with MultiValueCollector', () => { key: 'color', value: [], type: 'TEXT', + validation: null, }, output: { key: 'color', @@ -467,6 +472,7 @@ describe('The node collector reducer with MultiValueCollector', () => { key: 'color', value: ['red'], type: 'TEXT', + validation: null, }, output: { key: 'color', @@ -517,6 +523,7 @@ describe('The node collector reducer with DeviceAuthenticationFieldValue', () => value: '', }, type: 'TEXT', + validation: null, }, output: { key: 'device', @@ -566,6 +573,7 @@ describe('The node collector reducer with DeviceAuthenticationFieldValue', () => value: 's***********1@pingidentity.com', }, type: 'TEXT', + validation: null, }, output: { key: 'device', @@ -623,6 +631,7 @@ describe('The node collector reducer with DeviceRegistrationFieldValue', () => { key: 'device', value: 'EMAIL', type: 'TEXT', + validation: null, }, output: { key: 'device', @@ -665,6 +674,7 @@ describe('The node collector reducer with DeviceRegistrationFieldValue', () => { key: 'device', value: 'EMAIL', type: 'TEXT', + validation: null, }, output: { key: 'device', @@ -729,6 +739,7 @@ describe('The phone number collector reducer', () => { phoneNumber: '', }, type: 'PHONE_NUMBER', + validation: null, }, output: { key: 'phone-number-key', @@ -772,6 +783,7 @@ describe('The phone number collector reducer', () => { phoneNumber: '', }, type: 'PHONE_NUMBER', + validation: null, }, output: { key: 'phone-number-key', @@ -811,6 +823,7 @@ describe('The phone number collector reducer', () => { phoneNumber: '', }, type: 'PHONE_NUMBER', + validation: null, }, output: { key: 'phone-number-key', @@ -836,6 +849,7 @@ describe('The phone number collector reducer', () => { phoneNumber: '555-555-5555', }, type: 'PHONE_NUMBER', + validation: null, }, output: { key: 'phone-number-key', @@ -849,3 +863,255 @@ describe('The phone number collector reducer', () => { ]); }); }); + +describe('The node collector reducer with ProtectFieldValue', () => { + it('should handle collector updates', () => { + const action = { + type: 'node/update', + payload: { + id: 'protect-key-0', + value: 'mock-data', + }, + }; + + const state: ProtectCollector[] = [ + { + category: 'SingleValueAutoCollector', + error: null, + type: 'ProtectCollector', + id: 'protect-key-0', + name: 'protect-key', + input: { + key: 'protect-key', + value: '', + type: 'PROTECT', + }, + output: { + key: 'protect-key', + type: 'PROTECT', + config: { + behavioralDataCollection: true, + universalDeviceIdentification: false, + }, + }, + }, + ]; + + expect(nodeCollectorReducer(state, action)).toStrictEqual([ + { + category: 'SingleValueAutoCollector', + error: null, + type: 'ProtectCollector', + id: 'protect-key-0', + name: 'protect-key', + input: { + key: 'protect-key', + value: 'mock-data', + type: 'PROTECT', + }, + output: { + key: 'protect-key', + type: 'PROTECT', + config: { + behavioralDataCollection: true, + universalDeviceIdentification: false, + }, + }, + }, + ]); + }); +}); + +describe('The node collector reducer with FidoRegistrationFieldValue', () => { + it('should handle collector updates ', () => { + // todo: declare inputValue type as FidoRegistrationInputValue + const mockInputValue = { + attestationValue: { + id: '1HHEH4ATYSax0K-TBW-YpA', + type: 'public-key', + rawId: '1HHEH4ATYSax0K+TBW+YpA==', + authenticatorAttachment: 'platform', + response: { + clientDataJSON: 'mock-client-data-json', + attestationObject: 'mock-attestation-object', + }, + }, + }; + 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: mockInputValue, + }, + }; + + 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: mockInputValue, + type: 'FIDO2', + validation: null, + }, + output: { + key: 'fido2-registration', + type: 'FIDO2', + config: { + publicKeyCredentialCreationOptions, + action: 'REGISTER', + trigger: 'BUTTON', + }, + }, + }, + ]); + }); +}); + +describe('The node collector reducer with FidoAuthenticationFieldValue', () => { + it('should handle collector updates ', () => { + // todo: declare inputValue type as FidoAuthenticationInputValue + const mockInputValue = { + assertionValue: { + id: 'p_DyLMDrLOpMbuDLA-wnFA', + rawId: 'p/DyLMDrLOpMbuDLA+wnFA==', + type: 'public-key', + response: { + clientDataJSON: 'mock-client-data-json', + authenticatorData: 'mock-authenticator-data', + signature: 'mock-signature', + userHandle: 'mock-user-handle', + }, + }, + }; + 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: mockInputValue, + }, + }; + + 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: mockInputValue, + type: 'FIDO2', + validation: null, + }, + output: { + key: 'fido2-authentication', + type: 'FIDO2', + config: { + publicKeyCredentialRequestOptions, + action: 'AUTHENTICATE', + trigger: 'BUTTON', + }, + }, + }, + ]); + }); +}); diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index 4e1d7a3058..63bf2e36ec 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -26,9 +26,11 @@ import { returnObjectValueCollector, returnProtectCollector, returnUnknownCollector, + returnFidoRegistrationCollector, + returnFidoAuthenticationCollector, } from './collector.utils.js'; import type { DaVinciField, UnknownField } from './davinci.types.js'; -import { +import type { ActionCollector, MultiSelectCollector, SingleSelectCollector, @@ -47,6 +49,10 @@ import { PhoneNumberOutputValue, UnknownCollector, ProtectCollector, + FidoRegistrationCollector, + FidoAuthenticationCollector, + FidoAuthenticationInputValue, + FidoRegistrationInputValue, } from './collector.types.js'; /** @@ -61,7 +67,12 @@ export const nextCollectorValues = createAction<{ }>('node/next'); export const updateCollectorValues = createAction<{ id: string; - value: string | string[] | PhoneNumberInputValue; + value: + | string + | string[] + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue; index?: number; }>('node/update'); @@ -85,6 +96,8 @@ const initialCollectorValues: ( | ValidatedTextCollector | UnknownCollector | ProtectCollector + | FidoRegistrationCollector + | FidoAuthenticationCollector )[] = []; /** @@ -164,8 +177,15 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build return returnSubmitCollector(field, idx); } case 'PROTECT': { - const str = data as string; - return returnProtectCollector(field, idx, str); + return returnProtectCollector(field, idx); + } + case 'FIDO2': { + if (field.action === 'REGISTER') { + return returnFidoRegistrationCollector(field, idx); + } else if (field.action === 'AUTHENTICATE') { + return returnFidoAuthenticationCollector(field, idx); + } + break; } default: // Default is handled below @@ -184,8 +204,8 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build }) /** * Using the `updateCollectorValues` const (e.g. `'node/update'`) to add the case - * 'node/next' is essentially derived `createSlice` below. `node.next()` is - * transformed to `'node/next'` for the action type. + * 'node/update' is essentially derived `createSlice` below. `node.update()` is + * transformed to `'node/update'` for the action type. */ .addCase(updateCollectorValues, (state, action) => { const collector = state.find((collector) => collector.id === action.payload.id); @@ -205,7 +225,7 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build if ( collector.category === 'SingleValueCollector' || collector.category === 'ValidatedSingleValueCollector' || - collector.category === 'SingleValueAutoCollector' + collector.type === 'ProtectCollector' ) { if (typeof action.payload.value !== 'string') { throw new Error('Value argument must be a string'); @@ -276,5 +296,31 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build } collector.input.value = action.payload.value; } + + if (collector.type === 'FidoRegistrationCollector') { + if (typeof action.payload.id !== 'string') { + throw new Error('Index argument must be a string'); + } + if (typeof action.payload.value !== 'object') { + throw new Error('Value argument must be an object'); + } + if (!('attestationValue' in action.payload.value)) { + throw new Error('Value argument must contain an attestationValue property'); + } + collector.input.value = action.payload.value; + } + + if (collector.type === 'FidoAuthenticationCollector') { + if (typeof action.payload.id !== 'string') { + throw new Error('Index argument must be a string'); + } + if (typeof action.payload.value !== 'object') { + throw new Error('Value argument must be an object'); + } + if (!('assertionValue' in action.payload.value)) { + throw new Error('Value argument must contain an assertionValue property'); + } + collector.input.value = action.payload.value; + } }); }); 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 3beda1d0ec..de5f5b67f0 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -33,6 +33,8 @@ import { PhoneNumberCollector, UnknownCollector, ProtectCollector, + FidoRegistrationCollector, + FidoAuthenticationCollector, } from './collector.types.js'; // ErrorDetail and Links are used as part of the DaVinciError and server._links types respectively @@ -232,6 +234,8 @@ describe('Node Types', () => { | SingleSelectCollector | ValidatedTextCollector | ProtectCollector + | FidoRegistrationCollector + | FidoAuthenticationCollector | UnknownCollector >(); diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index c6de407dd3..2bcc57a5f2 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -23,6 +23,8 @@ import type { PhoneNumberCollector, ProtectCollector, UnknownCollector, + FidoRegistrationCollector, + FidoAuthenticationCollector, } from './collector.types.js'; import type { Links } from './davinci.types.js'; @@ -42,6 +44,8 @@ export type Collectors = | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector + | FidoRegistrationCollector + | FidoAuthenticationCollector | UnknownCollector; export interface CollectorErrors { diff --git a/packages/davinci-client/src/types.ts b/packages/davinci-client/src/types.ts index 6b410b6739..d6a01a6c46 100644 --- a/packages/davinci-client/src/types.ts +++ b/packages/davinci-client/src/types.ts @@ -49,6 +49,8 @@ export type DeviceRegistrationCollector = collectors.DeviceRegistrationCollector export type DeviceAuthenticationCollector = collectors.DeviceAuthenticationCollector; export type PhoneNumberCollector = collectors.PhoneNumberCollector; export type ProtectCollector = collectors.ProtectCollector; +export type FidoRegistrationCollector = collectors.FidoRegistrationCollector; +export type FidoAuthenticationCollector = collectors.FidoAuthenticationCollector; export type InternalErrorResponse = client.InternalErrorResponse; export type { RequestMiddleware };