diff --git a/.changeset/embed-password-policy-in-component.md b/.changeset/embed-password-policy-in-component.md new file mode 100644 index 0000000000..70968475fd --- /dev/null +++ b/.changeset/embed-password-policy-in-component.md @@ -0,0 +1,9 @@ +--- +'@forgerock/davinci-client': minor +--- + +Add `ValidatedPasswordCollector` alongside `PasswordCollector`. The new collector is emitted whenever a password field carries a `passwordPolicy` — the presence of the policy is the sole discriminator between the two types, regardless of the server-side field tag (`PASSWORD` vs `PASSWORD_VERIFY`). `ValidatedPasswordCollector.output.passwordPolicy` is required; consumers can render password requirements directly from the collector. + +Both collectors now expose a `verify: boolean` on `output` (defaults to `false`), propagated from the field when the server sends `verify: true`. + +`store.validate(collector)` accepts a `ValidatedPasswordCollector` and returns a validator that enforces the policy's length, unique-character, repeated-character, and per-charset minimum rules. Passing a `PasswordCollector` returns the standard "cannot be validated" error. diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000000..0467d38052 --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +.opensource/ diff --git a/.opensource/forgerock-javascript-sdk b/.opensource/forgerock-javascript-sdk new file mode 160000 index 0000000000..1e3f0d7de2 --- /dev/null +++ b/.opensource/forgerock-javascript-sdk @@ -0,0 +1 @@ +Subproject commit 1e3f0d7de2572ae5a0433525c5af65c73c031e67 diff --git a/e2e/davinci-app/components/password.ts b/e2e/davinci-app/components/password.ts index 5b835478f8..1f9ec63405 100644 --- a/e2e/davinci-app/components/password.ts +++ b/e2e/davinci-app/components/password.ts @@ -4,32 +4,109 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import type { PasswordCollector, Updater } from '@forgerock/davinci-client/types'; +import type { + PasswordCollector, + ValidatedPasswordCollector, + Updater, + Validator, +} from '@forgerock/davinci-client/types'; import { dotToCamelCase } from '../helper.js'; +const UPPERCASE_RE = /^[A-Z]+$/; +const LOWERCASE_RE = /^[a-z]+$/; +const DIGIT_RE = /^[0-9]+$/; + export default function passwordComponent( formEl: HTMLFormElement, - collector: PasswordCollector, - updater: Updater, + collector: PasswordCollector | ValidatedPasswordCollector, + updater: Updater, + validator?: Validator, ) { + const collectorKey = dotToCamelCase(collector.output.key); const label = document.createElement('label'); const input = document.createElement('input'); - label.htmlFor = dotToCamelCase(collector.output.key); + label.htmlFor = collectorKey; label.innerText = collector.output.label; input.type = 'password'; - input.id = dotToCamelCase(collector.output.key); - input.name = dotToCamelCase(collector.output.key); + input.id = collectorKey; + input.name = collectorKey; formEl?.appendChild(label); formEl?.appendChild(input); - formEl - ?.querySelector(`#${dotToCamelCase(collector.output.key)}`) - ?.addEventListener('blur', (event: Event) => { - const error = updater((event.target as HTMLInputElement).value); - if (error && 'error' in error) { - console.error(error.error.message); + if (collector.type === 'ValidatedPasswordCollector') { + const passwordPolicy = collector.output.passwordPolicy; + const requirementsList = document.createElement('ul'); + requirementsList.className = 'password-requirements'; + + if (passwordPolicy.length) { + const { min, max } = passwordPolicy.length; + let lengthMessage: string | null = null; + if (min != null && max != null) { + lengthMessage = `${min}–${max} characters`; + } else if (min != null) { + lengthMessage = `At least ${min} characters`; + } else if (max != null) { + lengthMessage = `At most ${max} characters`; + } + if (lengthMessage) { + const li = document.createElement('li'); + li.textContent = lengthMessage; + requirementsList.appendChild(li); + } + } + + if (passwordPolicy.minCharacters) { + for (const [charset, count] of Object.entries(passwordPolicy.minCharacters)) { + const li = document.createElement('li'); + if (UPPERCASE_RE.test(charset)) { + li.textContent = `At least ${count} uppercase letter(s)`; + } else if (LOWERCASE_RE.test(charset)) { + li.textContent = `At least ${count} lowercase letter(s)`; + } else if (DIGIT_RE.test(charset)) { + li.textContent = `At least ${count} number(s)`; + } else { + li.textContent = `At least ${count} special character(s)`; + } + requirementsList.appendChild(li); } - }); + } + + if (requirementsList.children.length > 0) { + formEl?.appendChild(requirementsList); + } + } + + const inputEl = formEl?.querySelector(`#${collectorKey}`); + const shouldValidate = collector.type === 'ValidatedPasswordCollector' && !!validator; + + inputEl?.addEventListener('input', (event: Event) => { + const value = (event.target as HTMLInputElement).value; + + if (shouldValidate) { + const result = validator(value); + if (Array.isArray(result) && result.length) { + let errorEl = formEl?.querySelector(`.${collectorKey}-error`); + if (!errorEl) { + errorEl = document.createElement('ul'); + errorEl.className = `${collectorKey}-error`; + inputEl.after(errorEl); + } + const items = result.map((msg) => { + const li = document.createElement('li'); + li.textContent = msg; + return li; + }); + errorEl.replaceChildren(...items); + return; + } + formEl?.querySelector(`.${collectorKey}-error`)?.remove(); + } + + const error = updater(value); + if (error && 'error' in error) { + console.error(error.error.message); + } + }); } diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index 46ef800d5b..d9327e2083 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -234,13 +234,17 @@ const urlParams = new URLSearchParams(window.location.search); davinciClient.update(collector), // Returns an update function for this collector davinciClient.validate(collector), // Returns a validate function for this collector ); - } else if (collector.type === 'PasswordCollector') { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - collector; + } else if ( + collector.type === 'PasswordCollector' || + collector.type === 'ValidatedPasswordCollector' + ) { passwordComponent( formEl, // You can ignore this; it's just for rendering collector, // This is the plain object of the collector davinciClient.update(collector), // Returns an update function for this collector + collector.type === 'ValidatedPasswordCollector' + ? davinciClient.validate(collector) + : undefined, ); } else if (collector.type === 'SubmitCollector') { submitButtonComponent( diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index cedf484d86..f361a1d1bc 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -147,11 +147,13 @@ export interface CollectorErrors { } // @public (undocumented) -export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | UnknownCollector; +export type Collectors = FlowCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | UnknownCollector; // @public export type CollectorValueType = T extends { type: 'PasswordCollector'; +} ? string : T extends { + type: 'ValidatedPasswordCollector'; } ? string : T extends { type: 'TextCollector'; category: 'SingleValueCollector'; @@ -1001,7 +1003,7 @@ export type InferMultiValueCollectorType = T export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : NoValueCollectorBase<'NoValueCollector'>; // @public -export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; +export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : T extends 'ValidatedPasswordCollector' ? ValidatedPasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; // @public (undocumented) export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; @@ -1139,8 +1141,8 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) @@ -1292,7 +1294,90 @@ export interface OutgoingQueryParams { } // @public (undocumented) -export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>; +export interface PasswordCollector { + // (undocumented) + category: 'SingleValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: string | number | boolean; + type: string; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + verify: boolean; + }; + // (undocumented) + type: 'PasswordCollector'; +} + +// @public +export type PasswordField = { + type: 'PASSWORD' | 'PASSWORD_VERIFY'; + key: string; + label: string; + required?: boolean; + verify?: boolean; + passwordPolicy?: PasswordPolicy; +}; + +// @public (undocumented) +export interface PasswordPolicy { + // (undocumented) + createdAt?: string; + // (undocumented) + default?: boolean; + // (undocumented) + description?: string; + // (undocumented) + excludesCommonlyUsed?: boolean; + // (undocumented) + excludesProfileData?: boolean; + // (undocumented) + history?: { + count?: number; + retentionDays?: number; + }; + // (undocumented) + id?: string; + // (undocumented) + length?: { + min?: number; + max?: number; + }; + // (undocumented) + lockout?: { + failureCount?: number; + durationSeconds?: number; + }; + // (undocumented) + maxAgeDays?: number; + // (undocumented) + maxRepeatedCharacters?: number; + // (undocumented) + minAgeDays?: number; + // (undocumented) + minCharacters?: Record; + // (undocumented) + minUniqueCharacters?: number; + // (undocumented) + name?: string; + // (undocumented) + notSimilarToCurrent?: boolean; + // (undocumented) + populationCount?: number; + // (undocumented) + updatedAt?: string; +} // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; @@ -1557,10 +1642,10 @@ export interface SingleValueCollectorNoValue | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; +export type SingleValueCollectors = PasswordCollector | ValidatedPasswordCollector | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; // @public -export type SingleValueCollectorTypes = 'PasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector'; +export type SingleValueCollectorTypes = 'PasswordCollector' | 'ValidatedPasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector'; // @public (undocumented) export interface SingleValueCollectorWithValue { @@ -1590,11 +1675,11 @@ export interface SingleValueCollectorWithValue { // (undocumented) 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 e3e941d9ae..4c602bd4a7 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -147,11 +147,13 @@ export interface CollectorErrors { } // @public (undocumented) -export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | UnknownCollector; +export type Collectors = FlowCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | UnknownCollector; // @public export type CollectorValueType = T extends { type: 'PasswordCollector'; +} ? string : T extends { + type: 'ValidatedPasswordCollector'; } ? string : T extends { type: 'TextCollector'; category: 'SingleValueCollector'; @@ -998,7 +1000,7 @@ export type InferMultiValueCollectorType = T export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : NoValueCollectorBase<'NoValueCollector'>; // @public -export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; +export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : T extends 'ValidatedPasswordCollector' ? ValidatedPasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; // @public (undocumented) export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; @@ -1136,8 +1138,8 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) @@ -1289,7 +1291,90 @@ export interface OutgoingQueryParams { } // @public (undocumented) -export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>; +export interface PasswordCollector { + // (undocumented) + category: 'SingleValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: string | number | boolean; + type: string; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + verify: boolean; + }; + // (undocumented) + type: 'PasswordCollector'; +} + +// @public +export type PasswordField = { + type: 'PASSWORD' | 'PASSWORD_VERIFY'; + key: string; + label: string; + required?: boolean; + verify?: boolean; + passwordPolicy?: PasswordPolicy; +}; + +// @public (undocumented) +export interface PasswordPolicy { + // (undocumented) + createdAt?: string; + // (undocumented) + default?: boolean; + // (undocumented) + description?: string; + // (undocumented) + excludesCommonlyUsed?: boolean; + // (undocumented) + excludesProfileData?: boolean; + // (undocumented) + history?: { + count?: number; + retentionDays?: number; + }; + // (undocumented) + id?: string; + // (undocumented) + length?: { + min?: number; + max?: number; + }; + // (undocumented) + lockout?: { + failureCount?: number; + durationSeconds?: number; + }; + // (undocumented) + maxAgeDays?: number; + // (undocumented) + maxRepeatedCharacters?: number; + // (undocumented) + minAgeDays?: number; + // (undocumented) + minCharacters?: Record; + // (undocumented) + minUniqueCharacters?: number; + // (undocumented) + name?: string; + // (undocumented) + notSimilarToCurrent?: boolean; + // (undocumented) + populationCount?: number; + // (undocumented) + updatedAt?: string; +} // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; @@ -1554,10 +1639,10 @@ export interface SingleValueCollectorNoValue | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; +export type SingleValueCollectors = PasswordCollector | ValidatedPasswordCollector | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; // @public -export type SingleValueCollectorTypes = 'PasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector'; +export type SingleValueCollectorTypes = 'PasswordCollector' | 'ValidatedPasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' | 'TextCollector' | 'ValidatedTextCollector'; // @public (undocumented) export interface SingleValueCollectorWithValue { @@ -1587,11 +1672,11 @@ export interface SingleValueCollectorWithValue { // (undocumented) diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index 3e2d3adcd8..f326d253f5 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -55,7 +55,7 @@ import type { Validator, Poller, } from './client.types.js'; -import { returnValidator } from './collector.utils.js'; +import { returnPasswordPolicyValidator, returnValidator } from './collector.utils.js'; import type { ContinueNode, StartNode } from './node.types.js'; /** @@ -390,6 +390,18 @@ export async function davinci({ return handleUpdateValidateError('Collector not found', 'state_error', log.error); } + if (collectorToUpdate.type === 'ValidatedPasswordCollector') { + return returnPasswordPolicyValidator(collectorToUpdate); + } + + if (collectorToUpdate.type === 'PasswordCollector') { + return handleUpdateValidateError( + 'PasswordCollector cannot be validated; pass a ValidatedPasswordCollector', + 'state_error', + log.error, + ); + } + if ( collectorToUpdate.category !== 'ValidatedSingleValueCollector' && collectorToUpdate.category !== 'ObjectValueCollector' && diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index 1830f6fd5c..c23cf6709f 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.ts @@ -36,36 +36,38 @@ export type InitFlow = () => Promise; */ export type CollectorValueType = T extends { type: 'PasswordCollector' } ? string - : T extends { type: 'TextCollector'; category: 'SingleValueCollector' } + : T extends { type: 'ValidatedPasswordCollector' } ? string - : T extends { type: 'TextCollector'; category: 'ValidatedSingleValueCollector' } + : T extends { type: 'TextCollector'; category: 'SingleValueCollector' } ? string - : T extends { type: 'SingleSelectCollector' } + : T extends { type: 'TextCollector'; category: 'ValidatedSingleValueCollector' } ? string - : T extends { type: 'MultiSelectCollector' } - ? string[] - : T extends { type: 'DeviceRegistrationCollector' } - ? string - : T extends { type: 'DeviceAuthenticationCollector' } + : T extends { type: 'SingleSelectCollector' } + ? string + : T extends { type: 'MultiSelectCollector' } + ? string[] + : T extends { type: 'DeviceRegistrationCollector' } ? string - : T extends { type: 'PhoneNumberCollector' } - ? PhoneNumberInputValue - : T extends { type: 'FidoRegistrationCollector' } - ? FidoRegistrationInputValue - : T extends { type: 'FidoAuthenticationCollector' } - ? FidoAuthenticationInputValue - : T extends { category: 'SingleValueCollector' } - ? string - : T extends { category: 'ValidatedSingleValueCollector' } + : T extends { type: 'DeviceAuthenticationCollector' } + ? string + : T extends { type: 'PhoneNumberCollector' } + ? PhoneNumberInputValue + : T extends { type: 'FidoRegistrationCollector' } + ? FidoRegistrationInputValue + : T extends { type: 'FidoAuthenticationCollector' } + ? FidoAuthenticationInputValue + : T extends { category: 'SingleValueCollector' } ? string - : T extends { category: 'MultiValueCollector' } - ? string[] - : - | string - | string[] - | PhoneNumberInputValue - | FidoRegistrationInputValue - | FidoAuthenticationInputValue; + : T extends { category: 'ValidatedSingleValueCollector' } + ? string + : T extends { category: 'MultiValueCollector' } + ? string[] + : + | string + | string[] + | PhoneNumberInputValue + | FidoRegistrationInputValue + | FidoAuthenticationInputValue; /** * Generic updater function that accepts values appropriate for the collector type. 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 deafe24f8c..4fe89db9ee 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -14,6 +14,7 @@ import type { ActionCollectorNoUrl, TextCollector, PasswordCollector, + ValidatedPasswordCollector, FlowCollector, IdpCollector, SubmitCollector, @@ -24,6 +25,7 @@ import type { InferMultiValueCollectorType, InferActionCollectorType, } from './collector.types.js'; +import type { PasswordPolicy } from './davinci.types.js'; describe('Collector Types', () => { describe('SingleValueCollector Types', () => { @@ -38,20 +40,37 @@ describe('Collector Types', () => { }); it('should validate PasswordCollector structure', () => { - expectTypeOf().toMatchTypeOf< - SingleValueCollectorNoValue<'PasswordCollector'> - >(); expectTypeOf() .toHaveProperty('category') .toEqualTypeOf<'SingleValueCollector'>(); - expectTypeOf().toHaveProperty('type'); + expectTypeOf().toHaveProperty('type').toEqualTypeOf<'PasswordCollector'>(); expectTypeOf().toEqualTypeOf<{ key: string; label: string; type: string; + verify: boolean; }>(); }); + it('should validate ValidatedPasswordCollector structure', () => { + expectTypeOf() + .toHaveProperty('category') + .toEqualTypeOf<'SingleValueCollector'>(); + expectTypeOf() + .toHaveProperty('type') + .toEqualTypeOf<'ValidatedPasswordCollector'>(); + expectTypeOf() + .toHaveProperty('verify') + .toEqualTypeOf(); + expectTypeOf() + .toHaveProperty('passwordPolicy') + .toEqualTypeOf(); + }); + + it('should validate PasswordCollector output does NOT have passwordPolicy', () => { + expectTypeOf().not.toHaveProperty('passwordPolicy'); + }); + it('should validate SingleCollector structure', () => { expectTypeOf().toMatchTypeOf< SingleValueCollectorWithValue<'SingleSelectCollector'> @@ -262,11 +281,35 @@ describe('Collector Types', () => { key: '', label: '', type: '', + verify: false, }, }; expectTypeOf(tCollector).toMatchTypeOf(); }); + it('should correctly infer ValidatedPasswordCollector Type', () => { + const tCollector: InferSingleValueCollectorType<'ValidatedPasswordCollector'> = { + category: 'SingleValueCollector', + error: null, + type: 'ValidatedPasswordCollector', + id: '', + name: '', + input: { + key: '', + value: '', + type: '', + }, + output: { + key: '', + label: '', + type: '', + verify: false, + passwordPolicy: {}, + }, + }; + + expectTypeOf(tCollector).toMatchTypeOf(); + }); it('should correctly infer SingleValueCollector Type', () => { const tCollector: InferSingleValueCollectorType<'SingleValueCollector'> = { category: 'SingleValueCollector', diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index e6c882a5c4..d823227a42 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -5,7 +5,11 @@ * of the MIT license. See the LICENSE file for details. */ -import type { FidoAuthenticationOptions, FidoRegistrationOptions } from './davinci.types.js'; +import type { + FidoAuthenticationOptions, + FidoRegistrationOptions, + PasswordPolicy, +} from './davinci.types.js'; /** ********************************************************************* * SINGLE-VALUE COLLECTORS @@ -16,6 +20,7 @@ import type { FidoAuthenticationOptions, FidoRegistrationOptions } from './davin */ export type SingleValueCollectorTypes = | 'PasswordCollector' + | 'ValidatedPasswordCollector' | 'SingleValueCollector' | 'SingleSelectCollector' | 'SingleSelectObjectCollector' @@ -157,14 +162,16 @@ export type InferSingleValueCollectorType = ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector - : /** - * At this point, we have not passed in a collector type - * or we have explicitly passed in 'SingleValueCollector' - * So we can return either a SingleValueCollector with value - * or without a value. - **/ - | SingleValueCollectorWithValue<'SingleValueCollector'> - | SingleValueCollectorNoValue<'SingleValueCollector'>; + : T extends 'ValidatedPasswordCollector' + ? ValidatedPasswordCollector + : /** + * At this point, we have not passed in a collector type + * or we have explicitly passed in 'SingleValueCollector' + * So we can return either a SingleValueCollector with value + * or without a value. + **/ + | SingleValueCollectorWithValue<'SingleValueCollector'> + | SingleValueCollectorNoValue<'SingleValueCollector'>; /** * SINGLE-VALUE COLLECTOR TYPES @@ -174,13 +181,51 @@ export type SingleValueCollector = | SingleValueCollectorNoValue; export type SingleValueCollectors = - | SingleValueCollectorNoValue<'PasswordCollector'> + | PasswordCollector + | ValidatedPasswordCollector | SingleSelectCollectorWithValue<'SingleSelectCollector'> | SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorWithValue<'TextCollector'> | ValidatedSingleValueCollectorWithValue<'TextCollector'>; -export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>; +export interface PasswordCollector { + category: 'SingleValueCollector'; + error: string | null; + type: 'PasswordCollector'; + id: string; + name: string; + input: { + key: string; + value: string | number | boolean; + type: string; + }; + output: { + key: string; + label: string; + type: string; + verify: boolean; + }; +} + +export interface ValidatedPasswordCollector { + category: 'SingleValueCollector'; + error: string | null; + type: 'ValidatedPasswordCollector'; + id: string; + name: string; + input: { + key: string; + value: string | number | boolean; + type: string; + }; + output: { + key: string; + label: string; + type: string; + verify: boolean; + passwordPolicy: PasswordPolicy; + }; +} export type TextCollector = SingleValueCollectorWithValue<'TextCollector'>; export type SingleSelectCollector = SingleSelectCollectorWithValue<'SingleSelectCollector'>; export type ValidatedTextCollector = ValidatedSingleValueCollectorWithValue<'TextCollector'>; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index ebc33f3c6b..cd8f52fad1 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -12,6 +12,8 @@ import { returnSubmitCollector, returnSingleValueCollector, returnPasswordCollector, + returnPasswordPolicyValidator, + returnValidatedPasswordCollector, returnTextCollector, returnSingleSelectCollector, returnMultiSelectCollector, @@ -28,6 +30,7 @@ import type { DaVinciField, DeviceAuthenticationField, DeviceRegistrationField, + PasswordField, FidoAuthenticationField, FidoRegistrationField, PhoneNumberField, @@ -316,6 +319,7 @@ describe('Single Value Collectors', () => { key: mockField.key, label: mockField.label, type: mockField.type, + verify: false, }, }); expect(result.output).not.toHaveProperty('value'); @@ -337,9 +341,26 @@ describe('Single Value Collectors', () => { describe('Specialized Single Value Collectors', () => { it('creates a password collector', () => { - const result = returnPasswordCollector(mockField, 1); + const passwordField: PasswordField = { + type: 'PASSWORD', + key: 'password', + label: 'Password', + }; + const result = returnPasswordCollector(passwordField, 1); expect(result.type).toBe('PasswordCollector'); expect(result.output).not.toHaveProperty('value'); + expect(result.output.verify).toBe(false); + }); + + it('propagates verify: true from a PASSWORD field onto the PasswordCollector', () => { + const passwordField: PasswordField = { + type: 'PASSWORD', + key: 'password', + label: 'Password', + verify: true, + }; + const result = returnPasswordCollector(passwordField, 1); + expect(result.output.verify).toBe(true); }); it('creates a text collector', () => { @@ -1136,3 +1157,221 @@ describe('Return collector validator', () => { ); }); }); + +describe('returnValidatedPasswordCollector', () => { + const mockPasswordPolicy = { + id: '39cad7af-3c2f-4672-9c3f-c47e5169e582', + name: 'Standard', + length: { min: 8, max: 255 }, + minCharacters: { + '~!@#$%^&*()-_=+[]{}|;:,.<>/?': 1, + '0123456789': 1, + ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, + abcdefghijklmnopqrstuvwxyz: 1, + }, + }; + + it('should create a ValidatedPasswordCollector with embedded passwordPolicy', () => { + const field: PasswordField = { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + required: true, + passwordPolicy: mockPasswordPolicy, + }; + + const result = returnValidatedPasswordCollector(field, 0); + + expect(result).toEqual({ + category: 'SingleValueCollector', + error: null, + type: 'ValidatedPasswordCollector', + id: 'user.password-0', + name: 'user.password', + input: { + key: 'user.password', + value: '', + type: 'PASSWORD_VERIFY', + }, + output: { + key: 'user.password', + label: 'Password', + type: 'PASSWORD_VERIFY', + verify: false, + passwordPolicy: mockPasswordPolicy, + }, + }); + }); + + it('should propagate verify: true from the field onto the collector', () => { + const field: PasswordField = { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + verify: true, + passwordPolicy: mockPasswordPolicy, + }; + + const result = returnValidatedPasswordCollector(field, 0); + + expect(result.output.verify).toBe(true); + }); + + it('should fall back to an empty policy when called directly with a field that has no policy', () => { + // In normal flows the reducer selects returnPasswordCollector when a field has no policy. + // This test exercises the factory's defensive fallback for callers who bypass the reducer. + const field: PasswordField = { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + }; + + const result = returnValidatedPasswordCollector(field, 1); + + expect(result.output.passwordPolicy).toEqual({}); + expect(result.output.verify).toBe(false); + }); + + it('should record errors when field is missing properties', () => { + const invalidField = {} as PasswordField; + const result = returnValidatedPasswordCollector(invalidField, 0); + expect(result.error).toContain('Key is not found'); + expect(result.error).toContain('Label is not found'); + expect(result.error).toContain('Type is not found'); + }); +}); + +describe('returnPasswordPolicyValidator', () => { + const makeCollector = (passwordPolicy?: Record) => { + const field: PasswordField = { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + ...(passwordPolicy && { passwordPolicy }), + } as PasswordField; + return returnValidatedPasswordCollector(field, 0); + }; + + it('should return an empty array when the collector has no passwordPolicy', () => { + const validate = returnPasswordPolicyValidator(makeCollector()); + expect(validate('anything')).toEqual([]); + }); + + it('should return an empty array when the value satisfies all policy rules', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ + length: { min: 8, max: 20 }, + minUniqueCharacters: 5, + maxRepeatedCharacters: 2, + minCharacters: { '0123456789': 1, '!@#$%^&*()': 1 }, + }), + ); + expect(validate('Valid1@Password')).toEqual([]); + }); + + describe('length rule', () => { + it('should fail with a range message when value is shorter than length.min and max is set', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ length: { min: 8, max: 20 } }), + ); + const errors = validate('short'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('8'); + expect(errors[0]).toContain('20'); + }); + + it('should fail when value is longer than length.max', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ length: { min: 1, max: 4 } })); + expect(validate('toolong')).toHaveLength(1); + }); + + it('should check only the lower bound when length.max is undefined', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ length: { min: 8 } })); + expect(validate('short')).toHaveLength(1); + expect(validate('longenough')).toEqual([]); + }); + + it('should check only the upper bound when length.min is undefined', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ length: { max: 4 } })); + expect(validate('toolong')).toHaveLength(1); + expect(validate('ok')).toEqual([]); + }); + + it('should skip the length check entirely when both min and max are undefined', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ length: {} })); + expect(validate('')).toEqual([]); + expect(validate('anything-at-all')).toEqual([]); + }); + }); + + describe('minUniqueCharacters rule', () => { + it('should fail when the count of distinct characters is below the minimum', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ minUniqueCharacters: 5 })); + const errors = validate('aaa111@@@'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('5'); + }); + + it('should pass when the count of distinct characters meets the minimum', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ minUniqueCharacters: 3 })); + expect(validate('abc')).toEqual([]); + }); + }); + + describe('maxRepeatedCharacters rule', () => { + it('should fail based on total occurrences of any character, not only consecutive runs', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ maxRepeatedCharacters: 2 })); + const errors = validate('aXaXaX'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('2'); + }); + + it('should pass when no character appears more than the maximum', () => { + const validate = returnPasswordPolicyValidator(makeCollector({ maxRepeatedCharacters: 2 })); + expect(validate('abcabc')).toEqual([]); + }); + }); + + describe('minCharacters rule', () => { + it('should fail when the value contains fewer characters from the required charset than required', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ minCharacters: { '0123456789': 2 } }), + ); + const errors = validate('Password@1'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('2'); + expect(errors[0]).toContain('0123456789'); + }); + + it('should pass when enough characters from the required charset are present', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ minCharacters: { '!@#$%^&*()': 2 } }), + ); + expect(validate('hello@world!')).toEqual([]); + }); + + it('should emit one error per failing charset when multiple are required', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ + minCharacters: { + '0123456789': 1, + ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, + }, + }), + ); + const errors = validate('lowercaseonly'); + expect(errors).toHaveLength(2); + }); + }); + + it('should accumulate errors from multiple failing rules', () => { + const validate = returnPasswordPolicyValidator( + makeCollector({ + length: { min: 12, max: 20 }, + minUniqueCharacters: 10, + minCharacters: { '0123456789': 1 }, + }), + ); + expect(validate('aaa').length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index bbbdc2fdde..19857b64c6 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ +import { Array as Arr, Option, pipe } from 'effect'; + /** * Import the required types */ @@ -30,6 +32,8 @@ import type { SingleValueAutoCollectorTypes, ObjectValueAutoCollectorTypes, QrCodeCollectorBase, + PasswordCollector, + ValidatedPasswordCollector, } from './collector.types.js'; import type { DeviceAuthenticationField, @@ -37,6 +41,8 @@ import type { FidoAuthenticationField, FidoRegistrationField, MultiSelectField, + PasswordField, + PasswordPolicy, PhoneNumberField, ProtectField, QrCodeField, @@ -146,7 +152,7 @@ export function returnSubmitCollector(field: StandardField, idx: number) { * @returns {SingleValueCollector} The constructed SingleValueCollector object. */ export function returnSingleValueCollector< - Field extends StandardField | SingleSelectField | ValidatedField, + Field extends StandardField | SingleSelectField | ValidatedField | PasswordField, CollectorType extends SingleValueCollectorTypes = 'SingleValueCollector', >(field: Field, idx: number, collectorType: CollectorType, data?: string) { let error = ''; @@ -161,6 +167,32 @@ export function returnSingleValueCollector< } if (collectorType === 'PasswordCollector') { + const verify = 'verify' in field ? field.verify === true : false; + return { + category: 'SingleValueCollector', + error: error || null, + type: collectorType, + id: `${field?.key}-${idx}`, + name: field.key, + input: { + key: field.key, + value: '', + type: field.type, + }, + output: { + key: field.key, + label: field.label, + type: field.type, + verify, + }, + } as InferSingleValueCollectorType; + } else if (collectorType === 'ValidatedPasswordCollector') { + // passwordPolicy is the discriminator — callers must only request this variant when + // the field carries one. Fallback to an empty object keeps the factory total in the + // face of misuse; the policy validator will treat an empty policy as no rules. + const passwordPolicy = + 'passwordPolicy' in field && field.passwordPolicy ? field.passwordPolicy : {}; + const verify = 'verify' in field ? field.verify === true : false; return { category: 'SingleValueCollector', error: error || null, @@ -176,7 +208,8 @@ export function returnSingleValueCollector< key: field.key, label: field.label, type: field.type, - // No default or existing value is passed + verify, + passwordPolicy, }, } as InferSingleValueCollectorType; } else if (collectorType === 'SingleSelectCollector') { @@ -430,13 +463,34 @@ export function returnObjectValueAutoCollector< } /** - * @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. + * @function returnPasswordCollector - Creates a PasswordCollector (no password policy). + * @param {PasswordField} field - The PASSWORD / PASSWORD_VERIFY field; a `verify` flag is + * propagated to the collector output if set. * @param {number} idx - The index to be used in the id of the PasswordCollector. * @returns {PasswordCollector} The constructed PasswordCollector object. */ -export function returnPasswordCollector(field: StandardField, idx: number) { - return returnSingleValueCollector(field, idx, 'PasswordCollector'); +export function returnPasswordCollector(field: PasswordField, idx: number): PasswordCollector { + return returnSingleValueCollector(field, idx, 'PasswordCollector') as PasswordCollector; +} + +/** + * @function returnValidatedPasswordCollector - Creates a ValidatedPasswordCollector. The + * presence of a `passwordPolicy` on the incoming field is the sole discriminator between + * this variant and {@link returnPasswordCollector}; callers are responsible for selecting + * the right factory based on `field.passwordPolicy`. + * @param {PasswordField} field - The PASSWORD / PASSWORD_VERIFY field carrying a passwordPolicy. + * @param {number} idx - The index of the field in the form. + * @returns {ValidatedPasswordCollector} The constructed ValidatedPasswordCollector. + */ +export function returnValidatedPasswordCollector( + field: PasswordField, + idx: number, +): ValidatedPasswordCollector { + return returnSingleValueCollector( + field, + idx, + 'ValidatedPasswordCollector', + ) as ValidatedPasswordCollector; } /** @@ -808,6 +862,96 @@ export function returnValidator( }; } +/** + * A single policy check: given the policy and a candidate value, produce zero or more + * human-readable error strings. Rules are pure and independent — new ones can be added + * by extending `passwordPolicyRules` below. + */ +type PasswordPolicyRule = (policy: PasswordPolicy, value: string) => readonly string[]; + +const countChars = (value: string): ReadonlyMap => { + const counts = new Map(); + for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1); + return counts; +}; + +const formatLengthMessage = (min?: number, max?: number): string => { + if (min != null && max != null) return `Password must be between ${min} and ${max} characters`; + if (min != null) return `Password must be at least ${min} characters`; + return `Password must be at most ${max} characters`; +}; + +const lengthRule: PasswordPolicyRule = (policy, value) => { + const length = policy.length; + if (!length) return []; + const { min, max } = length; + if (min == null && max == null) return []; + const outOfRange = (min != null && value.length < min) || (max != null && value.length > max); + return outOfRange ? [formatLengthMessage(min, max)] : []; +}; + +const minUniqueCharactersRule: PasswordPolicyRule = (policy, value) => { + const min = policy.minUniqueCharacters; + if (min == null) return []; + return new Set(value).size < min + ? [`Password must contain at least ${min} unique characters`] + : []; +}; + +const maxRepeatedCharactersRule: PasswordPolicyRule = (policy, value) => { + const max = policy.maxRepeatedCharacters; + if (max == null) return []; + const maxCount = pipe( + countChars(value), + (counts) => Array.from(counts.values()), + Arr.reduce(0, (acc, n) => (n > acc ? n : acc)), + ); + return maxCount > max ? [`Password cannot repeat any character more than ${max} times`] : []; +}; + +const minCharactersRule: PasswordPolicyRule = (policy, value) => { + if (!policy.minCharacters) return []; + return pipe( + Object.entries(policy.minCharacters), + Arr.filterMap(([charset, min]) => { + const members = new Set(charset); + let hits = 0; + for (const ch of value) if (members.has(ch)) hits += 1; + return hits < min + ? Option.some(`Password must contain at least ${min} character(s) from "${charset}"`) + : Option.none(); + }), + ); +}; + +const passwordPolicyRules: readonly PasswordPolicyRule[] = [ + lengthRule, + minUniqueCharactersRule, + maxRepeatedCharactersRule, + minCharactersRule, +]; + +/** + * @function returnPasswordPolicyValidator - Creates a validator function that checks a candidate + * value against the `passwordPolicy` embedded on a `ValidatedPasswordCollector`. Rules mirror the + * native SDKs: length bounds, minimum unique characters, maximum repeated character occurrences, + * and per-charset minimums. Returns `[]` when no policy is present on the collector. + * @param {ValidatedPasswordCollector} collector - The collector whose output may carry a passwordPolicy. + * @returns {(value: string) => string[]} - A validator that returns human-readable error strings. + */ +export function returnPasswordPolicyValidator( + collector: ValidatedPasswordCollector, +): (value: string) => string[] { + const policy = collector.output.passwordPolicy; + return (value: string) => + policy + ? pipe( + passwordPolicyRules, + Arr.flatMap((rule) => rule(policy, value)), + ) + : []; +} + export function returnUnknownCollector(field: Record, idx: number) { return { category: 'UnknownCollector', diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 2f97c3c63b..68255dfa71 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -53,14 +53,7 @@ export interface Links { } export type StandardField = { - type: - | 'PASSWORD' - | 'PASSWORD_VERIFY' - | 'TEXT' - | 'SUBMIT_BUTTON' - | 'FLOW_BUTTON' - | 'FLOW_LINK' - | 'BUTTON'; + type: 'TEXT' | 'SUBMIT_BUTTON' | 'FLOW_BUTTON' | 'FLOW_LINK' | 'BUTTON'; key: string; label: string; @@ -68,6 +61,42 @@ export type StandardField = { required?: boolean; }; +export interface PasswordPolicy { + id?: string; + name?: string; + description?: string; + excludesProfileData?: boolean; + notSimilarToCurrent?: boolean; + excludesCommonlyUsed?: boolean; + maxAgeDays?: number; + minAgeDays?: number; + maxRepeatedCharacters?: number; + minUniqueCharacters?: number; + history?: { count?: number; retentionDays?: number }; + lockout?: { failureCount?: number; durationSeconds?: number }; + length?: { min?: number; max?: number }; + minCharacters?: Record; + populationCount?: number; + createdAt?: string; + updatedAt?: string; + default?: boolean; +} + +/** + * Raw server field shape for password inputs. The server tags the `type` as + * `PASSWORD_VERIFY` whenever `verify` is set, but our collector taxonomy ignores + * that — we pick `PasswordCollector` vs `ValidatedPasswordCollector` based on + * whether `passwordPolicy` is present. + */ +export type PasswordField = { + type: 'PASSWORD' | 'PASSWORD_VERIFY'; + key: string; + label: string; + required?: boolean; + verify?: boolean; + passwordPolicy?: PasswordPolicy; +}; + export type ReadOnlyField = { type: 'LABEL'; content: string; @@ -240,7 +269,12 @@ export type ComplexValueFields = export type MultiValueFields = MultiSelectField; export type ReadOnlyFields = ReadOnlyField | QrCodeField; export type RedirectFields = RedirectField; -export type SingleValueFields = StandardField | ValidatedField | SingleSelectField | ProtectField; +export type SingleValueFields = + | StandardField + | PasswordField + | ValidatedField + | SingleSelectField + | ProtectField; export type DaVinciField = | ComplexValueFields diff --git a/packages/davinci-client/src/lib/davinci.utils.test.ts b/packages/davinci-client/src/lib/davinci.utils.test.ts index 81a12bcf40..094d4d945c 100644 --- a/packages/davinci-client/src/lib/davinci.utils.test.ts +++ b/packages/davinci-client/src/lib/davinci.utils.test.ts @@ -38,7 +38,7 @@ describe('transformSubmitRequest', () => { category: 'SingleValueCollector', error: null, input: { key: 'password', value: 'secret', type: 'PASSWORD' }, - output: { key: 'password', label: 'Password', type: 'PASSWORD' }, + output: { key: 'password', label: 'Password', type: 'PASSWORD', verify: false }, type: 'PasswordCollector', id: 'xyz', name: 'password', diff --git a/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts index 4962470b45..27f046ebeb 100644 --- a/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts +++ b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts @@ -42,6 +42,49 @@ export const obj = { label: 'Password', required: true, }, + { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + required: true, + passwordPolicy: { + id: '39cad7af-3c2f-4672-9c3f-c47e5169e582', + environment: { + id: '02fb4743-189a-4bc7-9d6c-a919edfe6447', + }, + name: 'Standard', + description: 'A standard policy that incorporates industry best practices', + excludesProfileData: true, + notSimilarToCurrent: true, + excludesCommonlyUsed: true, + maxAgeDays: 182, + minAgeDays: 1, + maxRepeatedCharacters: 2, + minUniqueCharacters: 5, + history: { + count: 6, + retentionDays: 365, + }, + lockout: { + failureCount: 5, + durationSeconds: 900, + }, + length: { + min: 8, + max: 255, + }, + minCharacters: { + '~!@#$%^&*()-_=+[]{}|;:,.<>/?': 1, + '0123456789': 1, + ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, + abcdefghijklmnopqrstuvwxyz: 1, + }, + populationCount: 1, + createdAt: '2024-01-03T19:50:39.586Z', + updatedAt: '2024-01-03T19:50:39.586Z', + default: true, + }, + }, { type: 'SUBMIT_BUTTON', label: 'Sign On', @@ -140,6 +183,7 @@ export const obj = { value: { 'user.username': '', password: '', + 'user.password': '', 'dropdown-field': '', 'combobox-field': '', 'radio-field': '', @@ -163,14 +207,6 @@ export const obj = { themeId: 'activeTheme', formId: 'f0cf83ab-f8f4-4f4a-9260-8f7d27061fa7', passwordPolicy: { - _links: { - environment: { - href: 'http://10.76.247.190:4140/directory-api/environments/02fb4743-189a-4bc7-9d6c-a919edfe6447', - }, - self: { - href: 'http://10.76.247.190:4140/directory-api/environments/02fb4743-189a-4bc7-9d6c-a919edfe6447/passwordPolicies/39cad7af-3c2f-4672-9c3f-c47e5169e582', - }, - }, id: '39cad7af-3c2f-4672-9c3f-c47e5169e582', environment: { id: '02fb4743-189a-4bc7-9d6c-a919edfe6447', @@ -213,6 +249,7 @@ export const obj = { 'ERROR_DISPLAY', 'TEXT', 'PASSWORD', + 'PASSWORD_VERIFY', 'RADIO', 'CHECKBOX', 'FLOW_LINK', diff --git a/packages/davinci-client/src/lib/mock-data/node.next.mock.ts b/packages/davinci-client/src/lib/mock-data/node.next.mock.ts index e32441326a..ee017c75b7 100644 --- a/packages/davinci-client/src/lib/mock-data/node.next.mock.ts +++ b/packages/davinci-client/src/lib/mock-data/node.next.mock.ts @@ -26,7 +26,7 @@ export const nodeNext0 = { id: 'password-1', name: 'password', input: { key: 'password', value: '', type: 'PASSWORD' }, - output: { key: 'password', label: 'Password', type: 'PASSWORD' }, + output: { key: 'password', label: 'Password', type: 'PASSWORD', verify: false }, }, { category: 'ActionCollector', diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index b30d7d12b3..1380481d66 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -13,6 +13,8 @@ import type { FidoAuthenticationCollector, FidoRegistrationCollector, MultiSelectCollector, + PasswordCollector, + ValidatedPasswordCollector, PhoneNumberCollector, PollingCollector, ProtectCollector, @@ -123,6 +125,7 @@ describe('The node collector reducer', () => { key: 'password', label: 'Password', type: 'PASSWORD', + verify: false, }, }, { @@ -201,6 +204,7 @@ describe('The node collector reducer', () => { key: 'password', label: 'Password', type: 'PASSWORD', + verify: false, }, }, { @@ -275,6 +279,7 @@ describe('The node collector reducer', () => { key: 'password', label: 'Password', type: 'PASSWORD', + verify: false, }, }, { @@ -1190,6 +1195,180 @@ describe('The node collector reducer with pollCollectorValues', () => { }); }); +describe('PASSWORD_VERIFY with password policy', () => { + const mockPasswordPolicy = { + id: '39cad7af-3c2f-4672-9c3f-c47e5169e582', + name: 'Standard', + description: 'A standard policy that incorporates industry best practices', + length: { min: 8, max: 255 }, + minCharacters: { + '~!@#$%^&*()-_=+[]{}|;:,.<>/?': 1, + '0123456789': 1, + ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, + abcdefghijklmnopqrstuvwxyz: 1, + }, + }; + + it('should produce ValidatedPasswordCollector with embedded passwordPolicy', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + required: true, + passwordPolicy: mockPasswordPolicy, + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result).toEqual([ + { + category: 'SingleValueCollector', + error: null, + type: 'ValidatedPasswordCollector', + id: 'user.password-0', + name: 'user.password', + input: { + key: 'user.password', + value: '', + type: 'PASSWORD_VERIFY', + }, + output: { + key: 'user.password', + label: 'Password', + type: 'PASSWORD_VERIFY', + verify: false, + passwordPolicy: mockPasswordPolicy, + }, + } satisfies ValidatedPasswordCollector, + ]); + }); + + it('should fall back to PasswordCollector when a PASSWORD_VERIFY field has no policy', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('PasswordCollector'); + expect(result[0].output).not.toHaveProperty('passwordPolicy'); + }); + + it('should produce ValidatedPasswordCollector when a PASSWORD field carries a policy', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD', + key: 'user.password', + label: 'Password', + passwordPolicy: mockPasswordPolicy, + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('ValidatedPasswordCollector'); + expect((result[0] as ValidatedPasswordCollector).output.passwordPolicy).toEqual( + mockPasswordPolicy, + ); + }); + + it('should propagate verify: true from the field onto PasswordCollector', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD', + key: 'password', + label: 'Password', + verify: true, + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('PasswordCollector'); + expect((result[0] as PasswordCollector).output.verify).toBe(true); + }); + + it('should propagate verify: true from the field onto ValidatedPasswordCollector', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD_VERIFY', + key: 'user.password', + label: 'Password', + verify: true, + passwordPolicy: mockPasswordPolicy, + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('ValidatedPasswordCollector'); + expect((result[0] as ValidatedPasswordCollector).output.verify).toBe(true); + }); + + it('should default verify to false when the field omits it', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD', + key: 'password', + label: 'Password', + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect((result[0] as PasswordCollector).output.verify).toBe(false); + }); + + it('should still produce PasswordCollector for PASSWORD type (no regression)', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'PASSWORD', + key: 'password', + label: 'Password', + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result[0].type).toBe('PasswordCollector'); + expect(result[0].output).not.toHaveProperty('passwordPolicy'); + }); +}); + describe('The node collector reducer with FidoAuthenticationFieldValue', () => { it('should handle collector updates ', () => { // todo: declare inputValue type as FidoAuthenticationInputValue diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index e97a0af42e..23a5618fde 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -16,6 +16,7 @@ import { returnActionCollector, returnFlowCollector, returnPasswordCollector, + returnValidatedPasswordCollector, returnIdpCollector, returnSubmitCollector, returnTextCollector, @@ -38,6 +39,7 @@ import type { SingleSelectCollector, FlowCollector, PasswordCollector, + ValidatedPasswordCollector, SingleValueCollector, IdpCollector, SubmitCollector, @@ -87,6 +89,7 @@ export const pollCollectorValues = createAction('node/poll'); const initialCollectorValues: ( | FlowCollector | PasswordCollector + | ValidatedPasswordCollector | TextCollector | IdpCollector | SubmitCollector @@ -168,8 +171,13 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build } case 'PASSWORD': case 'PASSWORD_VERIFY': { - // No data to send - return returnPasswordCollector(field, idx); + // Policy presence is the sole discriminator between the two collector types. + // The server may tag the field as PASSWORD_VERIFY when the `verify` flag is + // set, but the collector taxonomy only cares about whether a policy came + // along — otherwise it's a PasswordCollector regardless of the tag. + return field.passwordPolicy + ? returnValidatedPasswordCollector(field, idx) + : returnPasswordCollector(field, idx); } case 'PHONE_NUMBER': { const prefillData = data as PhoneNumberOutputValue; 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 d2733abc0e..07b2ce7f58 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -21,6 +21,7 @@ import { FlowCollector, MultiSelectCollector, PasswordCollector, + ValidatedPasswordCollector, ReadOnlyCollector, SingleSelectCollector, SingleValueCollector, @@ -223,6 +224,7 @@ describe('Node Types', () => { expectTypeOf().toMatchTypeOf< | TextCollector | PasswordCollector + | ValidatedPasswordCollector | FlowCollector | IdpCollector | SubmitCollector @@ -261,7 +263,7 @@ describe('Node Types', () => { id: 'test', name: 'Test', input: { key: 'test', value: '', type: 'string' }, - output: { key: 'test', label: 'Test', type: 'string' }, + output: { key: 'test', label: 'Test', type: 'string', verify: false }, }, ]; diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index ecc01c5616..6d810d95bf 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -9,6 +9,7 @@ import { GenericError } from '@forgerock/sdk-types'; import type { FlowCollector, PasswordCollector, + ValidatedPasswordCollector, TextCollector, IdpCollector, SubmitCollector, @@ -33,6 +34,7 @@ import type { Links } from './davinci.types.js'; export type Collectors = | FlowCollector | PasswordCollector + | ValidatedPasswordCollector | TextCollector | SingleSelectCollector | IdpCollector diff --git a/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts b/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts index 7ba8b20d75..ca7d78b180 100644 --- a/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts +++ b/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts @@ -10,6 +10,7 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { Updater } from './client.types.js'; import type { PasswordCollector, + ValidatedPasswordCollector, TextCollector, ValidatedTextCollector, SingleSelectCollector, @@ -29,6 +30,7 @@ import type { Collectors } from './node.types.js'; type MockUpdate = < T extends | PasswordCollector + | ValidatedPasswordCollector | TextCollector | ValidatedTextCollector | SingleSelectCollector @@ -64,6 +66,22 @@ describe('Updater Type Narrowing with Real Usage Pattern', () => { } }); + it('ValidatedPasswordCollector should narrow collector to ValidatedPasswordCollector type', () => { + const collector = {} as Collectors; + + if (collector.type === 'ValidatedPasswordCollector') { + // 1. Collector itself should be narrowed to ValidatedPasswordCollector + expectTypeOf(collector).toEqualTypeOf(); + + // 2. update() should return Updater + const updater = mockUpdate(collector); + expectTypeOf(updater).toEqualTypeOf>(); + + // 3. The updater parameter should accept string + expectTypeOf(updater).parameter(0).toEqualTypeOf(); + } + }); + it('TextCollector should narrow collector to TextCollector | ValidatedTextCollector', () => { const collector = {} as Collectors;