diff --git a/.changeset/breezy-actors-sell.md b/.changeset/breezy-actors-sell.md new file mode 100644 index 0000000000..a90b0953bd --- /dev/null +++ b/.changeset/breezy-actors-sell.md @@ -0,0 +1,8 @@ +--- +'@forgerock/storage': minor +--- + +feat: Update storage package + +- Updated ESLint configurations for consistent code style and linting rules. +- Ensured compatibility with `verbatimModuleSyntax` by correcting type-only imports and module exports. diff --git a/.changeset/bright-lights-yawn.md b/.changeset/bright-lights-yawn.md new file mode 100644 index 0000000000..eb1948b05d --- /dev/null +++ b/.changeset/bright-lights-yawn.md @@ -0,0 +1,7 @@ +--- +'@forgerock/sdk-utilities': minor +--- + +feat: Update SDK utilities + +- Inlined `REQUESTED_WITH` and `X_REQUESTED_PLATFORM` constants with literal types for better tree-shaking and type narrowing. diff --git a/.changeset/good-games-accept.md b/.changeset/good-games-accept.md new file mode 100644 index 0000000000..c4d31c9a65 --- /dev/null +++ b/.changeset/good-games-accept.md @@ -0,0 +1,8 @@ +--- +'@forgerock/iframe-manager': minor +--- + +feat: Update iframe-manager + +- Updated ESLint configurations for consistent code style and linting rules. +- Ensured compatibility with `verbatimModuleSyntax` by correcting type-only imports and module exports. diff --git a/.changeset/kind-guests-sneeze.md b/.changeset/kind-guests-sneeze.md new file mode 100644 index 0000000000..127bbb8bbc --- /dev/null +++ b/.changeset/kind-guests-sneeze.md @@ -0,0 +1,11 @@ +--- +'@forgerock/journey-client': minor +--- + +feat: Implement new journey client + +- Implemented a new `journey()` factory function for creating stateful client instances. +- Integrated Redux Toolkit and RTK Query for robust state management and API interactions. +- Refactored `resume` logic to correctly persist and retrieve plain `Step` payloads, resolving prototype loss issues during serialization. +- Improved error handling and type safety within the client. +- Updated internal callback handling and device profiling integration. diff --git a/.changeset/orange-peaches-warn.md b/.changeset/orange-peaches-warn.md new file mode 100644 index 0000000000..cd1cd2aa25 --- /dev/null +++ b/.changeset/orange-peaches-warn.md @@ -0,0 +1,11 @@ +--- +'@forgerock/journey-client': minor +--- + +feat(journey-client): Add WebAuthn, QR Code, and Recovery Code support + +- Introduces new utility modules (`FRWebAuthn`, `FRQRCode`, `FRRecoveryCodes`) to handle advanced authentication methods within authentication journeys. +- Adds comprehensive parsing and handling for WebAuthn registration and authentication steps, including a fix for a type error where `TextOutputCallback` was being incorrectly inferred as `TextInputCallback`. +- Implements support for displaying QR codes (for both OTP and Push) and for displaying and using recovery codes. +- Includes extensive unit tests for the new callback types and utility modules to ensure correctness. +- Updates documentation to reflect the new capabilities and architectural changes. diff --git a/.changeset/tender-schools-scream.md b/.changeset/tender-schools-scream.md new file mode 100644 index 0000000000..6857437f0c --- /dev/null +++ b/.changeset/tender-schools-scream.md @@ -0,0 +1,8 @@ +--- +'@forgerock/sdk-types': minor +--- + +feat: Update SDK types + +- Updated ESLint configurations for consistent code style and linting rules. +- Ensured compatibility with `verbatimModuleSyntax` by correcting type-only imports and module exports. diff --git a/.changeset/wild-items-stop.md b/.changeset/wild-items-stop.md new file mode 100644 index 0000000000..43e8e413a3 --- /dev/null +++ b/.changeset/wild-items-stop.md @@ -0,0 +1,8 @@ +--- +'@forgerock/sdk-logger': minor +--- + +feat: Update SDK logger + +- Updated ESLint configurations for consistent code style and linting rules. +- Ensured compatibility with `verbatimModuleSyntax` by correcting type-only imports and module exports. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 859ccee5b2..297dc84b64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - uses: codecov/codecov-action@v5 with: - files: ./packages/**/coverage/*.xml + directory: ./packages/ token: ${{ secrets.CODECOV_TOKEN }} - uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 09f2485ffe..982be8b22b 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,7 @@ packages/davinci-client/src/lib/mock-data/*.d.ts.map test-output .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md + +# Gemini local knowledge base files +GEMINI.md +**/GEMINI.md \ No newline at end of file diff --git a/.whitesource b/.whitesource index 95a879f280..78465e3590 100644 --- a/.whitesource +++ b/.whitesource @@ -15,7 +15,7 @@ "enableScan": true, "scanPullRequests": true, "incrementalScan": false, - "baseBranches": ["integration"] + "baseBranches": ["main"] }, "checkRunSettingsSAST": { "checkRunConclusionLevel": "failure", diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ac4236760e..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,91 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is the Ping JavaScript SDK - a monorepo containing multiple packages for web applications integrating with the Ping platform. The SDK provides APIs for user authentication, device management, and accessing Ping-secured resources. - -## Development Commands - -### Core Commands - -- `pnpm build` - Build all affected packages -- `pnpm test` - Run tests for all affected packages -- `pnpm lint` - Lint all affected packages -- `pnpm format` - Format code using Prettier -- `pnpm nx typecheck` - Run TypeScript type checking - -### Package Management - -- `pnpm create-package` - Generate a new library package using Nx -- `pnpm nx serve ` - Serve a specific package in development -- `pnpm nx test --watch` - Run tests for a specific package in watch mode - -### E2E Testing - -- `pnpm test:e2e` - Run end-to-end tests for affected packages -- Individual e2e apps are in `e2e/` directory with their own test suites - -## Architecture - -### Monorepo Structure - -The repository uses **Nx** as the monorepo tool with the following structure: - -``` -packages/ -├── davinci-client/ # DaVinci flow orchestration client -├── device-client/ # Device management (binding, profiles, WebAuthn) -├── oidc-client/ # OpenID Connect authentication client -├── protect/ # Ping Protect fraud detection -├── sdk-effects/ # Effect-based utilities (logger, storage, etc.) -│ ├── iframe-manager/ -│ ├── logger/ -│ ├── oidc/ -│ ├── sdk-request-middleware/ -│ └── storage/ -├── sdk-types/ # Shared TypeScript types -└── sdk-utilities/ # Common utilities (PKCE, URL handling) -``` - -### Key Packages - -- **davinci-client**: State management for DaVinci authentication flows using Redux Toolkit -- **oidc-client**: OIDC authentication with token management and storage -- **device-client**: Device binding, profiles, OATH, Push, and WebAuthn capabilities -- **protect**: Fraud detection and risk assessment integration -- **sdk-effects**: Effect-based architecture packages for common functionalities - -### Technology Stack - -- **Package Manager**: pnpm with workspace configuration -- **Build Tool**: Vite for building and bundling -- **Testing**: Vitest for unit tests, Playwright for e2e tests -- **State Management**: Redux Toolkit (in davinci-client) -- **TypeScript**: Strict typing with composite project configuration -- **Linting**: ESLint with Prettier integration - -### Nx Configuration - -- Uses target defaults for build, test, lint, and typecheck -- Caching enabled for build and test targets -- Workspace layout configured for packages and e2e apps -- Parallel execution limited to 1 for consistency - -### Package Dependencies - -Packages use workspace references (`workspace:*`) for internal dependencies and catalog references (`catalog:`) for shared external dependencies like `@reduxjs/toolkit`. - -### Testing Strategy - -- Unit tests: Vitest with coverage reporting -- E2E tests: Playwright with dedicated test apps in `e2e/` directory -- Mock API server available in `e2e/mock-api-v2/` for testing - -## Development Notes - -- All packages are built as ES modules with TypeScript declarations -- Use `pnpm nx affected` commands to run tasks only on changed packages -- The repository uses conventional commits and automated releases via changesets -- Individual packages can be tested independently using their specific build/test scripts diff --git a/codecov.yml b/codecov.yml index e0e409adb9..07b19e90db 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,23 +1,36 @@ +# A more comprehensive Codecov configuration codecov: - require_ci_to_pass: false + # Require CI to pass before Codecov processes reports. This ensures that + # coverage is only measured on successful builds. + require_ci_to_pass: true notify: - wait_for_ci: false + # Wait for CI to finish before sending notifications. + wait_for_ci: true + coverage: + # Set a higher default target for project and patch coverage. + # This encourages a better testing culture. status: project: default: - target: 40% - threshold: 1% + target: 70% + threshold: 2% # Allow for a small drop in coverage patch: default: - target: 40% - threshold: 1% + target: 80% # New code should be well-tested + threshold: 0% # Do not allow new code to decrease coverage + parsers: v1: include_full_missed_files: true + + # Configure how coverage is displayed. range: '70...100' round: down precision: 2 + +# Ignore files that are not relevant for code coverage. +# This keeps the coverage report clean and focused on the source code. ignore: - '**/dist/**/*' - '**/*.mock*' @@ -35,33 +48,37 @@ ignore: - '**/*.md' - '**/LICENSE' - '**/*.json' + - 'e2e/**/*' # E2E tests are not unit tests + - 'tools/**/*' # Tooling scripts + - 'scripts/**/*' # Other scripts +# Configure the comment that Codecov posts on pull requests. comment: - layout: 'header, diff, files' + layout: 'header, diff, components, files' behavior: default - require_changes: false - require_base: false - require_head: true - hide_project_coverage: false - -bundle_analysis: - status: 'informational' + require_changes: false # Post a comment even if there are no coverage changes + require_head: true # Only post a comment if there is a coverage report for the head commit -flag_management: +component_management: default_rules: + # Carry forward coverage from previous commits for all components. carryforward: true - statuses: - - type: project - target: 40% - - type: patch - target: 40% - individual_flags: - - name: package-* + individual_components: + # This rule applies to all components that start with "package-". + # It is expected that the CI script uploads coverage reports with components + # like "package-davinci-client", "package-oidc-client", etc. + # This makes the configuration future-proof for new packages. + - component_id: package-* paths: - - packages/*/ - carryforward: true + - packages/ # Only consider files in the packages directory for these components statuses: - type: project - target: 40% + target: 80% # Higher target for packages + threshold: 2% - type: patch - target: 40% + target: 90% # New code in packages should be very well-tested + threshold: 0% + +# Enable bundle analysis to track the size of the published packages. +bundle_analysis: + status: 'informational' diff --git a/e2e/mock-api-v2/GEMINI.md b/e2e/mock-api-v2/GEMINI.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/eslint.config.mjs b/eslint.config.mjs index 75854c1423..b38bd77ab9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,9 @@ export default [ ignores: [ '**/dist', '**/docs', + '**/node_modules', + '**/coverage', + '**/tmp', '**/vite.config.*.timestamp*', '**/vitest.config.*.timestamp*', '**/out-tsc', diff --git a/nx.json b/nx.json index 75a097048d..540f8f89fc 100644 --- a/nx.json +++ b/nx.json @@ -133,6 +133,19 @@ "watchDepsTargetName": "vite:watch-deps" }, "include": ["packages/**/**/*", "e2e/**/**/*", "tools/**/**/*"] + }, + { + "plugin": "@nx/js/typescript", + "include": ["packages/journey-client/**"], + "options": { + "typecheck": { + "targetName": "typecheck" + }, + "build": { + "targetName": "build", + "configName": "tsconfig.lib.json" + } + } } ], "parallel": 1, @@ -140,17 +153,6 @@ "appsDir": "", "libsDir": "" }, - "generators": { - "@nx/js:library": { - "outDir": "{projectRoot}/dist", - "unitTestRunner": "vitest" - }, - "@nx/web:application": { - "style": "css", - "unitTestRunner": "none", - "e2eTestRunner": "playwright" - } - }, "useDaemonProcess": true, "useInferencePlugins": true, "defaultBase": "main", diff --git a/package.json b/package.json index e308c8bc4a..0c9f69883c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "ci:release": "pnpm publish -r --no-git-checks && changeset tag", "ci:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm nx format:write --uncommitted", "circular-dep-check": "madge --circular .", - "clean": "shx rm -rf ./{coverage,dist,docs,node_modules,tmp}/ ./{packages,e2e}/*/{dist,node_modules}/ && git clean -fX -e \"!.env*,nx-cloud.env\"", + "clean": "shx rm -rf ./{coverage,dist,docs,node_modules,tmp}/ ./{packages,e2e}/*/{dist,node_modules}/ && git clean -fX -e \"!.env*,nx-cloud.env\" -e \"!**/GEMINI.md\"", "commit": "git cz", "commitlint": "commitlint --edit", "create-package": "nx g @nx/js:library", diff --git a/packages/davinci-client/vite.config.ts b/packages/davinci-client/vite.config.ts index b12f08392f..b921b564ce 100644 --- a/packages/davinci-client/vite.config.ts +++ b/packages/davinci-client/vite.config.ts @@ -46,14 +46,10 @@ export default defineConfig({ '**/vitest.{workspace,projects}.[jt]s?(on)', '**/.{eslint,mocha,prettier}rc.{?(c|m)js,yml}', ], - reporter: [ - ['text', { skipEmpty: true }], - ['html', { skipEmpty: true }], - ['json', { skipEmpty: true }], - ], + reporter: ['text', 'html', 'json'], enabled: Boolean(process.env['CI']), reportsDirectory: './coverage', - provider: 'v8', + provider: 'v8' as const, }, }, }); diff --git a/packages/device-client/vite.config.ts b/packages/device-client/vite.config.ts index 30eeb5ca57..457d7c89f3 100644 Binary files a/packages/device-client/vite.config.ts and b/packages/device-client/vite.config.ts differ diff --git a/packages/journey-client/README.md b/packages/journey-client/README.md new file mode 100644 index 0000000000..e352fb2a39 --- /dev/null +++ b/packages/journey-client/README.md @@ -0,0 +1,143 @@ +# @forgerock/journey-client + +`@forgerock/journey-client` is a modern JavaScript client for interacting with Ping Identity's authentication journeys (formerly ForgeRock authentication trees). It provides a stateful, developer-friendly API that abstracts the complexities of the underlying authentication flow, making it easier to integrate with your applications. + +## Features + +- **Stateful Client**: Manages the authentication journey state internally, simplifying interaction compared to stateless approaches. +- **Redux Toolkit & RTK Query**: Built on robust and modern state management and data fetching libraries for predictable state and efficient API interactions. +- **Callback Handling**: Provides a structured way to interact with various authentication callbacks (e.g., username, password, MFA, device profiling). +- **Serializable Redux State**: Ensures the Redux store remains serializable by storing raw API payloads, with class instances created on demand. + +## Installation + +```bash +pnpm add @forgerock/journey-client +# or +npm install @forgerock/journey-client +# or +yarn add @forgerock/journey-client +``` + +## Usage + +The `journey-client` is initialized via an asynchronous factory function, `journey()`, which returns a client instance with methods to control the authentication flow. + +### Basic Authentication Flow + +```typescript +import { journey } from '@forgerock/journey-client'; +import { callbackType } from '@forgerock/sdk-types'; +import type { NameCallback, PasswordCallback } from '@forgerock/journey-client/src/lib/callbacks'; + +async function authenticateUser() { + const client = await journey({ + config: { + serverConfig: { baseUrl: 'https://your-am-instance.com' }, + realmPath: 'root', // e.g., 'root', 'alpha' + tree: 'Login', // The name of your authentication tree/journey + }, + }); + + try { + // 1. Start the authentication journey + let step = await client.start(); + + // 2. Handle callbacks in a loop until success or failure + while (step.type === 'Step') { + console.log('Current step:', step.payload); + + // Example: Handle NameCallback + if (step.getCallbacksOfType(callbackType.NameCallback).length > 0) { + const nameCallback = step.getCallbackOfType(callbackType.NameCallback); + console.log('Prompt for username:', nameCallback.getPrompt()); + nameCallback.setName('demo'); // Set the username + } + + // Example: Handle PasswordCallback + if (step.getCallbacksOfType(callbackType.PasswordCallback).length > 0) { + const passwordCallback = step.getCallbackOfType( + callbackType.PasswordCallback, + ); + console.log('Prompt for password:', passwordCallback.getPrompt()); + passwordCallback.setPassword('password'); // Set the password + } + + // ... handle other callback types as needed (e.g., ChoiceCallback, DeviceProfileCallback) + + // Submit the current step and get the next one + step = await client.next({ step: step.payload }); + } + + // 3. Check the final result + if (step.type === 'LoginSuccess') { + console.log('Login successful!', step.getSessionToken()); + // You can now use the session token for subsequent authenticated requests + } else if (step.type === 'LoginFailure') { + console.error('Login failed:', step.getMessage()); + // Display error message to the user + } else { + console.warn('Unexpected step type:', step.type, step.payload); + } + } catch (error) { + console.error('An error occurred during the authentication journey:', error); + // Handle network errors or other unexpected issues + } +} + +authenticateUser(); +``` + +### Client Methods + +The `journey()` factory function returns a client instance with the following methods: + +- `client.start(options?: StepOptions): Promise` + Initiates a new authentication journey. Returns the first `JourneyStep` in the journey. + +- `client.next(options: { step: Step; options?: StepOptions }): Promise` + Submits the current `Step` payload (obtained from `JourneyStep.payload`) to the authentication API and retrieves the next `JourneyStep` in the journey. + +- `client.redirect(step: JourneyStep): Promise` + Handles `RedirectCallback`s by storing the current step and redirecting the browser to the specified URL. This is typically used for external authentication providers. + +- `client.resume(url: string, options?: StepOptions): Promise` + Resumes an authentication journey after an external redirect (e.g., from an OAuth provider). It retrieves the previously stored step and combines it with URL parameters to continue the flow. + +### Handling Callbacks + +The `JourneyStep` object provides methods to easily access and manipulate callbacks: + +- `step.getCallbackOfType(type: CallbackType): T` + Retrieves a single callback of a specific type. Throws an error if zero or more than one callback of that type is found. + +- `step.getCallbacksOfType(type: CallbackType): T[]` + Retrieves all callbacks of a specific type as an array. + +- `callback.getPrompt(): string` (example for `NameCallback`, `PasswordCallback`) + Gets the prompt message for the callback. + +- `callback.setName(value: string): void` (example for `NameCallback`) + Sets the input value for the callback. + +- `callback.setPassword(value: string): void` (example for `PasswordCallback`) + Sets the input value for the callback. + +- `callback.setProfile(profile: DeviceProfileData): void` (example for `DeviceProfileCallback`) + Sets the device profile data for the callback. + +## Building + +This library is part of an Nx monorepo. To build it, run: + +```bash +pnpm nx build @forgerock/journey-client +``` + +## Testing + +To run the unit tests for this package, run: + +```bash +pnpm nx test @forgerock/journey-client +``` diff --git a/packages/journey-client/eslint.config.mjs b/packages/journey-client/eslint.config.mjs new file mode 100644 index 0000000000..a881271cb4 --- /dev/null +++ b/packages/journey-client/eslint.config.mjs @@ -0,0 +1,19 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')).default, + }, + }, +]; diff --git a/packages/journey-client/package.json b/packages/journey-client/package.json new file mode 100644 index 0000000000..5db24ad195 --- /dev/null +++ b/packages/journey-client/package.json @@ -0,0 +1,60 @@ +{ + "name": "@forgerock/journey-client", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", + "default": "./dist/src/index.js" + }, + "./journey-device": "./dist/src/lib/journey-device/index.js", + "./journey-policy": "./dist/src/lib/journey-policy/index.js", + "./journey-qrcode": "./dist/src/lib/journey-qrcode/journey-qrcode.js", + "./journey-recoverycodes": "./dist/src/lib/recovery-codes/index.js", + "./journey-webauthn": "./dist/src/lib/journey-webauthn/index.js", + "./package.json": "./package.json" + }, + "main": "./dist/src/index.js", + "module": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "scripts": { + "build": "pnpm nx nxBuild", + "lint": "pnpm nx nxLint", + "test": "pnpm nx nxTest", + "test:watch": "pnpm nx nxTest --watch" + }, + "dependencies": { + "@forgerock/sdk-logger": "workspace:*", + "@forgerock/sdk-request-middleware": "workspace:*", + "@forgerock/sdk-types": "workspace:*", + "@forgerock/sdk-utilities": "workspace:*", + "@forgerock/storage": "workspace:*", + "@reduxjs/toolkit": "catalog:", + "tslib": "^2.3.0", + "vite": "6.3.4", + "vitest-canvas-mock": "^0.3.3" + }, + "devDependencies": { + "@vitest/coverage-v8": "^1.2.0", + "vite": "6.3.4", + "vitest": "^1.2.0" + }, + "nx": { + "tags": ["scope:package"], + "implicitDependencies": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "packages/journey-client/dist", + "main": "packages/journey-client/src/index.ts", + "tsConfig": "packages/journey-client/tsconfig.lib.json", + "format": ["esm"] + } + } + } + } +} diff --git a/packages/journey-client/src/index.ts b/packages/journey-client/src/index.ts new file mode 100644 index 0000000000..d3827f7d60 --- /dev/null +++ b/packages/journey-client/src/index.ts @@ -0,0 +1 @@ +export * from './lib/journey-client.js'; diff --git a/packages/journey-client/src/lib/callbacks/attribute-input-callback.test.ts b/packages/journey-client/src/lib/callbacks/attribute-input-callback.test.ts new file mode 100644 index 0000000000..174407b194 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/attribute-input-callback.test.ts @@ -0,0 +1,87 @@ +/* + * @forgerock/javascript-sdk + * + * attribute-input-callback.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; +import type { Callback } from '@forgerock/sdk-types'; +import AttributeInputCallback from './attribute-input-callback.js'; + +describe('AttributeInputCallback', () => { + const payload: Callback = { + _id: 0, + input: [ + { + name: 'IDToken0', + value: '', + }, + { + name: 'IDToken0validateOnly', + value: false, + }, + ], + output: [ + { + name: 'name', + value: 'givenName', + }, + { + name: 'prompt', + value: 'First Name:', + }, + { + name: 'required', + value: true, + }, + { + name: 'policies', + value: { + policyRequirements: ['a', 'b'], + name: 'givenName', + policies: [], + }, + }, + { + name: 'failedPolicies', + value: [JSON.stringify({ failedPolicies: { c: 'c', d: 'd' } })], + }, + { + name: 'validateOnly', + value: false, + }, + ], + type: callbackType.StringAttributeInputCallback, + }; + + it('reads/writes basic properties with "validate only"', () => { + const cb = new AttributeInputCallback(payload); + cb.setValue('Clark'); + cb.setValidateOnly(true); + + expect(cb.getType()).toBe('StringAttributeInputCallback'); + expect(cb.getName()).toBe('givenName'); + expect(cb.getPrompt()).toBe('First Name:'); + expect(cb.isRequired()).toBe(true); + expect(cb.getPolicies().policyRequirements).toStrictEqual(['a', 'b']); + expect(cb.getFailedPolicies()).toStrictEqual([{ failedPolicies: { c: 'c', d: 'd' } }]); + expect(cb.getInputValue()).toBe('Clark'); + if (!cb.payload.input) { + throw new Error('Input is not defined'); + } + expect(cb.payload.input[1].value).toBe(true); + }); + + it('writes validate only to `false` for submission', () => { + const cb = new AttributeInputCallback(payload); + cb.setValidateOnly(false); + if (!cb.payload.input) { + throw new Error('Input is not defined'); + } + expect(cb.payload.input[1].value).toBe(false); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/attribute-input-callback.ts b/packages/journey-client/src/lib/callbacks/attribute-input-callback.ts new file mode 100644 index 0000000000..d7879a1707 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/attribute-input-callback.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import type { Callback, PolicyRequirement } from '@forgerock/sdk-types'; +import JourneyCallback from './index.js'; + +/** + * Represents a callback used to collect attributes. + * + * @typeparam T Maps to StringAttributeInputCallback, NumberAttributeInputCallback and + * BooleanAttributeInputCallback, respectively + */ +class AttributeInputCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the attribute name. + */ + public getName(): string { + return this.getOutputByName('name', ''); + } + + /** + * Gets the attribute prompt. + */ + public getPrompt(): string { + return this.getOutputByName('prompt', ''); + } + + /** + * Gets whether the attribute is required. + */ + public isRequired(): boolean { + return this.getOutputByName('required', false); + } + + /** + * Gets the callback's failed policies. + */ + public getFailedPolicies(): PolicyRequirement[] { + const failedPoliciesJsonStrings = this.getOutputByName('failedPolicies', []); + try { + return failedPoliciesJsonStrings.map((v) => JSON.parse(v)) as PolicyRequirement[]; + } catch { + throw new Error( + 'Unable to parse "failed policies" from the ForgeRock server. The JSON within `AttributeInputCallback` was either malformed or missing.', + ); + } + } + + /** + * Gets the callback's applicable policies. + */ + public getPolicies(): Record { + return this.getOutputByName>('policies', {}); + } + + /** + * Set if validating value only. + */ + public setValidateOnly(value: boolean): void { + this.setInputValue(value, /validateOnly/); + } + + /** + * Sets the attribute's value. + */ + public setValue(value: T): void { + this.setInputValue(value); + } +} + +export default AttributeInputCallback; diff --git a/packages/journey-client/src/lib/callbacks/choice-callback.test.ts b/packages/journey-client/src/lib/callbacks/choice-callback.test.ts new file mode 100644 index 0000000000..7f60cbb512 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/choice-callback.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import ChoiceCallback from './choice-callback.js'; + +describe('ChoiceCallback', () => { + const payload: Callback = { + type: callbackType.ChoiceCallback, + output: [ + { + name: 'prompt', + value: 'Select an option', + }, + { + name: 'choices', + value: ['one', 'two', 'three'], + }, + { + name: 'defaultChoice', + value: 1, + }, + ], + input: [ + { + name: 'IDToken1', + value: 0, + }, + ], + }; + + it('should allow getting the prompt', () => { + const cb = new ChoiceCallback(payload); + expect(cb.getPrompt()).toBe('Select an option'); + }); + + it('should allow getting the choices', () => { + const cb = new ChoiceCallback(payload); + expect(cb.getChoices()).toEqual(['one', 'two', 'three']); + }); + + it('should allow getting the default choice', () => { + const cb = new ChoiceCallback(payload); + expect(cb.getDefaultChoice()).toBe(1); + }); + + it('should allow setting the choice by index', () => { + const cb = new ChoiceCallback(payload); + cb.setChoiceIndex(2); + expect(cb.getInputValue()).toBe(2); + }); + + it('should throw an error for an out-of-bounds index', () => { + const cb = new ChoiceCallback(payload); + expect(() => cb.setChoiceIndex(3)).toThrow('3 is out of bounds'); + expect(() => cb.setChoiceIndex(-1)).toThrow('-1 is out of bounds'); + }); + + it('should allow setting the choice by value', () => { + const cb = new ChoiceCallback(payload); + cb.setChoiceValue('two'); + expect(cb.getInputValue()).toBe(1); + }); + + it('should throw an error for an invalid choice value', () => { + const cb = new ChoiceCallback(payload); + expect(() => cb.setChoiceValue('four')).toThrow('"four" is not a valid choice'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/choice-callback.ts b/packages/journey-client/src/lib/callbacks/choice-callback.ts new file mode 100644 index 0000000000..00ddb0d8e2 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/choice-callback.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect an answer to a choice. + */ +class ChoiceCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the choice's prompt. + */ + public getPrompt(): string { + return this.getOutputByName('prompt', ''); + } + + /** + * Gets the choice's default answer. + */ + public getDefaultChoice(): number { + return this.getOutputByName('defaultChoice', 0); + } + + /** + * Gets the choice's possible answers. + */ + public getChoices(): string[] { + return this.getOutputByName('choices', []); + } + + /** + * Sets the choice's answer by index position. + */ + public setChoiceIndex(index: number): void { + const length = this.getChoices().length; + if (index < 0 || index > length - 1) { + throw new Error(`${index} is out of bounds`); + } + this.setInputValue(index); + } + + /** + * Sets the choice's answer by value. + */ + public setChoiceValue(value: string): void { + const index = this.getChoices().indexOf(value); + if (index === -1) { + throw new Error(`"${value}" is not a valid choice`); + } + this.setInputValue(index); + } +} + +export default ChoiceCallback; diff --git a/packages/journey-client/src/lib/callbacks/confirmation-callback.test.ts b/packages/journey-client/src/lib/callbacks/confirmation-callback.test.ts new file mode 100644 index 0000000000..05140c5655 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/confirmation-callback.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import ConfirmationCallback from './confirmation-callback.js'; + +describe('ConfirmationCallback', () => { + const payload: Callback = { + type: callbackType.ConfirmationCallback, + output: [ + { + name: 'prompt', + value: 'Are you sure?', + }, + { + name: 'messageType', + value: 0, + }, + { + name: 'options', + value: ['Yes', 'No'], + }, + { + name: 'optionType', + value: -1, + }, + { + name: 'defaultOption', + value: 1, + }, + ], + input: [ + { + name: 'IDToken1', + value: 0, + }, + ], + }; + + it('should allow getting the prompt', () => { + const cb = new ConfirmationCallback(payload); + expect(cb.getPrompt()).toBe('Are you sure?'); + }); + + it('should allow getting the message type', () => { + const cb = new ConfirmationCallback(payload); + expect(cb.getMessageType()).toBe(0); + }); + + it('should allow getting the options', () => { + const cb = new ConfirmationCallback(payload); + expect(cb.getOptions()).toEqual(['Yes', 'No']); + }); + + it('should allow getting the option type', () => { + const cb = new ConfirmationCallback(payload); + expect(cb.getOptionType()).toBe(-1); + }); + + it('should allow getting the default option', () => { + const cb = new ConfirmationCallback(payload); + expect(cb.getDefaultOption()).toBe(1); + }); + + it('should allow setting the option by index', () => { + const cb = new ConfirmationCallback(payload); + cb.setOptionIndex(1); + expect(cb.getInputValue()).toBe(1); + }); + + it('should throw an error for an invalid index', () => { + const cb = new ConfirmationCallback(payload); + expect(() => cb.setOptionIndex(2)).toThrow('"2" is not a valid choice'); + }); + + it('should allow setting the option by value', () => { + const cb = new ConfirmationCallback(payload); + cb.setOptionValue('No'); + expect(cb.getInputValue()).toBe(1); + }); + + it('should throw an error for an invalid value', () => { + const cb = new ConfirmationCallback(payload); + expect(() => cb.setOptionValue('Maybe')).toThrow('"Maybe" is not a valid choice'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/confirmation-callback.ts b/packages/journey-client/src/lib/callbacks/confirmation-callback.ts new file mode 100644 index 0000000000..3f985d726c --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/confirmation-callback.ts @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect a confirmation to a message. + */ +class ConfirmationCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the index position of the confirmation's default answer. + */ + public getDefaultOption(): number { + return Number(this.getOutputByName('defaultOption', 0)); + } + + /** + * Gets the confirmation's message type. + */ + public getMessageType(): number { + return Number(this.getOutputByName('messageType', 0)); + } + + /** + * Gets the confirmation's possible answers. + */ + public getOptions(): string[] { + return this.getOutputByName('options', []); + } + + /** + * Gets the confirmation's option type. + */ + public getOptionType(): number { + return Number(this.getOutputByName('optionType', 0)); + } + + /** + * Gets the confirmation's prompt. + */ + public getPrompt(): string { + return this.getOutputByName('prompt', ''); + } + + /** + * Set option index. + */ + public setOptionIndex(index: number): void { + if (index !== 0 && index !== 1) { + throw new Error(`"${index}" is not a valid choice`); + } + this.setInputValue(index); + } + + /** + * Set option value. + */ + public setOptionValue(value: string): void { + const index = this.getOptions().indexOf(value); + if (index === -1) { + throw new Error(`"${value}" is not a valid choice`); + } + this.setInputValue(index); + } +} + +export default ConfirmationCallback; diff --git a/packages/journey-client/src/lib/callbacks/device-profile-callback.test.ts b/packages/journey-client/src/lib/callbacks/device-profile-callback.test.ts new file mode 100644 index 0000000000..687b98464b --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/device-profile-callback.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import DeviceProfileCallback from './device-profile-callback.js'; + +describe('DeviceProfileCallback', () => { + const payload: Callback = { + type: callbackType.DeviceProfileCallback, + output: [ + { + name: 'message', + value: 'Collecting device profile...', + }, + { + name: 'metadata', + value: true, + }, + { + name: 'location', + value: false, + }, + ], + input: [ + { + name: 'IDToken1', + value: '', + }, + ], + }; + + it('should allow getting the message', () => { + const cb = new DeviceProfileCallback(payload); + expect(cb.getMessage()).toBe('Collecting device profile...'); + }); + + it('should allow getting the metadata requirement', () => { + const cb = new DeviceProfileCallback(payload); + expect(cb.isMetadataRequired()).toBe(true); + }); + + it('should allow getting the location requirement', () => { + const cb = new DeviceProfileCallback(payload); + expect(cb.isLocationRequired()).toBe(false); + }); + + it('should allow setting the device profile', () => { + const cb = new DeviceProfileCallback(payload); + const profile = { + identifier: 'test-id', + metadata: { + hardware: { display: {} }, + browser: {}, + platform: {}, + }, + }; + cb.setProfile(profile); + expect(cb.getInputValue()).toBe(JSON.stringify(profile)); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/device-profile-callback.ts b/packages/journey-client/src/lib/callbacks/device-profile-callback.ts new file mode 100644 index 0000000000..17ec440d6a --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/device-profile-callback.ts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; +import type { DeviceProfileData } from '../journey-device/interfaces.js'; + +/** + * Represents a callback used to collect device profile data. + */ +class DeviceProfileCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the callback's data. + */ + public getMessage(): string { + return this.getOutputByName('message', ''); + } + + /** + * Does callback require metadata? + */ + public isMetadataRequired(): boolean { + return this.getOutputByName('metadata', false); + } + + /** + * Does callback require location data? + */ + public isLocationRequired(): boolean { + return this.getOutputByName('location', false); + } + + /** + * Sets the profile. + */ + public setProfile(profile: DeviceProfileData): void { + this.setInputValue(JSON.stringify(profile)); + } +} + +export default DeviceProfileCallback; diff --git a/packages/journey-client/src/lib/callbacks/factory.test.ts b/packages/journey-client/src/lib/callbacks/factory.test.ts new file mode 100644 index 0000000000..be7abdb731 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/factory.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import createCallback from './factory.js'; + +// Import all callback classes to check against +import AttributeInputCallback from './attribute-input-callback.js'; +import ChoiceCallback from './choice-callback.js'; +import ConfirmationCallback from './confirmation-callback.js'; +import DeviceProfileCallback from './device-profile-callback.js'; +import HiddenValueCallback from './hidden-value-callback.js'; +import KbaCreateCallback from './kba-create-callback.js'; +import MetadataCallback from './metadata-callback.js'; +import NameCallback from './name-callback.js'; +import PasswordCallback from './password-callback.js'; +import PingOneProtectEvaluationCallback from './ping-protect-evaluation-callback.js'; +import PingOneProtectInitializeCallback from './ping-protect-initialize-callback.js'; +import PollingWaitCallback from './polling-wait-callback.js'; +import ReCaptchaCallback from './recaptcha-callback.js'; +import ReCaptchaEnterpriseCallback from './recaptcha-enterprise-callback.js'; +import RedirectCallback from './redirect-callback.js'; +import SelectIdPCallback from './select-idp-callback.js'; +import SuspendedTextOutputCallback from './suspended-text-output-callback.js'; +import TermsAndConditionsCallback from './terms-and-conditions-callback.js'; +import TextInputCallback from './text-input-callback.js'; +import TextOutputCallback from './text-output-callback.js'; +import ValidatedCreatePasswordCallback from './validated-create-password-callback.js'; +import ValidatedCreateUsernameCallback from './validated-create-username-callback.js'; +import JourneyCallback from './index.js'; + +describe('Callback Factory', () => { + const testCases = [ + { type: callbackType.BooleanAttributeInputCallback, class: AttributeInputCallback }, + { type: callbackType.ChoiceCallback, class: ChoiceCallback }, + { type: callbackType.ConfirmationCallback, class: ConfirmationCallback }, + { type: callbackType.DeviceProfileCallback, class: DeviceProfileCallback }, + { type: callbackType.HiddenValueCallback, class: HiddenValueCallback }, + { type: callbackType.KbaCreateCallback, class: KbaCreateCallback }, + { type: callbackType.MetadataCallback, class: MetadataCallback }, + { type: callbackType.NameCallback, class: NameCallback }, + { type: callbackType.NumberAttributeInputCallback, class: AttributeInputCallback }, + { type: callbackType.PasswordCallback, class: PasswordCallback }, + { + type: callbackType.PingOneProtectEvaluationCallback, + class: PingOneProtectEvaluationCallback, + }, + { + type: callbackType.PingOneProtectInitializeCallback, + class: PingOneProtectInitializeCallback, + }, + { type: callbackType.PollingWaitCallback, class: PollingWaitCallback }, + { type: callbackType.ReCaptchaCallback, class: ReCaptchaCallback }, + { type: callbackType.ReCaptchaEnterpriseCallback, class: ReCaptchaEnterpriseCallback }, + { type: callbackType.RedirectCallback, class: RedirectCallback }, + { type: callbackType.SelectIdPCallback, class: SelectIdPCallback }, + { type: callbackType.StringAttributeInputCallback, class: AttributeInputCallback }, + { type: callbackType.SuspendedTextOutputCallback, class: SuspendedTextOutputCallback }, + { type: callbackType.TermsAndConditionsCallback, class: TermsAndConditionsCallback }, + { type: callbackType.TextInputCallback, class: TextInputCallback }, + { type: callbackType.TextOutputCallback, class: TextOutputCallback }, + { type: callbackType.ValidatedCreatePasswordCallback, class: ValidatedCreatePasswordCallback }, + { type: callbackType.ValidatedCreateUsernameCallback, class: ValidatedCreateUsernameCallback }, + ]; + + testCases.forEach((testCase) => { + it(`should create an instance of ${testCase.class.name} for type ${testCase.type}`, () => { + const payload: Callback = { type: testCase.type, input: [], output: [] }; + const callback = createCallback(payload); + expect(callback).toBeInstanceOf(testCase.class); + }); + }); + + it('should create a base JourneyCallback for an unknown type', () => { + const payload: Callback = { type: 'UnknownCallback' as any, input: [], output: [] }; + const callback = createCallback(payload); + expect(callback).toBeInstanceOf(JourneyCallback); + // Ensure it's not an instance of a more specific class + expect(callback).not.toBeInstanceOf(NameCallback); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/factory.ts b/packages/journey-client/src/lib/callbacks/factory.ts new file mode 100644 index 0000000000..52b9057d68 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/factory.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; +import AttributeInputCallback from './attribute-input-callback.js'; +import ChoiceCallback from './choice-callback.js'; +import ConfirmationCallback from './confirmation-callback.js'; +import DeviceProfileCallback from './device-profile-callback.js'; +import HiddenValueCallback from './hidden-value-callback.js'; +import KbaCreateCallback from './kba-create-callback.js'; +import MetadataCallback from './metadata-callback.js'; +import NameCallback from './name-callback.js'; +import PasswordCallback from './password-callback.js'; +import PingOneProtectEvaluationCallback from './ping-protect-evaluation-callback.js'; +import PingOneProtectInitializeCallback from './ping-protect-initialize-callback.js'; +import PollingWaitCallback from './polling-wait-callback.js'; +import ReCaptchaCallback from './recaptcha-callback.js'; +import ReCaptchaEnterpriseCallback from './recaptcha-enterprise-callback.js'; +import RedirectCallback from './redirect-callback.js'; +import SelectIdPCallback from './select-idp-callback.js'; +import SuspendedTextOutputCallback from './suspended-text-output-callback.js'; +import TermsAndConditionsCallback from './terms-and-conditions-callback.js'; +import TextInputCallback from './text-input-callback.js'; +import TextOutputCallback from './text-output-callback.js'; +import ValidatedCreatePasswordCallback from './validated-create-password-callback.js'; +import ValidatedCreateUsernameCallback from './validated-create-username-callback.js'; + +type JourneyCallbackFactory = (callback: Callback) => JourneyCallback; + +/** + * @hidden + */ +function createCallback(callback: Callback): JourneyCallback { + switch (callback.type) { + case callbackType.BooleanAttributeInputCallback: + return new AttributeInputCallback(callback); + case callbackType.ChoiceCallback: + return new ChoiceCallback(callback); + case callbackType.ConfirmationCallback: + return new ConfirmationCallback(callback); + case callbackType.DeviceProfileCallback: + return new DeviceProfileCallback(callback); + case callbackType.HiddenValueCallback: + return new HiddenValueCallback(callback); + case callbackType.KbaCreateCallback: + return new KbaCreateCallback(callback); + case callbackType.MetadataCallback: + return new MetadataCallback(callback); + case callbackType.NameCallback: + return new NameCallback(callback); + case callbackType.NumberAttributeInputCallback: + return new AttributeInputCallback(callback); + case callbackType.PasswordCallback: + return new PasswordCallback(callback); + case callbackType.PingOneProtectEvaluationCallback: + return new PingOneProtectEvaluationCallback(callback); + case callbackType.PingOneProtectInitializeCallback: + return new PingOneProtectInitializeCallback(callback); + case callbackType.PollingWaitCallback: + return new PollingWaitCallback(callback); + case callbackType.ReCaptchaCallback: + return new ReCaptchaCallback(callback); + case callbackType.ReCaptchaEnterpriseCallback: + return new ReCaptchaEnterpriseCallback(callback); + case callbackType.RedirectCallback: + return new RedirectCallback(callback); + case callbackType.SelectIdPCallback: + return new SelectIdPCallback(callback); + case callbackType.StringAttributeInputCallback: + return new AttributeInputCallback(callback); + case callbackType.SuspendedTextOutputCallback: + return new SuspendedTextOutputCallback(callback); + case callbackType.TermsAndConditionsCallback: + return new TermsAndConditionsCallback(callback); + case callbackType.TextInputCallback: + return new TextInputCallback(callback); + case callbackType.TextOutputCallback: + return new TextOutputCallback(callback); + case callbackType.ValidatedCreatePasswordCallback: + return new ValidatedCreatePasswordCallback(callback); + case callbackType.ValidatedCreateUsernameCallback: + return new ValidatedCreateUsernameCallback(callback); + default: + return new JourneyCallback(callback); + } +} + +export default createCallback; +export type { JourneyCallbackFactory }; diff --git a/packages/journey-client/src/lib/callbacks/fr-auth-callback.test.ts b/packages/journey-client/src/lib/callbacks/fr-auth-callback.test.ts new file mode 100644 index 0000000000..2f3c609c33 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/fr-auth-callback.test.ts @@ -0,0 +1,40 @@ +/* + * @forgerock/javascript-sdk + * + * fr-auth-callback.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import { callbackType } from '@forgerock/sdk-types'; +import type { Callback } from '@forgerock/sdk-types'; + +describe('JourneyCallback', () => { + it('reads/writes basic properties', () => { + const payload: Callback = { + _id: 0, + input: [ + { + name: 'userName', + value: '', + }, + ], + output: [ + { + name: 'prompt', + value: 'Username:', + }, + ], + type: callbackType.NameCallback, + }; + const cb = new JourneyCallback(payload); + cb.setInputValue('superman'); + + expect(cb.getType()).toBe('NameCallback'); + expect(cb.getOutputValue('prompt')).toBe('Username:'); + expect(cb.getInputValue()).toBe('superman'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/hidden-value-callback.test.ts b/packages/journey-client/src/lib/callbacks/hidden-value-callback.test.ts new file mode 100644 index 0000000000..9ef7f72873 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/hidden-value-callback.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import HiddenValueCallback from './hidden-value-callback.js'; + +describe('HiddenValueCallback', () => { + it('should instantiate correctly', () => { + const payload: Callback = { + type: callbackType.HiddenValueCallback, + output: [{ name: 'value', value: 'some-hidden-value' }], + input: [{ name: 'IDToken1', value: '' }], + }; + const cb = new HiddenValueCallback(payload); + expect(cb).toBeInstanceOf(HiddenValueCallback); + expect(cb.getOutputValue('value')).toBe('some-hidden-value'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/hidden-value-callback.ts b/packages/journey-client/src/lib/callbacks/hidden-value-callback.ts new file mode 100644 index 0000000000..e27820c8ac --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/hidden-value-callback.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect information indirectly from the user. + */ +class HiddenValueCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } +} + +export default HiddenValueCallback; diff --git a/packages/journey-client/src/lib/callbacks/index.ts b/packages/journey-client/src/lib/callbacks/index.ts new file mode 100644 index 0000000000..00f62424ad --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/index.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { type CallbackType, type Callback, type NameValue } from '@forgerock/sdk-types'; + +/** + * Base class for authentication tree callback wrappers. + */ +class JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public payload: Callback) {} + + /** + * Gets the name of this callback type. + */ + public getType(): CallbackType { + return this.payload.type; + } + + /** + * Gets the value of the specified input element, or the first element if `selector` is not + * provided. + * + * @param selector The index position or name of the desired element + */ + public getInputValue(selector: number | string = 0): unknown { + return this.getArrayElement(this.payload.input, selector).value; + } + + /** + * Sets the value of the specified input element, or the first element if `selector` is not + * provided. + * + * @param selector The index position or name of the desired element + */ + public setInputValue(value: unknown, selector: number | string | RegExp = 0): void { + this.getArrayElement(this.payload.input, selector).value = value; + } + + /** + * Gets the value of the specified output element, or the first element if `selector` + * is not provided. + * + * @param selector The index position or name of the desired element + */ + public getOutputValue(selector: number | string = 0): unknown { + return this.getArrayElement(this.payload.output, selector).value; + } + + /** + * Gets the value of the first output element with the specified name or the + * specified default value. + * + * @param name The name of the desired element + */ + public getOutputByName(name: string, defaultValue: T): T { + const output = this.payload.output.find((x: NameValue) => x.name === name); + return output ? (output.value as T) : defaultValue; + } + + private getArrayElement( + array: NameValue[] | undefined, + selector: number | string | RegExp = 0, + ): NameValue { + if (array === undefined) { + throw new Error(`No NameValue array was provided to search (selector ${selector})`); + } + + if (typeof selector === 'number') { + if (selector < 0 || selector > array.length - 1) { + throw new Error(`Selector index ${selector} is out of range`); + } + return array[selector]; + } + + if (typeof selector === 'string') { + const input = array.find((x) => x.name === selector); + if (!input) { + throw new Error(`Missing callback input entry "${selector}"`); + } + return input; + } + + // Duck typing for RegEx + if (typeof selector === 'object' && selector.test && Boolean(selector.exec)) { + const input = array.find((x) => selector.test(x.name)); + if (!input) { + throw new Error(`Missing callback input entry "${selector}"`); + } + return input; + } + + throw new Error('Invalid selector value type'); + } +} + +export default JourneyCallback; diff --git a/packages/journey-client/src/lib/callbacks/kba-create-callback.test.ts b/packages/journey-client/src/lib/callbacks/kba-create-callback.test.ts new file mode 100644 index 0000000000..41eecbf99e --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/kba-create-callback.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import KbaCreateCallback from './kba-create-callback.js'; + +describe('KbaCreateCallback', () => { + const payload: Callback = { + type: callbackType.KbaCreateCallback, + output: [ + { + name: 'prompt', + value: 'What is your favorite color?', + }, + { + name: 'predefinedQuestions', + value: ['Question 1', 'Question 2'], + }, + ], + input: [ + { + name: 'IDToken1question', + value: '', + }, + { + name: 'IDToken1answer', + value: '', + }, + ], + }; + + it('should allow getting the prompt and questions', () => { + const cb = new KbaCreateCallback(payload); + expect(cb.getPrompt()).toBe('What is your favorite color?'); + expect(cb.getPredefinedQuestions()).toEqual(['Question 1', 'Question 2']); + }); + + it('should allow setting the question and answer', () => { + const cb = new KbaCreateCallback(payload); + cb.setQuestion('My custom question'); + cb.setAnswer('Blue'); + expect(cb.getInputValue('IDToken1question')).toBe('My custom question'); + expect(cb.getInputValue('IDToken1answer')).toBe('Blue'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/kba-create-callback.ts b/packages/journey-client/src/lib/callbacks/kba-create-callback.ts new file mode 100644 index 0000000000..afbb00bc6d --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/kba-create-callback.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect KBA-style security questions and answers. + */ +class KbaCreateCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the callback prompt. + */ + public getPrompt(): string { + return this.getOutputByName('prompt', ''); + } + + /** + * Gets the callback's list of pre-defined security questions. + */ + public getPredefinedQuestions(): string[] { + return this.getOutputByName('predefinedQuestions', []); + } + + /** + * Sets the callback's security question. + */ + public setQuestion(question: string): void { + this.setValue('question', question); + } + + /** + * Sets the callback's security question answer. + */ + public setAnswer(answer: string): void { + this.setValue('answer', answer); + } + + private setValue(type: 'question' | 'answer', value: string): void { + if (!this.payload.input) { + throw new Error('KBA payload is missing input'); + } + + const input = this.payload.input.find((x) => x.name.endsWith(type)); + if (!input) { + throw new Error(`No input has name ending in "${type}"`); + } + input.value = value; + } +} + +export default KbaCreateCallback; diff --git a/packages/journey-client/src/lib/callbacks/metadata-callback.test.ts b/packages/journey-client/src/lib/callbacks/metadata-callback.test.ts new file mode 100644 index 0000000000..868383dd2d --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/metadata-callback.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import MetadataCallback from './metadata-callback.js'; + +describe('MetadataCallback', () => { + it('should allow getting the data', () => { + const mockData = { foo: 'bar', baz: 123 }; + const payload: Callback = { + type: callbackType.MetadataCallback, + output: [ + { + name: 'data', + value: mockData, + }, + ], + input: [], + }; + const cb = new MetadataCallback(payload); + expect(cb.getData()).toEqual(mockData); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/metadata-callback.ts b/packages/journey-client/src/lib/callbacks/metadata-callback.ts new file mode 100644 index 0000000000..be523d73c2 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/metadata-callback.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to deliver and collect miscellaneous data. + */ +class MetadataCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the callback's data. + */ + public getData(): T { + return this.getOutputByName('data', {} as T); + } +} + +export default MetadataCallback; diff --git a/packages/journey-client/src/lib/callbacks/name-callback.test.ts b/packages/journey-client/src/lib/callbacks/name-callback.test.ts new file mode 100644 index 0000000000..3243af0aa6 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/name-callback.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import NameCallback from './name-callback.js'; + +describe('NameCallback', () => { + const payload: Callback = { + type: callbackType.NameCallback, + output: [ + { + name: 'prompt', + value: 'Username', + }, + ], + input: [ + { + name: 'IDToken1', + value: '', + }, + ], + }; + + it('should allow getting the prompt', () => { + const cb = new NameCallback(payload); + expect(cb.getPrompt()).toBe('Username'); + }); + + it('should allow setting the name', () => { + const cb = new NameCallback(payload); + cb.setName('test-user'); + expect(cb.getInputValue()).toBe('test-user'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/name-callback.ts b/packages/journey-client/src/lib/callbacks/name-callback.ts new file mode 100644 index 0000000000..bd19ad5bb6 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/name-callback.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect a username. + */ +class NameCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the callback's prompt. + */ + public getPrompt(): string { + return this.getOutputByName('prompt', ''); + } + + /** + * Sets the username. + */ + public setName(name: string): void { + this.setInputValue(name); + } +} + +export default NameCallback; diff --git a/packages/journey-client/src/lib/callbacks/password-callback.test.ts b/packages/journey-client/src/lib/callbacks/password-callback.test.ts new file mode 100644 index 0000000000..3b35a5ff85 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/password-callback.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import PasswordCallback from './password-callback.js'; + +describe('PasswordCallback', () => { + const payload: Callback = { + type: callbackType.PasswordCallback, + output: [ + { + name: 'prompt', + value: 'Password', + }, + { + name: 'policies', + value: ['policy1', 'policy2'], + }, + { + name: 'failedPolicies', + value: ['failedPolicy1'], + }, + ], + input: [ + { + name: 'IDToken1', + value: '', + }, + ], + }; + + it('should allow getting the prompt', () => { + const cb = new PasswordCallback(payload); + expect(cb.getPrompt()).toBe('Password'); + }); + + it('should allow getting policies', () => { + const cb = new PasswordCallback(payload); + expect(cb.getPolicies()).toEqual(['policy1', 'policy2']); + }); + + it('should allow getting failed policies', () => { + const cb = new PasswordCallback(payload); + expect(cb.getFailedPolicies()).toEqual(['failedPolicy1']); + }); + + it('should allow setting the password', () => { + const cb = new PasswordCallback(payload); + cb.setPassword('new-password'); + expect(cb.getInputValue()).toBe('new-password'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/password-callback.ts b/packages/journey-client/src/lib/callbacks/password-callback.ts new file mode 100644 index 0000000000..4d1e8685c7 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/password-callback.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback, PolicyRequirement } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect a password. + */ +class PasswordCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the callback's failed policies. + */ + public getFailedPolicies(): PolicyRequirement[] { + return this.getOutputByName('failedPolicies', []); + } + + /** + * Gets the callback's applicable policies. + */ + public getPolicies(): string[] { + return this.getOutputByName('policies', []); + } + + /** + * Gets the callback's prompt. + */ + public getPrompt(): string { + return this.getOutputByName('prompt', ''); + } + + /** + * Sets the password. + */ + public setPassword(password: string): void { + this.setInputValue(password); + } +} + +export default PasswordCallback; diff --git a/packages/journey-client/src/lib/callbacks/ping-protect-evaluation-callback.test.ts b/packages/journey-client/src/lib/callbacks/ping-protect-evaluation-callback.test.ts new file mode 100644 index 0000000000..4c2843ab62 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/ping-protect-evaluation-callback.test.ts @@ -0,0 +1,68 @@ +/* + * @forgerock/javascript-sdk + * + * ping-protect-evaluation-callback.test.ts + * + * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { vi, describe, it, expect } from 'vitest'; +import { callbackType } from '@forgerock/sdk-types'; +import PingOneProtectEvaluationCallback from './ping-protect-evaluation-callback.js'; + +describe('PingOneProtectEvaluationCallback', () => { + it('should be defined', () => { + expect(PingOneProtectEvaluationCallback).toBeDefined(); + }); + it('should test that the pauseBehavior method can be called', () => { + const callback = new PingOneProtectEvaluationCallback({ + type: callbackType.PingOneProtectEvaluationCallback, + output: [{ name: 'pauseBehavioralData', value: true }], + }); + const mock = vi.spyOn(callback, 'getPauseBehavioralData'); + callback.getPauseBehavioralData(); + expect(mock).toHaveBeenCalled(); + }); + it('should test setData method', () => { + const callback = new PingOneProtectEvaluationCallback({ + type: callbackType.PingOneProtectEvaluationCallback, + output: [{ name: 'signals', value: '' }], + input: [ + { + name: 'IDToken1signals', + value: '', + }, + { + name: 'IDToken1clientError', + value: '', + }, + ], + }); + const mock = vi.spyOn(callback, 'setData'); + callback.setData('data'); + expect(mock).toHaveBeenCalledWith('data'); + expect(callback.getInputValue('IDToken1signals')).toBe('data'); + }); + it('should test setClientError method', () => { + const callback = new PingOneProtectEvaluationCallback({ + type: callbackType.PingOneProtectEvaluationCallback, + output: [{ name: 'signals', value: '' }], + input: [ + { + name: 'IDToken1signals', + value: '', + }, + { + name: 'IDToken1clientError', + value: '', + }, + ], + }); + const mock = vi.spyOn(callback, 'setClientError'); + callback.setClientError('error i just set'); + expect(mock).toHaveBeenCalledWith('error i just set'); + expect(callback.getInputValue('IDToken1clientError')).toBe('error i just set'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/ping-protect-evaluation-callback.ts b/packages/journey-client/src/lib/callbacks/ping-protect-evaluation-callback.ts new file mode 100644 index 0000000000..9e1dce3da7 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/ping-protect-evaluation-callback.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * @class - Represents a callback used to complete and package up device and behavioral data. + */ +class PingOneProtectEvaluationCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the callback's pauseBehavioralData value. + * @returns {boolean} + */ + public getPauseBehavioralData(): boolean { + return this.getOutputByName('pauseBehavioralData', false); + } + + /** + * @method setData - Set the result of data collection + * @param {string} data - Data from calling pingProtect.get() + * @returns {void} + */ + public setData(data: string): void { + this.setInputValue(data, /signals/); + } + + /** + * @method setClientError - Set the client error message + * @param {string} errorMessage - Error message + * @returns {void} + */ + public setClientError(errorMessage: string): void { + this.setInputValue(errorMessage, /clientError/); + } +} + +export default PingOneProtectEvaluationCallback; + +/** + * Example of callback: +{ + "type": "PingOneProtectEvaluationCallback", + "output": [ + { + "name": "pauseBehavioralData", + "value": true + } + ], + "input": [ + { + "name": "IDToken1signals", + "value": "" + }, + { + "name": "IDToken1clientError", + "value": "" + } + ] +} +*/ diff --git a/packages/journey-client/src/lib/callbacks/ping-protect-initialize-callback.test.ts b/packages/journey-client/src/lib/callbacks/ping-protect-initialize-callback.test.ts new file mode 100644 index 0000000000..c54a30ce4f --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/ping-protect-initialize-callback.test.ts @@ -0,0 +1,152 @@ +/* + * @forgerock/javascript-sdk + * + * ping-protect-intitialize-callback.test.ts + * + * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { vi, describe, expect, it } from 'vitest'; +import { callbackType } from '@forgerock/sdk-types'; +import PingOneProtectInitializeCallback from './ping-protect-initialize-callback.js'; + +describe('PingOneProtectInitializeCallback', () => { + it('should exist', () => { + expect(PingOneProtectInitializeCallback).toBeDefined(); + }); + it('should test the getConfig method', () => { + const callback = new PingOneProtectInitializeCallback({ + type: callbackType.PingOneProtectInitializeCallback, + input: [ + { + name: 'IDToken1signals', + value: '', + }, + { + name: 'IDToken1clientError', + value: '', + }, + ], + output: [ + { + name: 'envId', + value: '02fb4743-189a-4bc7-9d6c-a919edfe6447', + }, + { + name: 'consoleLogEnabled', + value: false, + }, + { + name: 'deviceAttributesToIgnore', + value: [], + }, + { + name: 'customHost', + value: '', + }, + { + name: 'lazyMetadata', + value: false, + }, + { + name: 'behavioralDataCollection', + value: true, + }, + { + name: 'deviceKeyRsyncIntervals', + value: 14, + }, + { + name: 'enableTrust', + value: false, + }, + { + name: 'disableTags', + value: false, + }, + { + name: 'disableHub', + value: false, + }, + ], + }); + const mock = vi.spyOn(callback, 'getConfig'); + const config = callback.getConfig(); + expect(mock).toHaveBeenCalled(); + expect(config).toMatchObject({ + envId: '02fb4743-189a-4bc7-9d6c-a919edfe6447', + consoleLogEnabled: false, + deviceAttributesToIgnore: [], + customHost: '', + lazyMetadata: false, + behavioralDataCollection: true, + deviceKeyRsyncIntervals: 14, + enableTrust: false, + disableTags: false, + disableHub: false, + }); + }); + it('should test the setClientError method', () => { + const callback = new PingOneProtectInitializeCallback({ + type: callbackType.PingOneProtectInitializeCallback, + input: [ + { + name: 'IDToken1signals', + value: '', + }, + { + name: 'IDToken1clientError', + value: '', + }, + ], + output: [ + { + name: 'envId', + value: '02fb4743-189a-4bc7-9d6c-a919edfe6447', + }, + { + name: 'consoleLogEnabled', + value: false, + }, + { + name: 'deviceAttributesToIgnore', + value: [], + }, + { + name: 'customHost', + value: '', + }, + { + name: 'lazyMetadata', + value: false, + }, + { + name: 'behavioralDataCollection', + value: true, + }, + { + name: 'deviceKeyRsyncIntervals', + value: 14, + }, + { + name: 'enableTrust', + value: false, + }, + { + name: 'disableTags', + value: false, + }, + { + name: 'disableHub', + value: false, + }, + ], + }); + const mock = vi.spyOn(callback, 'setClientError'); + callback.setClientError('error i just set'); + expect(mock).toHaveBeenCalled(); + expect(callback.getInputValue('IDToken1clientError')).toBe('error i just set'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/ping-protect-initialize-callback.ts b/packages/journey-client/src/lib/callbacks/ping-protect-initialize-callback.ts new file mode 100644 index 0000000000..513d1d018f --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/ping-protect-initialize-callback.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * @class - Represents a callback used to initialize and start device and behavioral data collection. + */ +class PingOneProtectInitializeCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Get callback's initialization config settings + */ + public getConfig() { + const config = { + envId: this.getOutputByName('envId', ''), + consoleLogEnabled: this.getOutputByName('consoleLogEnabled', false), + deviceAttributesToIgnore: this.getOutputByName('deviceAttributesToIgnore', []), + customHost: this.getOutputByName('customHost', ''), + lazyMetadata: this.getOutputByName('lazyMetadata', false), + behavioralDataCollection: this.getOutputByName('behavioralDataCollection', true), + deviceKeyRsyncIntervals: this.getOutputByName('deviceKeyRsyncIntervals', 14), + enableTrust: this.getOutputByName('enableTrust', false), + disableTags: this.getOutputByName('disableTags', false), + disableHub: this.getOutputByName('disableHub', false), + }; + return config; + } + + public setClientError(errorMessage: string): void { + this.setInputValue(errorMessage, /clientError/); + } +} + +export default PingOneProtectInitializeCallback; diff --git a/packages/journey-client/src/lib/callbacks/polling-wait-callback.test.ts b/packages/journey-client/src/lib/callbacks/polling-wait-callback.test.ts new file mode 100644 index 0000000000..78203621c8 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/polling-wait-callback.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import PollingWaitCallback from './polling-wait-callback.js'; + +describe('PollingWaitCallback', () => { + const payload: Callback = { + type: callbackType.PollingWaitCallback, + output: [ + { + name: 'message', + value: 'Please wait...', + }, + { + name: 'waitTime', + value: '5000', + }, + ], + input: [], + }; + + it('should allow getting the message and wait time', () => { + const cb = new PollingWaitCallback(payload); + expect(cb.getMessage()).toBe('Please wait...'); + expect(cb.getWaitTime()).toBe(5000); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/polling-wait-callback.ts b/packages/journey-client/src/lib/callbacks/polling-wait-callback.ts new file mode 100644 index 0000000000..38c4be6bbe --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/polling-wait-callback.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to instruct the system to poll while a backend process completes. + */ +class PollingWaitCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the message to display while polling. + */ + public getMessage(): string { + return this.getOutputByName('message', ''); + } + + /** + * Gets the polling interval in milliseconds. + */ + public getWaitTime(): number { + return Number(this.getOutputByName('waitTime', 0)); + } +} + +export default PollingWaitCallback; diff --git a/packages/journey-client/src/lib/callbacks/recaptcha-callback.test.ts b/packages/journey-client/src/lib/callbacks/recaptcha-callback.test.ts new file mode 100644 index 0000000000..106c5514ed --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/recaptcha-callback.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import ReCaptchaCallback from './recaptcha-callback.js'; + +describe('ReCaptchaCallback', () => { + const payload: Callback = { + type: callbackType.ReCaptchaCallback, + output: [ + { + name: 'recaptchaSiteKey', + value: 'test-site-key', + }, + ], + input: [ + { + name: 'IDToken1', + value: '', + }, + ], + }; + + it('should allow getting the site key', () => { + const cb = new ReCaptchaCallback(payload); + expect(cb.getSiteKey()).toBe('test-site-key'); + }); + + it('should allow setting the result', () => { + const cb = new ReCaptchaCallback(payload); + cb.setResult('recaptcha-response-token'); + expect(cb.getInputValue()).toBe('recaptcha-response-token'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/recaptcha-callback.ts b/packages/journey-client/src/lib/callbacks/recaptcha-callback.ts new file mode 100644 index 0000000000..28fc309c6c --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/recaptcha-callback.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to integrate reCAPTCHA. + */ +class ReCaptchaCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the reCAPTCHA site key. + */ + public getSiteKey(): string { + return this.getOutputByName('recaptchaSiteKey', ''); + } + + /** + * Sets the reCAPTCHA result. + */ + public setResult(result: string): void { + this.setInputValue(result); + } +} + +export default ReCaptchaCallback; diff --git a/packages/journey-client/src/lib/callbacks/recaptcha-enterprise-callback.test.ts b/packages/journey-client/src/lib/callbacks/recaptcha-enterprise-callback.test.ts new file mode 100644 index 0000000000..417f6fea76 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/recaptcha-enterprise-callback.test.ts @@ -0,0 +1,84 @@ +/* + * @forgerock/javascript-sdk + * + * recaptcha-enterprise-callback.test.ts + * + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, expect, it, beforeAll } from 'vitest'; +import ReCaptchaEnterpriseCallback from './recaptcha-enterprise-callback.js'; +import { callbackType } from '@forgerock/sdk-types'; +import { Callback } from '@forgerock/sdk-types'; + +const recaptchaCallback: Callback = { + type: callbackType.ReCaptchaEnterpriseCallback, + output: [ + { + name: 'recaptchaSiteKey', + value: '6LdSu_spAAAAAKz3UhIy4JYQld2lm_WRt7dEhf9T', + }, + { + name: 'captchaApiUri', + value: 'https://www.google.com/recaptcha/enterprise.js', + }, + { + name: 'captchaDivClass', + value: 'g-recaptcha', + }, + ], + input: [ + { + name: 'IDToken1token', + value: '', + }, + { + name: 'IDToken1action', + value: '', + }, + { + name: 'IDToken1clientError', + value: '', + }, + { + name: 'IDToken1payload', + value: '', + }, + ], +}; +describe('enterprise recaptcha', () => { + let callback: ReCaptchaEnterpriseCallback; + beforeAll(() => { + callback = new ReCaptchaEnterpriseCallback(recaptchaCallback); + }); + it('should get the site key', () => { + const siteKey = callback.getSiteKey(); + expect(siteKey).toEqual('6LdSu_spAAAAAKz3UhIy4JYQld2lm_WRt7dEhf9T'); + }); + it('should get captchaApiUri', () => { + const url = callback.getApiUrl(); + expect(url).toEqual('https://www.google.com/recaptcha/enterprise.js'); + }); + it('should set the action', () => { + callback.setAction('my_action'); + expect(callback.getInputValue('IDToken1action')).toEqual('my_action'); + }); + it('should set the client error', () => { + callback.setClientError('error here'); + expect(callback.getInputValue('IDToken1clientError')).toEqual('error here'); + }); + it('should set the payload', () => { + callback.setPayload({ test: 'here' }); + expect(callback.getInputValue('IDToken1payload')).toEqual({ test: 'here' }); + }); + it('should set the token', () => { + callback.setResult('12345'); + expect(callback.getInputValue('IDToken1token')).toEqual('12345'); + }); + it('should get the class', () => { + const className = callback.getElementClass(); + expect(className).toBe('g-recaptcha'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/recaptcha-enterprise-callback.ts b/packages/journey-client/src/lib/callbacks/recaptcha-enterprise-callback.ts new file mode 100644 index 0000000000..4c6a05ebee --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/recaptcha-enterprise-callback.ts @@ -0,0 +1,73 @@ +/* + * @forgerock/javascript-sdk + * + * recaptcha-callback.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to integrate reCAPTCHA. + */ +class ReCaptchaEnterpriseCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the reCAPTCHA site key. + */ + public getSiteKey(): string { + return this.getOutputByName('recaptchaSiteKey', ''); + } + + /** + * Get the api url + */ + public getApiUrl(): string { + return this.getOutputByName('captchaApiUri', ''); + } + /** + * Get the class name + */ + public getElementClass(): string { + return this.getOutputByName('captchaDivClass', ''); + } + /** + * Sets the reCAPTCHA result. + */ + public setResult(result: string): void { + this.setInputValue(result); + } + + /** + * Set client client error + */ + public setClientError(error: string) { + this.setInputValue(error, 'IDToken1clientError'); + } + + /** + * Set the recaptcha payload + */ + public setPayload(payload: unknown) { + this.setInputValue(payload, 'IDToken1payload'); + } + + /** + * Set the recaptcha action + */ + public setAction(action: string) { + this.setInputValue(action, 'IDToken1action'); + } +} + +export default ReCaptchaEnterpriseCallback; diff --git a/packages/journey-client/src/lib/callbacks/redirect-callback.test.ts b/packages/journey-client/src/lib/callbacks/redirect-callback.test.ts new file mode 100644 index 0000000000..1b5d576130 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/redirect-callback.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import RedirectCallback from './redirect-callback.js'; + +describe('RedirectCallback', () => { + const payload: Callback = { + type: callbackType.RedirectCallback, + output: [ + { + name: 'redirectUrl', + value: 'https://am.example.com/callback?param=value', + }, + ], + input: [], + }; + + it('should allow getting the redirect URL', () => { + const cb = new RedirectCallback(payload); + expect(cb.getRedirectUrl()).toBe('https://am.example.com/callback?param=value'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/redirect-callback.ts b/packages/journey-client/src/lib/callbacks/redirect-callback.ts new file mode 100644 index 0000000000..f90e0e180a --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/redirect-callback.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect an answer to a choice. + */ +class RedirectCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the redirect URL. + */ + public getRedirectUrl(): string { + return this.getOutputByName('redirectUrl', ''); + } +} + +export default RedirectCallback; diff --git a/packages/journey-client/src/lib/callbacks/select-idp-callback.test.ts b/packages/journey-client/src/lib/callbacks/select-idp-callback.test.ts new file mode 100644 index 0000000000..04e95d1943 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/select-idp-callback.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import SelectIdPCallback from './select-idp-callback.js'; + +describe('SelectIdPCallback', () => { + const payload: Callback = { + type: callbackType.SelectIdPCallback, + output: [ + { + name: 'providers', + value: [ + { provider: 'google', uiConfig: {} }, + { provider: 'facebook', uiConfig: {} }, + ], + }, + ], + input: [ + { + name: 'IDToken1', + value: '', + }, + ], + }; + + it('should allow getting the providers', () => { + const cb = new SelectIdPCallback(payload); + expect(cb.getProviders()).toHaveLength(2); + expect(cb.getProviders()[0].provider).toBe('google'); + }); + + it('should allow setting the provider', () => { + const cb = new SelectIdPCallback(payload); + cb.setProvider('facebook'); + expect(cb.getInputValue()).toBe('facebook'); + }); + + it('should throw an error for an invalid provider', () => { + const cb = new SelectIdPCallback(payload); + expect(() => cb.setProvider('twitter')).toThrow('"twitter" is not a valid choice'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/select-idp-callback.ts b/packages/journey-client/src/lib/callbacks/select-idp-callback.ts new file mode 100644 index 0000000000..f1c9dafa10 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/select-idp-callback.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +interface IdPValue { + provider: string; + uiConfig: { + [key: string]: string; + }; +} + +/** + * Represents a callback used to collect an answer to a choice. + */ +class SelectIdPCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the available providers. + */ + public getProviders(): IdPValue[] { + return this.getOutputByName('providers', []); + } + + /** + * Sets the provider by name. + */ + public setProvider(value: string): void { + const item = this.getProviders().find((item) => item.provider === value); + if (!item) { + throw new Error(`"${value}" is not a valid choice`); + } + this.setInputValue(item.provider); + } +} + +export default SelectIdPCallback; + +export type { IdPValue }; diff --git a/packages/journey-client/src/lib/callbacks/suspended-text-output-callback.test.ts b/packages/journey-client/src/lib/callbacks/suspended-text-output-callback.test.ts new file mode 100644 index 0000000000..5fde2f7392 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/suspended-text-output-callback.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import SuspendedTextOutputCallback from './suspended-text-output-callback.js'; + +describe('SuspendedTextOutputCallback', () => { + it('should instantiate correctly and inherit from TextOutputCallback', () => { + const payload: Callback = { + type: callbackType.SuspendedTextOutputCallback, + output: [ + { + name: 'message', + value: 'Suspended message', + }, + { + name: 'messageType', + value: '0', + }, + ], + input: [], + }; + const cb = new SuspendedTextOutputCallback(payload); + expect(cb.getMessage()).toBe('Suspended message'); + expect(cb.getMessageType()).toBe('0'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/suspended-text-output-callback.ts b/packages/journey-client/src/lib/callbacks/suspended-text-output-callback.ts new file mode 100644 index 0000000000..3057b739f2 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/suspended-text-output-callback.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import TextOutputCallback from './text-output-callback.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to display a message. + */ +class SuspendedTextOutputCallback extends TextOutputCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } +} + +export default SuspendedTextOutputCallback; diff --git a/packages/journey-client/src/lib/callbacks/terms-and-conditions-callback.test.ts b/packages/journey-client/src/lib/callbacks/terms-and-conditions-callback.test.ts new file mode 100644 index 0000000000..223b279793 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/terms-and-conditions-callback.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import TermsAndConditionsCallback from './terms-and-conditions-callback.js'; + +describe('TermsAndConditionsCallback', () => { + const date = new Date().toString(); + const payload: Callback = { + type: callbackType.TermsAndConditionsCallback, + output: [ + { + name: 'terms', + value: 'Lorem ipsum...', + }, + { + name: 'version', + value: '1.0', + }, + { + name: 'createDate', + value: date, + }, + ], + input: [ + { + name: 'IDToken1', + value: false, + }, + ], + }; + + it('should allow getting terms, version, and date', () => { + const cb = new TermsAndConditionsCallback(payload); + expect(cb.getTerms()).toBe('Lorem ipsum...'); + expect(cb.getVersion()).toBe('1.0'); + expect(cb.getCreateDate()).toEqual(new Date(date)); + }); + + it('should allow setting acceptance', () => { + const cb = new TermsAndConditionsCallback(payload); + cb.setAccepted(true); + expect(cb.getInputValue()).toBe(true); + cb.setAccepted(false); + expect(cb.getInputValue()).toBe(false); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/terms-and-conditions-callback.ts b/packages/journey-client/src/lib/callbacks/terms-and-conditions-callback.ts new file mode 100644 index 0000000000..7f04e34ac5 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/terms-and-conditions-callback.ts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect acceptance of terms and conditions. + */ +class TermsAndConditionsCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the terms and conditions content. + */ + public getTerms(): string { + return this.getOutputByName('terms', ''); + } + + /** + * Gets the version of the terms and conditions. + */ + public getVersion(): string { + return this.getOutputByName('version', ''); + } + + /** + * Gets the date of the terms and conditions. + */ + public getCreateDate(): Date { + const date = this.getOutputByName('createDate', ''); + return new Date(date); + } + + /** + * Sets the callback's acceptance. + */ + public setAccepted(accepted = true): void { + this.setInputValue(accepted); + } +} + +export default TermsAndConditionsCallback; diff --git a/packages/journey-client/src/lib/callbacks/text-input-callback.test.ts b/packages/journey-client/src/lib/callbacks/text-input-callback.test.ts new file mode 100644 index 0000000000..55e3e6759b --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/text-input-callback.test.ts @@ -0,0 +1,41 @@ +/* + * @forgerock/javascript-sdk + * + * attribute-input-callback.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; +import type { Callback } from '@forgerock/sdk-types'; +import TextInputCallback from './text-input-callback.js'; + +describe('TextInputCallback', () => { + const payload: Callback = { + type: callbackType.TextInputCallback, + output: [ + { + name: 'prompt', + value: 'Provide a nickname for this account', + }, + ], + input: [ + { + name: 'IDToken1', + value: '', + }, + ], + }; + + it('reads/writes basic properties', () => { + const cb = new TextInputCallback(payload); + + expect(cb.getPrompt()).toBe('Provide a nickname for this account'); + + cb.setInput('Test setting input'); + + expect(cb.getInputValue()).toBe('Test setting input'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/text-input-callback.ts b/packages/journey-client/src/lib/callbacks/text-input-callback.ts new file mode 100644 index 0000000000..8df288830a --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/text-input-callback.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to retrieve input from the user. + */ +class TextInputCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the callback's prompt. + */ + public getPrompt(): string { + return this.getOutputByName('prompt', ''); + } + + /** + * Sets the callback's input value. + */ + public setInput(input: string): void { + this.setInputValue(input); + } +} + +export default TextInputCallback; diff --git a/packages/journey-client/src/lib/callbacks/text-output-callback.test.ts b/packages/journey-client/src/lib/callbacks/text-output-callback.test.ts new file mode 100644 index 0000000000..e7e395eb28 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/text-output-callback.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Callback } from '@forgerock/sdk-types'; +import TextOutputCallback from './text-output-callback.js'; + +describe('TextOutputCallback', () => { + const payload: Callback = { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: 'This is a message', + }, + { + name: 'messageType', + value: '0', + }, + ], + input: [], + }; + + it('should allow getting the message and message type', () => { + const cb = new TextOutputCallback(payload); + expect(cb.getMessage()).toBe('This is a message'); + expect(cb.getMessageType()).toBe('0'); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/text-output-callback.ts b/packages/journey-client/src/lib/callbacks/text-output-callback.ts new file mode 100644 index 0000000000..8502db2354 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/text-output-callback.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to display a message. + */ +class TextOutputCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the message content. + */ + public getMessage(): string { + return this.getOutputByName('message', ''); + } + + /** + * Gets the message type. + */ + public getMessageType(): string { + return this.getOutputByName('messageType', ''); + } +} + +export default TextOutputCallback; diff --git a/packages/journey-client/src/lib/callbacks/validated-create-password-callback.test.ts b/packages/journey-client/src/lib/callbacks/validated-create-password-callback.test.ts new file mode 100644 index 0000000000..b717e3f8b0 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/validated-create-password-callback.test.ts @@ -0,0 +1,82 @@ +/* + * @forgerock/javascript-sdk + * + * validated-create-password-callback.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; +import type { Callback } from '@forgerock/sdk-types'; +import ValidatedCreatePasswordCallback from './validated-create-password-callback.js'; + +describe('ValidatedCreatePasswordCallback', () => { + const payload: Callback = { + type: callbackType.ValidatedCreatePasswordCallback, + output: [ + { + name: 'echoOn', + value: false, + }, + { + name: 'required', + value: true, + }, + { + name: 'policies', + value: { + policyRequirements: ['a', 'b'], + name: 'password', + policies: [], + }, + }, + { + name: 'failedPolicies', + value: [JSON.stringify({ failedPolicies: { c: 'c', d: 'd' } })], + }, + { + name: 'validateOnly', + value: false, + }, + { + name: 'prompt', + value: 'Password', + }, + ], + input: [ + { + name: 'IDToken2', + value: '', + }, + { + name: 'IDToken2validateOnly', + value: false, + }, + ], + _id: 1, + }; + + it('reads/writes basic properties with "validate only"', () => { + const cb = new ValidatedCreatePasswordCallback(payload); + cb.setPassword('abcd123'); + cb.setValidateOnly(true); + + expect(cb.getType()).toBe('ValidatedCreatePasswordCallback'); + expect(cb.getPrompt()).toBe('Password'); + expect(cb.isRequired()).toBe(true); + expect(cb.getPolicies().policyRequirements).toStrictEqual(['a', 'b']); + expect(cb.getFailedPolicies()).toStrictEqual([{ failedPolicies: { c: 'c', d: 'd' } }]); + if (!cb.payload.input) throw new Error('Input is not defined'); + expect(cb.payload.input[0].value).toBe('abcd123'); + expect(cb.payload.input[1].value).toBe(true); + }); + + it('writes validate only to `false` for submission', () => { + const cb = new ValidatedCreatePasswordCallback(payload); + cb.setValidateOnly(false); + if (!cb.payload.input) throw new Error('Input is not defined'); + expect(cb.payload.input[1].value).toBe(false); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/validated-create-password-callback.ts b/packages/journey-client/src/lib/callbacks/validated-create-password-callback.ts new file mode 100644 index 0000000000..f3098f78bd --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/validated-create-password-callback.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback, PolicyRequirement } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect a valid platform password. + */ +class ValidatedCreatePasswordCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the callback's failed policies. + */ + public getFailedPolicies(): PolicyRequirement[] { + const failedPolicies = this.getOutputByName( + 'failedPolicies', + [], + ) as unknown as string[]; + try { + return failedPolicies.map((v) => JSON.parse(v)) as PolicyRequirement[]; + } catch { + throw new Error( + 'Unable to parse "failed policies" from the ForgeRock server. The JSON within `ValidatedCreatePasswordCallback` was either malformed or missing.', + ); + } + } + + /** + * Gets the callback's applicable policies. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public getPolicies(): Record { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this.getOutputByName>('policies', {}); + } + + /** + * Gets the callback's prompt. + */ + public getPrompt(): string { + return this.getOutputByName('prompt', ''); + } + + /** + * Gets whether the password is required. + */ + public isRequired(): boolean { + return this.getOutputByName('required', false); + } + + /** + * Sets the callback's password. + */ + public setPassword(password: string): void { + this.setInputValue(password); + } + + /** + * Set if validating value only. + */ + public setValidateOnly(value: boolean): void { + this.setInputValue(value, /validateOnly/); + } +} + +export default ValidatedCreatePasswordCallback; diff --git a/packages/journey-client/src/lib/callbacks/validated-create-username-callback.test.ts b/packages/journey-client/src/lib/callbacks/validated-create-username-callback.test.ts new file mode 100644 index 0000000000..2a4036d01d --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/validated-create-username-callback.test.ts @@ -0,0 +1,82 @@ +/* + * @forgerock/javascript-sdk + * + * validated-create-username-callback.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; +import type { Callback } from '@forgerock/sdk-types'; +import ValidatedCreateUsernameCallback from './validated-create-username-callback.js'; + +describe('ValidatedCreateUsernameCallback', () => { + const payload: Callback = { + type: callbackType.ValidatedCreateUsernameCallback, + output: [ + { + name: 'echoOn', + value: false, + }, + { + name: 'required', + value: true, + }, + { + name: 'policies', + value: { + policyRequirements: ['a', 'b'], + name: 'username', + policies: [], + }, + }, + { + name: 'failedPolicies', + value: [JSON.stringify({ failedPolicies: { c: 'c', d: 'd' } })], + }, + { + name: 'validateOnly', + value: false, + }, + { + name: 'prompt', + value: 'Username', + }, + ], + input: [ + { + name: 'IDToken2', + value: '', + }, + { + name: 'IDToken2validateOnly', + value: false, + }, + ], + _id: 1, + }; + + it('reads/writes basic properties with "validate only"', () => { + const cb = new ValidatedCreateUsernameCallback(payload); + cb.setName('abcd123'); + cb.setValidateOnly(true); + + expect(cb.getType()).toBe('ValidatedCreateUsernameCallback'); + expect(cb.getPrompt()).toBe('Username'); + expect(cb.isRequired()).toBe(true); + expect(cb.getPolicies().policyRequirements).toStrictEqual(['a', 'b']); + expect(cb.getFailedPolicies()).toStrictEqual([{ failedPolicies: { c: 'c', d: 'd' } }]); + if (!cb.payload.input) throw new Error('Input is not defined'); + expect(cb.payload.input[0].value).toBe('abcd123'); + expect(cb.payload.input[1].value).toBe(true); + }); + + it('writes validate only to `false` for submission', () => { + const cb = new ValidatedCreateUsernameCallback(payload); + cb.setValidateOnly(false); + if (!cb.payload.input) throw new Error('Input is not defined'); + expect(cb.payload.input[1].value).toBe(false); + }); +}); diff --git a/packages/journey-client/src/lib/callbacks/validated-create-username-callback.ts b/packages/journey-client/src/lib/callbacks/validated-create-username-callback.ts new file mode 100644 index 0000000000..f7f0d32dd6 --- /dev/null +++ b/packages/journey-client/src/lib/callbacks/validated-create-username-callback.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyCallback from './index.js'; +import type { Callback, PolicyRequirement } from '@forgerock/sdk-types'; + +/** + * Represents a callback used to collect a valid platform username. + */ +class ValidatedCreateUsernameCallback extends JourneyCallback { + /** + * @param payload The raw payload returned by OpenAM + */ + constructor(public override payload: Callback) { + super(payload); + } + + /** + * Gets the callback's prompt. + */ + public getPrompt(): string { + return this.getOutputByName('prompt', ''); + } + + /** + * Gets the callback's failed policies. + */ + public getFailedPolicies(): PolicyRequirement[] { + const failedPolicies = this.getOutputByName( + 'failedPolicies', + [], + ) as unknown as string[]; + try { + return failedPolicies.map((v) => JSON.parse(v)) as PolicyRequirement[]; + } catch { + throw new Error( + 'Unable to parse "failed policies" from the ForgeRock server. The JSON within `ValidatedCreateUsernameCallback` was either malformed or missing.', + ); + } + } + + /** + * Gets the callback's applicable policies. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public getPolicies(): Record { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this.getOutputByName>('policies', {}); + } + + /** + * Gets whether the username is required. + */ + public isRequired(): boolean { + return this.getOutputByName('required', false); + } + + /** + * Sets the callback's username. + */ + public setName(name: string): void { + this.setInputValue(name); + } + + /** + * Set if validating value only. + */ + public setValidateOnly(value: boolean): void { + this.setInputValue(value, /validateOnly/); + } +} + +export default ValidatedCreateUsernameCallback; diff --git a/packages/journey-client/src/lib/config.types.ts b/packages/journey-client/src/lib/config.types.ts new file mode 100644 index 0000000000..3fc367b8c6 --- /dev/null +++ b/packages/journey-client/src/lib/config.types.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import type { LegacyConfigOptions, StepOptions } from '@forgerock/sdk-types'; + +export interface JourneyClientConfig extends LegacyConfigOptions { + query?: Record; + // Add any journey-specific config options here +} + +export type { StepOptions }; diff --git a/packages/journey-client/src/lib/interfaces.ts b/packages/journey-client/src/lib/interfaces.ts new file mode 100644 index 0000000000..042e0cb194 --- /dev/null +++ b/packages/journey-client/src/lib/interfaces.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import type { StepOptions, Step } from '@forgerock/sdk-types'; + +export interface NextOptions { + step: Step; + options?: StepOptions; +} diff --git a/packages/journey-client/src/lib/journey-client.test.ts b/packages/journey-client/src/lib/journey-client.test.ts new file mode 100644 index 0000000000..bc40675e64 --- /dev/null +++ b/packages/journey-client/src/lib/journey-client.test.ts @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { journey } from './journey-client.js'; +import { createJourneyStep } from './journey-step.utils.js'; +import { JourneyClientConfig } from './config.types.js'; +import { callbackType, Step } from '@forgerock/sdk-types'; + +// Create a singleton mock instance for storage +const mockStorageInstance = { + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), +}; + +// Mock dependencies with side effects +vi.mock('@forgerock/storage', () => ({ + createStorage: vi.fn(() => mockStorageInstance), +})); + +vi.mock('./journey-device/index.js', () => ({ + default: vi.fn(() => ({ + getProfile: vi.fn().mockResolvedValue({}), + })), +})); + +// Mock the fetch API to control responses +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const mockConfig: JourneyClientConfig = { + serverConfig: { + baseUrl: 'https://test.com', + }, + realmPath: 'root', +}; + +describe('journey-client', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test('should initialize and return a client object with all methods', async () => { + const client = await journey({ config: mockConfig }); + expect(client).toBeDefined(); + expect(client.start).toBeInstanceOf(Function); + expect(client.next).toBeInstanceOf(Function); + expect(client.redirect).toBeInstanceOf(Function); + expect(client.resume).toBeInstanceOf(Function); + }); + + test('start() should fetch and return the first step', async () => { + const mockStepResponse: Step = { authId: 'test-auth-id', callbacks: [] }; + mockFetch.mockResolvedValue(new Response(JSON.stringify(mockStepResponse))); + + const client = await journey({ config: mockConfig }); + const step = await client.start(); + expect(step).toBeDefined(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const request = mockFetch.mock.calls[0][0] as Request; + // TODO: This should be /journeys?_action=start, but the current implementation calls /authenticate + expect(request.url).toBe('https://test.com/json/realms/root/authenticate'); + expect(step).toHaveProperty('type', 'Step'); + expect(step && step.payload).toEqual(mockStepResponse); + }); + + test('next() should send the current step and return the next step', async () => { + const initialStepPayload: Step = { + authId: 'test-auth-id', + callbacks: [ + { + type: callbackType.NameCallback, + input: [{ name: 'IDToken1', value: 'test-user' }], + output: [], + }, + ], + }; + const nextStepPayload: Step = { + authId: 'test-auth-id', + callbacks: [ + { + type: callbackType.PasswordCallback, + input: [{ name: 'IDToken2', value: 'test-password' }], + output: [], + }, + ], + }; + const initialStep = initialStepPayload; + + mockFetch.mockResolvedValue(new Response(JSON.stringify(nextStepPayload))); + + const client = await journey({ config: mockConfig }); + const nextStep = await client.next(initialStep, {}); + expect(nextStep).toBeDefined(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const request = mockFetch.mock.calls[0][0] as Request; + // TODO: This should be /journeys?_action=next, but the current implementation calls /authenticate + expect(request.url).toBe('https://test.com/json/realms/root/authenticate'); + expect(request.method).toBe('POST'); + expect(await request.json()).toEqual(initialStep); + expect(nextStep).toHaveProperty('type', 'Step'); + expect(nextStep && nextStep.payload).toEqual(nextStepPayload); + }); + + test('redirect() should store the step and call location.assign', async () => { + const mockStepPayload: Step = { + callbacks: [ + { + type: callbackType.RedirectCallback, + input: [], + output: [{ name: 'redirectUrl', value: 'https://sso.com/redirect' }], + }, + ], + }; + const step = createJourneyStep(mockStepPayload); + + const assignMock = vi.fn(); + const locationSpy = vi.spyOn(window, 'location', 'get').mockReturnValue({ + ...window.location, + assign: assignMock, + }); + + const client = await journey({ config: mockConfig }); + await client.redirect(step); + + expect(mockStorageInstance.set).toHaveBeenCalledWith({ step: step.payload }); + expect(assignMock).toHaveBeenCalledWith('https://sso.com/redirect'); + + locationSpy.mockRestore(); + }); + + describe('resume()', () => { + test('should call next() with URL params when a previous step is in storage', async () => { + const previousStepPayload: Step = { + callbacks: [{ type: callbackType.RedirectCallback, input: [], output: [] }], + }; + mockStorageInstance.get.mockResolvedValue({ step: previousStepPayload }); + + const nextStepPayload: Step = { authId: 'test-auth-id', callbacks: [] }; + mockFetch.mockResolvedValue(new Response(JSON.stringify(nextStepPayload))); + + const client = await journey({ config: mockConfig }); + const resumeUrl = 'https://app.com/callback?code=123&state=abc'; + + const step = await client.resume(resumeUrl, {}); + expect(step).toBeDefined(); + + expect(mockStorageInstance.get).toHaveBeenCalledTimes(1); + expect(mockStorageInstance.remove).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + const request = mockFetch.mock.calls[0][0] as Request; + + // TODO: This should be /journeys?_action=next, but the current implementation calls /authenticate + const url = new URL(request.url); + expect(url.origin + url.pathname).toBe('https://test.com/json/realms/root/authenticate'); + expect(url.searchParams.get('code')).toBe('123'); + expect(url.searchParams.get('state')).toBe('abc'); + + expect(request.method).toBe('POST'); + expect(await request.json()).toEqual(previousStepPayload); + expect(step).toHaveProperty('type', 'Step'); + expect(step && step.payload).toEqual(nextStepPayload); + }); + + test('should correctly resume with a plain Step object from storage', async () => { + const plainStepPayload: Step = { + callbacks: [ + { type: callbackType.TextOutputCallback, output: [{ name: 'message', value: 'Hello' }] }, + ], + stage: 'testStage', + }; + mockStorageInstance.get.mockResolvedValue({ step: plainStepPayload }); + + const nextStepPayload: Step = { authId: 'test-auth-id', callbacks: [] }; + mockFetch.mockResolvedValue(new Response(JSON.stringify(nextStepPayload))); + + const client = await journey({ config: mockConfig }); + const resumeUrl = 'https://app.com/callback?code=123&state=abc'; + + const step = await client.resume(resumeUrl, {}); + expect(step).toBeDefined(); + + expect(mockStorageInstance.get).toHaveBeenCalledTimes(1); + expect(mockStorageInstance.remove).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const request = mockFetch.mock.calls[0][0] as Request; + expect(request.method).toBe('POST'); + expect(await request.json()).toEqual(plainStepPayload); // Expect the plain payload to be sent + expect(step).toHaveProperty('type', 'Step'); // The returned step should still be an JourneyStep instance + expect(step && step.payload).toEqual(nextStepPayload); + }); + + test('should throw an error if a previous step is required but not found', async () => { + mockStorageInstance.get.mockResolvedValue(undefined); + + const client = await journey({ config: mockConfig }); + const resumeUrl = 'https://app.com/callback?code=123&state=abc'; + + await expect(client.resume(resumeUrl)).rejects.toThrow( + 'Error: previous step information not found in storage for resume operation.', + ); + expect(mockStorageInstance.get).toHaveBeenCalledTimes(1); + expect(mockStorageInstance.remove).toHaveBeenCalledTimes(1); + }); + + test('should call start() with URL params when no previous step is required', async () => { + mockStorageInstance.get.mockResolvedValue(undefined); + + const mockStepResponse: Step = { authId: 'test-auth-id', callbacks: [] }; + mockFetch.mockResolvedValue(new Response(JSON.stringify(mockStepResponse))); + + const client = await journey({ config: mockConfig }); + const resumeUrl = 'https://app.com/callback?foo=bar'; + + const step = await client.resume(resumeUrl, {}); + expect(step).toBeDefined(); + + expect(mockStorageInstance.get).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + const request = mockFetch.mock.calls[0][0] as Request; + + // TODO: This should be /journeys?_action=start, but the current implementation calls /authenticate + const url = new URL(request.url); + expect(url.origin + url.pathname).toBe('https://test.com/json/realms/root/authenticate'); + expect(step).toHaveProperty('type', 'Step'); + expect(step && step.payload).toEqual(mockStepResponse); + }); + }); +}); diff --git a/packages/journey-client/src/lib/journey-client.ts b/packages/journey-client/src/lib/journey-client.ts new file mode 100644 index 0000000000..348ec07e1b --- /dev/null +++ b/packages/journey-client/src/lib/journey-client.ts @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import type { JourneyStep } from './journey-step.utils.js'; +import { createJourneyStore } from './journey.store.js'; +import { JourneyClientConfig, StepOptions } from './config.types.js'; +import { journeyApi } from './journey.api.js'; +import { setConfig } from './journey.slice.js'; +import { createStorage } from '@forgerock/storage'; +import { GenericError, callbackType, type Step } from '@forgerock/sdk-types'; +import RedirectCallback from './callbacks/redirect-callback.js'; +import { logger as loggerFn, LogLevel, CustomLogger } from '@forgerock/sdk-logger'; +import { RequestMiddleware } from '@forgerock/sdk-request-middleware'; +import { createJourneyObject } from './journey.utils.js'; + +export async function journey({ + config, + requestMiddleware, + logger, +}: { + config: JourneyClientConfig; + requestMiddleware?: RequestMiddleware[]; + logger?: { + level: LogLevel; + custom?: CustomLogger; + }; +}) { + const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); + + const store = createJourneyStore({ requestMiddleware, logger: log, config }); + store.dispatch(setConfig(config)); + + const stepStorage = createStorage<{ step: Step }>({ + type: 'sessionStorage', + name: 'journey-step', + }); + + const self = { + start: async (options?: StepOptions) => { + const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); + return data ? createJourneyObject(data) : undefined; + }, + + /** + * Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey. + * The `step` to be submitted is provided within the `options` object. + * + * @param options An object containing the current Step payload and optional StepOptions. + * @returns A Promise that resolves to the next JourneyStep in the journey, or undefined if the journey ends. + */ + next: async (step: Step, options?: StepOptions) => { + const { data } = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options })); + return data ? createJourneyObject(data) : undefined; + }, + + redirect: async (step: JourneyStep) => { + const cb = step.getCallbackOfType(callbackType.RedirectCallback) as RedirectCallback; + if (!cb) { + throw new Error('RedirectCallback not found on step'); + } + const redirectUrl = cb.getRedirectUrl(); + const err = await stepStorage.set({ step: step.payload }); + if (err && (err as GenericError).type) { + log.warn('Failed to persist step before redirect', err); + } + window.location.assign(redirectUrl); + }, + + resume: async ( + url: string, + options?: StepOptions, + ): Promise | undefined> => { + const parsedUrl = new URL(url); + const code = parsedUrl.searchParams.get('code'); + const state = parsedUrl.searchParams.get('state'); + const form_post_entry = parsedUrl.searchParams.get('form_post_entry'); + const responsekey = parsedUrl.searchParams.get('responsekey'); + + let previousStep: Step | undefined; // Declare previousStep here + + function requiresPreviousStep() { + return (code && state) || form_post_entry || responsekey; + } + + // Type guard for GenericError (assuming GenericError has 'error' and 'message' properties) + function isGenericError(obj: unknown): obj is GenericError { + return typeof obj === 'object' && obj !== null && 'error' in obj && 'message' in obj; + } + + // Type guard for { step: JourneyStep } + function isStoredStep(obj: unknown): obj is { step: Step } { + return ( + typeof obj === 'object' && + obj !== null && + 'step' in obj && + typeof (obj as any).step === 'object' + ); + } + + if (requiresPreviousStep()) { + const stored = await stepStorage.get(); + + if (stored) { + if (isGenericError(stored)) { + // If the stored item is a GenericError, it means something went wrong during storage/retrieval + // or the previous step was an error. + throw new Error(`Error retrieving previous step: ${stored.message || stored.error}`); + } else if (isStoredStep(stored)) { + previousStep = stored.step; + } + } + await stepStorage.remove(); + + if (!previousStep) { + throw new Error( + 'Error: previous step information not found in storage for resume operation.', + ); + } + } + + const nextOptions = { + ...options, + query: { + ...(options && options.query), // Spread options.query first + ...(code && { code }), + ...(state && { state }), + ...(form_post_entry && { form_post_entry }), + ...(responsekey && { responsekey }), + }, + }; + + if (previousStep) { + return await self.next(previousStep, nextOptions); + } else { + return await self.start(nextOptions); + } + }, + }; + return self; +} diff --git a/packages/journey-client/src/lib/journey-device/defaults.ts b/packages/journey-client/src/lib/journey-device/defaults.ts new file mode 100644 index 0000000000..c239c0d32a --- /dev/null +++ b/packages/journey-client/src/lib/journey-device/defaults.ts @@ -0,0 +1,90 @@ +/* + * @forgerock/javascript-sdk + * + * defaults.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +const browserProps = [ + 'userAgent', + 'appName', + 'appCodeName', + 'appVersion', + 'appMinorVersion', + 'buildID', + 'product', + 'productSub', + 'vendor', + 'vendorSub', + 'browserLanguage', +]; +const configurableCategories = [ + 'fontNames', + 'displayProps', + 'browserProps', + 'hardwareProps', + 'platformProps', +]; +const delay = 30 * 1000; +const devicePlatforms = { + mac: ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'], + windows: ['Win32', 'Win64', 'Windows', 'WinCE'], + ios: ['iPhone', 'iPad', 'iPod'], +}; +const displayProps = ['width', 'height', 'pixelDepth', 'orientation.angle']; +const fontNames = [ + 'cursive', + 'monospace', + 'serif', + 'sans-serif', + 'fantasy', + 'Arial', + 'Arial Black', + 'Arial Narrow', + 'Arial Rounded MT Bold', + 'Bookman Old Style', + 'Bradley Hand ITC', + 'Century', + 'Century Gothic', + 'Comic Sans MS', + 'Courier', + 'Courier New', + 'Georgia', + 'Gentium', + 'Impact', + 'King', + 'Lucida Console', + 'Lalit', + 'Modena', + 'Monotype Corsiva', + 'Papyrus', + 'Tahoma', + 'TeX', + 'Times', + 'Times New Roman', + 'Trebuchet MS', + 'Verdana', + 'Verona', +]; +const hardwareProps = [ + 'cpuClass', + 'deviceMemory', + 'hardwareConcurrency', + 'maxTouchPoints', + 'oscpu', +]; +const platformProps = ['language', 'platform', 'userLanguage', 'systemLanguage']; + +export { + browserProps, + configurableCategories, + delay, + devicePlatforms, + displayProps, + fontNames, + hardwareProps, + platformProps, +}; diff --git a/packages/journey-client/src/lib/journey-device/device-profile.mock.data.ts b/packages/journey-client/src/lib/journey-device/device-profile.mock.data.ts new file mode 100644 index 0000000000..680d277f39 --- /dev/null +++ b/packages/journey-client/src/lib/journey-device/device-profile.mock.data.ts @@ -0,0 +1,122 @@ +/* + * @forgerock/javascript-sdk + * + * device-profile.mock.data.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +const expectedJsdom = { + identifier: '', + metadata: { + hardware: { + display: { + width: 0, + height: 0, + pixelDepth: 24, + angle: '', + }, + cpuClass: null, + deviceMemory: null, + hardwareConcurrency: 16, + maxTouchPoints: null, + oscpu: null, + }, + browser: { + appName: 'Netscape', + userAgent: 'Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/16.2.2', + appCodeName: 'Mozilla', + appVersion: '4.0', + appMinorVersion: null, + buildID: null, + product: 'Gecko', + productSub: '20030107', + vendor: 'Apple Computer, Inc.', + vendorSub: '', + browserLanguage: null, + plugins: '', + }, + platform: { + deviceName: 'Unknown (Browser)', + fonts: '', + language: 'en-US', + platform: '', + userLanguage: null, + systemLanguage: null, + timezone: 300, + }, + }, +}; + +const expectedJsdomWithoutDisplay = { + identifier: '', + metadata: { + hardware: { + display: {}, + cpuClass: null, + deviceMemory: null, + hardwareConcurrency: 16, + maxTouchPoints: null, + oscpu: null, + }, + browser: { + appName: 'Netscape', + userAgent: 'Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/16.2.2', + appCodeName: 'Mozilla', + appVersion: '4.0', + appMinorVersion: null, + buildID: null, + product: 'Gecko', + productSub: '20030107', + vendor: 'Apple Computer, Inc.', + vendorSub: '', + browserLanguage: null, + plugins: '', + }, + platform: { + deviceName: 'Unknown (Browser)', + fonts: '', + language: 'en-US', + platform: '', + userLanguage: null, + systemLanguage: null, + timezone: 300, + }, + }, +}; + +const expectedJsdomWithNarrowedBrowserProps = { + identifier: '', + metadata: { + hardware: { + display: { + width: 0, + height: 0, + pixelDepth: 24, + angle: '', + }, + cpuClass: null, + deviceMemory: null, + hardwareConcurrency: 16, + maxTouchPoints: null, + oscpu: null, + }, + browser: { + userAgent: 'Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/16.2.2', + plugins: '', + }, + platform: { + deviceName: 'Unknown (Browser)', + fonts: '', + language: 'en-US', + platform: '', + userLanguage: null, + systemLanguage: null, + timezone: 300, + }, + }, +}; + +export { expectedJsdom, expectedJsdomWithoutDisplay, expectedJsdomWithNarrowedBrowserProps }; diff --git a/packages/journey-client/src/lib/journey-device/device-profile.test.ts b/packages/journey-client/src/lib/journey-device/device-profile.test.ts new file mode 100644 index 0000000000..0bbcf1ddfa --- /dev/null +++ b/packages/journey-client/src/lib/journey-device/device-profile.test.ts @@ -0,0 +1,127 @@ +/* + * @forgerock/javascript-sdk + * + * device-profile.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { vi, expect, describe, it, afterEach, beforeEach, SpyInstance } from 'vitest'; +import { JourneyDevice } from './index.js'; + +// Patch window.crypto.getRandomValues to return Uint32Array for compatibility +Object.defineProperty(window, 'crypto', { + writable: true, + value: { + getRandomValues: vi.fn().mockImplementation((arr: Uint32Array) => { + arr[0] = 714524572; + arr[1] = 2799534390; + arr[2] = 3707617532; + return arr; + }), + }, +}); + +describe('Test DeviceProfile', () => { + it('should return basic metadata', async () => { + const device = new JourneyDevice(); + const profile = await device.getProfile({ + location: false, + metadata: true, + }); + if (!profile.metadata) throw new Error('Metadata is not defined'); + const userAgent = profile.metadata.browser.userAgent as string; + const appName = profile.metadata.browser.appName as string; + const appVersion = profile.metadata.browser.appVersion as string; + const vendor = profile.metadata.browser.vendor as string; + const display = profile.metadata.hardware.display; + const deviceName = profile.metadata.platform.deviceName as string; + expect(userAgent.includes('jsdom')).toBeTruthy(); + expect(appName).toBe('Netscape'); + expect(appVersion).toBe('4.0'); + expect(vendor).toBe('Apple Computer, Inc.'); + expect(display).toHaveProperty('width'); + expect(display).toHaveProperty('height'); + expect(deviceName.length).toBeGreaterThan(1); + }); + + it('should return metadata without any display props', async () => { + const device = new JourneyDevice({ displayProps: [] }); + const profile = await device.getProfile({ + location: false, + metadata: true, + }); + if (!profile.metadata) throw new Error('Metadata is not defined'); + const userAgent = profile.metadata.browser.userAgent as string; + const display = profile.metadata.hardware.display; + const deviceName = profile.metadata.platform.deviceName as string; + expect(userAgent.length).toBeGreaterThan(1); + expect(display.width).toBeFalsy(); + expect(display.height).toBeFalsy(); + expect(deviceName.length).toBeGreaterThan(1); + }); + + it('should return metadata according to narrowed browser props', async () => { + const device = new JourneyDevice({ browserProps: ['userAgent'] }); + const profile = await device.getProfile({ + location: false, + metadata: true, + }); + if (!profile.metadata) throw new Error('Metadata is not defined'); + const userAgent = profile.metadata.browser.userAgent as string; + const appName = profile.metadata.browser.appName as string; + const appVersion = profile.metadata.browser.appVersion as string; + const vendor = profile.metadata.browser.vendor as string; + const display = profile.metadata.hardware.display; + const deviceName = profile.metadata.platform.deviceName as string; + expect(userAgent.includes('jsdom')).toBeTruthy(); + expect(appName).toBeFalsy(); + expect(appVersion).toBeFalsy(); + expect(vendor).toBeFalsy(); + expect(display).toHaveProperty('width'); + expect(display).toHaveProperty('height'); + expect(deviceName.length).toBeGreaterThan(1); + }); + + describe('logLevel tests', () => { + let warnSpy: SpyInstance; + const originalNavigator = global.navigator; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete global.navigator; + }); + + afterEach(() => { + warnSpy.mockRestore(); + global.navigator = originalNavigator; + }); + + it('should not log warnings if logLevel is "error"', () => { + const device = new JourneyDevice(undefined, 'error'); + device.getBrowserMeta(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should log warnings if logLevel is "warn"', () => { + const device = new JourneyDevice(undefined, 'warn'); + device.getBrowserMeta(); + expect(warnSpy).toHaveBeenCalledWith( + 'Cannot collect browser metadata. navigator is not defined.', + ); + }); + }); + + it('should use custom prefix for device identifier', () => { + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + const device = new JourneyDevice(undefined, 'info', 'my-custom-prefix'); + device.getIdentifier(); + + expect(setItemSpy).toHaveBeenCalledWith('my-custom-prefix-DeviceID', expect.any(String)); + + setItemSpy.mockRestore(); + }); +}); diff --git a/packages/journey-client/src/lib/journey-device/index.ts b/packages/journey-client/src/lib/journey-device/index.ts new file mode 100644 index 0000000000..bd57cd1737 --- /dev/null +++ b/packages/journey-client/src/lib/journey-device/index.ts @@ -0,0 +1,285 @@ +/* + * @forgerock/javascript-sdk + * + * index.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { + browserProps, + configurableCategories, + delay, + devicePlatforms, + displayProps, + fontNames, + hardwareProps, + platformProps, +} from './defaults.js'; +import type { + BaseProfileConfig, + Category, + CollectParameters, + DeviceProfileData, + Geolocation, + ProfileConfigOptions, +} from './interfaces.js'; +import { reduceToObject, reduceToString } from '@forgerock/sdk-utilities'; +import { logger as loggerFn } from '@forgerock/sdk-logger'; +import type { LogLevel } from '@forgerock/sdk-logger'; +type Logger = ReturnType; + +/** + * @class JourneyDevice - Collects user device metadata. + * + * Example: + * + * ```js + * // Instantiate new device object (w/optional config, if needed) + * const device = new forgerock.JourneyDevice({ + * // optional configuration + * }); + * + * // Call getProfile with required argument obj of boolean properties + * // of location and metadata + * const profile = await device.getProfile({ + * location: isLocationRequired, + * metadata: isMetadataRequired, + * }); + * ``` + */ +class JourneyDevice { + private config: BaseProfileConfig; + private logger: Logger; + private prefix: string; + + constructor( + configOptions?: ProfileConfigOptions, + logLevel: LogLevel = 'info', + prefix = 'forgerock', + ) { + this.logger = loggerFn({ level: logLevel }); + this.prefix = prefix; + this.config = { + fontNames, + devicePlatforms, + displayProps, + browserProps, + hardwareProps, + platformProps, + }; + + if (configOptions) { + Object.keys(configOptions).forEach((key: string) => { + if (!configurableCategories.includes(key)) { + throw new Error('Device profile configuration category does not exist.'); + } + this.config[key as Category] = configOptions[key as Category] as string[]; + }); + } + } + + public getBrowserMeta(): Record { + if (typeof navigator === 'undefined') { + this.logger.warn('Cannot collect browser metadata. navigator is not defined.'); + return {}; + } + return reduceToObject( + this.config.browserProps, + navigator as unknown as Record, + ); + } + + public getBrowserPluginsNames(): string { + if (!(typeof navigator !== 'undefined' && navigator.plugins)) { + this.logger.warn( + 'Cannot collect browser plugin information. navigator.plugins is not defined.', + ); + return ''; + } + return reduceToString( + Object.keys(navigator.plugins), + navigator.plugins as unknown as Record, + ); + } + + public getDeviceName(): string { + if (typeof navigator === 'undefined') { + this.logger.warn('Cannot collect device name. navigator is not defined.'); + return ''; + } + const userAgent = navigator.userAgent; + const platform = navigator.platform; + + switch (true) { + case this.config.devicePlatforms.mac.includes(platform): + return 'Mac (Browser)'; + case this.config.devicePlatforms.ios.includes(platform): + return `${platform} (Browser)`; + case this.config.devicePlatforms.windows.includes(platform): + return 'Windows (Browser)'; + case /Android/.test(platform) || /Android/.test(userAgent): + return 'Android (Browser)'; + case /CrOS/.test(userAgent) || /Chromebook/.test(userAgent): + return 'Chrome OS (Browser)'; + case /Linux/.test(platform): + return 'Linux (Browser)'; + default: + return `${platform || 'Unknown'} (Browser)`; + } + } + + public getDisplayMeta(): { [key: string]: string | number | null } { + if (typeof screen === 'undefined') { + this.logger.warn('Cannot collect screen information. screen is not defined.'); + return {}; + } + return reduceToObject(this.config.displayProps, screen as unknown as Record); + } + + public getHardwareMeta(): Record { + if (typeof navigator === 'undefined') { + this.logger.warn('Cannot collect OS metadata. Navigator is not defined.'); + return {}; + } + return reduceToObject( + this.config.hardwareProps, + navigator as unknown as Record, + ); + } + + public getIdentifier(): string { + const storageKey = `${this.prefix}-DeviceID`; + + if (!(typeof globalThis.crypto !== 'undefined' && globalThis.crypto.getRandomValues)) { + this.logger.warn( + 'Cannot generate profile ID. Crypto and/or getRandomValues is not supported.', + ); + return ''; + } + if (!localStorage) { + this.logger.warn('Cannot store profile ID. localStorage is not supported.'); + return ''; + } + let id = localStorage.getItem(storageKey); + if (!id) { + // generate ID, 3 sections of random numbers: "714524572-2799534390-3707617532" + id = globalThis.crypto.getRandomValues(new Uint32Array(3)).join('-'); + localStorage.setItem(storageKey, id); + } + return id; + } + + public getInstalledFonts(): string { + if (typeof document === 'undefined') { + this.logger.warn('Cannot collect font data. Global document object is undefined.'); + return ''; + } + const canvas = document.createElement('canvas'); + if (!canvas) { + this.logger.warn('Cannot collect font data. Browser does not support canvas element'); + return ''; + } + const context = canvas.getContext && canvas.getContext('2d'); + + if (!context) { + this.logger.warn('Cannot collect font data. Browser does not support 2d canvas context'); + return ''; + } + const text = 'abcdefghi0123456789'; + context.font = '72px Comic Sans'; + const baseWidth = context.measureText(text).width; + + const installedFonts = this.config.fontNames.reduce((prev: string, curr: string) => { + context.font = `72px ${curr}, Comic Sans`; + const newWidth = context.measureText(text).width; + + if (newWidth !== baseWidth) { + prev = `${prev}${curr};`; + } + return prev; + }, ''); + + return installedFonts; + } + + public async getLocationCoordinates(): Promise> { + if (!(typeof navigator !== 'undefined' && navigator.geolocation)) { + this.logger.warn( + 'Cannot collect geolocation information. navigator.geolocation is not defined.', + ); + return Promise.resolve({}); + } + return new Promise((resolve) => { + navigator.geolocation.getCurrentPosition( + (position) => + resolve({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }), + () => { + this.logger.warn('Cannot collect geolocation information. Geolocation API error.'); + resolve({}); + }, + { + enableHighAccuracy: true, + timeout: delay, + maximumAge: 0, + }, + ); + }); + } + + public getOSMeta(): Record { + if (typeof navigator === 'undefined') { + this.logger.warn('Cannot collect OS metadata. navigator is not defined.'); + return {}; + } + return reduceToObject( + this.config.platformProps, + navigator as unknown as Record, + ); + } + + public getTimezoneOffset(): number | null { + try { + return new Date().getTimezoneOffset(); + } catch { + this.logger.warn('Cannot collect timezone information. getTimezoneOffset is not defined.'); + return null; + } + } + + public async getProfile({ location, metadata }: CollectParameters): Promise { + const profile: DeviceProfileData = { + identifier: this.getIdentifier(), + }; + + if (metadata) { + profile.metadata = { + hardware: { + ...this.getHardwareMeta(), + display: this.getDisplayMeta(), + }, + browser: { + ...this.getBrowserMeta(), + plugins: this.getBrowserPluginsNames(), + }, + platform: { + ...this.getOSMeta(), + deviceName: this.getDeviceName(), + fonts: this.getInstalledFonts(), + timezone: this.getTimezoneOffset(), + }, + }; + } + if (location) { + profile.location = await this.getLocationCoordinates(); + } + return profile; + } +} + +export { JourneyDevice }; diff --git a/packages/journey-client/src/lib/journey-device/interfaces.ts b/packages/journey-client/src/lib/journey-device/interfaces.ts new file mode 100644 index 0000000000..edbdc9c3a7 --- /dev/null +++ b/packages/journey-client/src/lib/journey-device/interfaces.ts @@ -0,0 +1,67 @@ +/* + * @forgerock/javascript-sdk + * + * interfaces.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +type Category = 'fontNames' | 'displayProps' | 'browserProps' | 'hardwareProps' | 'platformProps'; + +interface CollectParameters { + location: boolean; + metadata: boolean; +} + +interface DeviceProfileData { + identifier: string; + metadata?: { + hardware: { + display: { + [key: string]: string | number | null; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; + }; + browser: { + [key: string]: string | number | null; + }; + platform: { + [key: string]: string | number | null; + }; + }; + location?: Geolocation | Record; +} + +interface Geolocation { + latitude: number; + longitude: number; +} + +interface BaseProfileConfig { + fontNames: string[]; + devicePlatforms: { + mac: string[]; + windows: string[]; + ios: string[]; + }; + displayProps: string[]; + browserProps: string[]; + hardwareProps: string[]; + platformProps: string[]; +} + +interface ProfileConfigOptions { + [key: string]: string[]; +} + +export type { + BaseProfileConfig, + Category, + CollectParameters, + DeviceProfileData, + Geolocation, + ProfileConfigOptions, +}; diff --git a/packages/journey-client/src/lib/journey-device/sample-profile.json b/packages/journey-client/src/lib/journey-device/sample-profile.json new file mode 100644 index 0000000000..5cd568f245 --- /dev/null +++ b/packages/journey-client/src/lib/journey-device/sample-profile.json @@ -0,0 +1,45 @@ +{ + "identifier": "714524572-2799534390-3707617532", + "metadata": { + "hardware": { + "cpuClass": null, + "deviceMemory": 8, + "hardwareConcurrency": 16, + "maxTouchPoints": 0, + "oscpu": null, + "display": { + "width": 1080, + "height": 1920, + "pixelDepth": 24, + "angle": 270 + } + }, + "browser": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36 Edg/80.0.361.111", + "appName": "Netscape", + "appCodeName": "Mozilla", + "appVersion": "5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36 Edg/80.0.361.111", + "appMinorVersion": null, + "buildID": null, + "product": "Gecko", + "productSub": "20030107", + "vendor": "Google Inc.", + "vendorSub": "", + "browserLanguage": null, + "plugins": "internal-pdf-viewer;mhjfbmdgcfjbbpaeojofohoefgiehjai;internal-nacl-plugin;" + }, + "platform": { + "deviceName": "Mac (Browser)", + "language": "en-US", + "platform": "MacIntel", + "userLanguage": null, + "systemLanguage": null, + "fonts": "cursive;monospace;sans-serif;fantasy;Arial;Arial Black;Arial Narrow;Arial Rounded MT Bold;Comic Sans MS;Courier;Courier New;Georgia;Impact;Papyrus;Tahoma;Trebuchet MS;Verdana;", + "timezone": 300 + } + }, + "location": { + "latitude": 27.766237, + "longitude": -94.889905 + } +} diff --git a/packages/journey-client/src/lib/journey-login-failure.utils.test.ts b/packages/journey-client/src/lib/journey-login-failure.utils.test.ts new file mode 100644 index 0000000000..09fdfa9f56 --- /dev/null +++ b/packages/journey-client/src/lib/journey-login-failure.utils.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, expect, it } from 'vitest'; +import { createJourneyLoginFailure } from './journey-login-failure.utils.js'; +import { StepType } from '@forgerock/sdk-types'; + +describe('createJourneyLoginFailure', () => { + it('should create a JourneyLoginFailure object', () => { + const step = { + code: 400, + detail: { failureUrl: 'failure-url' }, + message: 'Invalid credentials', + reason: 'INVALID_CREDENTIALS', + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const journeyLoginFailure = createJourneyLoginFailure(step); + + expect(journeyLoginFailure.type).toBe(StepType.LoginFailure); + expect(journeyLoginFailure.payload).toEqual(step); + expect(journeyLoginFailure.getCode()).toBe(400); + expect(journeyLoginFailure.getDetail()).toEqual({ failureUrl: 'failure-url' }); + expect(journeyLoginFailure.getMessage()).toBe('Invalid credentials'); + expect(journeyLoginFailure.getReason()).toBe('INVALID_CREDENTIALS'); + }); +}); diff --git a/packages/journey-client/src/lib/journey-login-failure.utils.ts b/packages/journey-client/src/lib/journey-login-failure.utils.ts new file mode 100644 index 0000000000..278d7109d8 --- /dev/null +++ b/packages/journey-client/src/lib/journey-login-failure.utils.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import type { Step, AuthResponse, FailureDetail } from '@forgerock/sdk-types'; +import { StepType } from '@forgerock/sdk-types'; +import JourneyPolicy from './journey-policy/index.js'; +import type { MessageCreator, ProcessedPropertyError } from './journey-policy/interfaces.js'; + +type JourneyLoginFailure = AuthResponse & { + payload: Step; + getCode: () => number; + getDetail: () => FailureDetail | undefined; + getMessage: () => string | undefined; + getProcessedMessage: (messageCreator?: MessageCreator) => ProcessedPropertyError[]; + getReason: () => string | undefined; +}; + +function getCode(payload: Step): number { + return Number(payload.code); +} + +function getDetail(payload: Step): FailureDetail | undefined { + return payload.detail; +} + +function getMessage(payload: Step): string | undefined { + return payload.message; +} + +function getProcessedMessage( + payload: Step, + messageCreator?: MessageCreator, +): ProcessedPropertyError[] { + return JourneyPolicy.parseErrors(payload, messageCreator); +} + +function getReason(payload: Step): string | undefined { + return payload.reason; +} + +function createJourneyLoginFailure(payload: Step): JourneyLoginFailure { + return { + payload, + type: StepType.LoginFailure, + getCode: () => getCode(payload), + getDetail: () => getDetail(payload), + getMessage: () => getMessage(payload), + getProcessedMessage: (messageCreator?: MessageCreator) => + getProcessedMessage(payload, messageCreator), + getReason: () => getReason(payload), + }; +} + +export { createJourneyLoginFailure, JourneyLoginFailure }; diff --git a/packages/journey-client/src/lib/journey-login-success.utils.test.ts b/packages/journey-client/src/lib/journey-login-success.utils.test.ts new file mode 100644 index 0000000000..3c351c8aab --- /dev/null +++ b/packages/journey-client/src/lib/journey-login-success.utils.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, expect, it } from 'vitest'; +import { createJourneyLoginSuccess } from './journey-login-success.utils.js'; +import { StepType } from '@forgerock/sdk-types'; + +describe('createJourneyLoginSuccess', () => { + it('should create a JourneyLoginSuccess object', () => { + const step = { + realm: 'my-realm', + tokenId: 'my-token', + successUrl: 'my-success-url', + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const journeyLoginSuccess = createJourneyLoginSuccess(step); + + expect(journeyLoginSuccess.type).toBe(StepType.LoginSuccess); + expect(journeyLoginSuccess.payload).toEqual(step); + expect(journeyLoginSuccess.getRealm()).toBe('my-realm'); + expect(journeyLoginSuccess.getSessionToken()).toBe('my-token'); + expect(journeyLoginSuccess.getSuccessUrl()).toBe('my-success-url'); + }); +}); diff --git a/packages/journey-client/src/lib/journey-login-success.utils.ts b/packages/journey-client/src/lib/journey-login-success.utils.ts new file mode 100644 index 0000000000..ff1b3aae9f --- /dev/null +++ b/packages/journey-client/src/lib/journey-login-success.utils.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import type { Step, AuthResponse } from '@forgerock/sdk-types'; +import { StepType } from '@forgerock/sdk-types'; + +type JourneyLoginSuccess = AuthResponse & { + payload: Step; + getRealm: () => string | undefined; + getSessionToken: () => string | undefined; + getSuccessUrl: () => string | undefined; +}; + +function getRealm(payload: Step): string | undefined { + return payload.realm; +} + +function getSessionToken(payload: Step): string | undefined { + return payload.tokenId; +} + +function getSuccessUrl(payload: Step): string | undefined { + return payload.successUrl; +} + +function createJourneyLoginSuccess(payload: Step): JourneyLoginSuccess { + return { + payload, + type: StepType.LoginSuccess, + getRealm: () => getRealm(payload), + getSessionToken: () => getSessionToken(payload), + getSuccessUrl: () => getSuccessUrl(payload), + }; +} + +export { + createJourneyLoginSuccess, + getRealm, + getSessionToken, + getSuccessUrl, + type JourneyLoginSuccess, +}; diff --git a/packages/journey-client/src/lib/journey-policy/index.ts b/packages/journey-client/src/lib/journey-policy/index.ts new file mode 100644 index 0000000000..783f20e58d --- /dev/null +++ b/packages/journey-client/src/lib/journey-policy/index.ts @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import type { FailedPolicyRequirement, PolicyRequirement, Step } from '@forgerock/sdk-types'; +import { PolicyKey } from '@forgerock/sdk-types'; +import type { MessageCreator, ProcessedPropertyError } from './interfaces.js'; +import defaultMessageCreator from './message-creator.js'; + +/** + * Utility for processing policy failures into human readable messages. + * + * Example: + * + * ```js + * // Create message overrides and extensions as needed + * const messageCreator = { + * [PolicyKey.unique]: (property: string) => ( + * `this is a custom message for "UNIQUE" policy of ${property}` + * ), + * CUSTOM_POLICY: (property: string, params: any) => ( + * `this is a custom message for "${params.policyRequirement}" policy of ${property}` + * ), + * }; + * + * const thisStep = await FRAuth.next(previousStep); + * + * if (thisStep.type === StepType.LoginFailure) { + * const messagesStepMethod = thisStep.getProcessedMessage(messageCreator); + * const messagesClassMethod = FRPolicy.parseErrors(thisStep, messageCreator) + * } + */ +abstract class JourneyPolicy { + /** + * Parses policy errors and generates human readable error messages. + * + * @param {Step} err The step containing the error. + * @param {MessageCreator} messageCreator + * Extensible and overridable custom error messages for policy failures. + * @return {ProcessedPropertyError[]} Array of objects containing all processed policy errors. + */ + public static parseErrors( + err: Partial, + messageCreator?: MessageCreator, + ): ProcessedPropertyError[] { + const errors: ProcessedPropertyError[] = []; + if (err.detail && err.detail.failedPolicyRequirements) { + err.detail.failedPolicyRequirements.map((x: FailedPolicyRequirement) => { + errors.push.apply(errors, [ + { + detail: x, + messages: this.parseFailedPolicyRequirement(x, messageCreator), + }, + ]); + }); + } + return errors; + } + + /** + * Parses a failed policy and returns a string array of error messages. + * + * @param {FailedPolicyRequirement} failedPolicy The detail data of the failed policy. + * @param {MessageCreator} customMessage + * Extensible and overridable custom error messages for policy failures. + * @return {string[]} Array of strings with all processed policy errors. + */ + public static parseFailedPolicyRequirement( + failedPolicy: FailedPolicyRequirement, + messageCreator?: MessageCreator, + ): string[] { + const errors: string[] = []; + failedPolicy.policyRequirements.map((policyRequirement: PolicyRequirement) => { + errors.push( + this.parsePolicyRequirement(failedPolicy.property, policyRequirement, messageCreator), + ); + }); + return errors; + } + + /** + * Parses a policy error into a human readable error message. + * + * @param {string} property The property with the policy failure. + * @param {PolicyRequirement} policy The policy failure data. + * @param {MessageCreator} customMessage + * Extensible and overridable custom error messages for policy failures. + * @return {string} Human readable error message. + */ + public static parsePolicyRequirement( + property: string, + policy: PolicyRequirement, + messageCreator: MessageCreator = {}, + ): string { + // AM is returning policy requirement failures as JSON strings + const policyObject = typeof policy === 'string' ? JSON.parse(policy) : { ...policy }; + + const policyRequirement = policyObject.policyRequirement; + + // Determine which message creator function to use + const effectiveMessageCreator = + messageCreator[policyRequirement] || + defaultMessageCreator[policyRequirement] || + defaultMessageCreator[PolicyKey.UnknownPolicy]; + + // Flatten the parameters and create the message + const params = policyObject.params + ? { ...policyObject.params, policyRequirement } + : { policyRequirement }; + const message = effectiveMessageCreator(property, params); + + return message; + } +} + +export default JourneyPolicy; +export type { MessageCreator, ProcessedPropertyError }; diff --git a/packages/journey-client/src/lib/journey-policy/interfaces.ts b/packages/journey-client/src/lib/journey-policy/interfaces.ts new file mode 100644 index 0000000000..0d9553f4e4 --- /dev/null +++ b/packages/journey-client/src/lib/journey-policy/interfaces.ts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import type { FailedPolicyRequirement } from '@forgerock/sdk-types'; + +interface MessageCreator { + [key: string]: (propertyName: string, params?: { [key: string]: unknown }) => string; +} + +interface ProcessedPropertyError { + detail: FailedPolicyRequirement; + messages: string[]; +} + +export type { MessageCreator, ProcessedPropertyError }; diff --git a/packages/journey-client/src/lib/journey-policy/journey-policy.test.ts b/packages/journey-client/src/lib/journey-policy/journey-policy.test.ts new file mode 100644 index 0000000000..a7eecaf670 --- /dev/null +++ b/packages/journey-client/src/lib/journey-policy/journey-policy.test.ts @@ -0,0 +1,201 @@ +/* + * @forgerock/javascript-sdk + * + * fr-policy.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import JourneyPolicy, { MessageCreator } from './index.js'; +import { PolicyKey } from '@forgerock/sdk-types'; + +describe('The IDM error handling', () => { + const property = 'userName'; + + it('returns a human readable error message', () => { + const test = { + expectedString: `${property} must be unique`, + policy: { + policyRequirement: 'UNIQUE', + }, + }; + const message = JourneyPolicy.parsePolicyRequirement(property, test.policy); + expect(message).toBe(test.expectedString); + }); + + it('returns a human readable error message with param data', () => { + const test = { + expectedString: `${property} must be at least 6 characters`, + policy: { + params: { + minLength: 6, + }, + policyRequirement: 'MIN_LENGTH', + }, + }; + const message = JourneyPolicy.parsePolicyRequirement(property, test.policy); + expect(message).toBe(test.expectedString); + }); + + it('returns a human readable generic message for unknown error', () => { + const test = { + expectedString: `${property}: Unknown policy requirement "SOME_UNKNOWN_POLICY"`, + policy: { + params: { + unknownParam: 6, + }, + policyRequirement: 'SOME_UNKNOWN_POLICY', + }, + }; + const message = JourneyPolicy.parsePolicyRequirement(property, test.policy); + expect(message).toBe(test.expectedString); + }); + + it('error handling is extensible by customer', () => { + const test = { + customMessage: { + CUSTOM_POLICY: (property: string, params?: { [key: string]: unknown }): string => + `this is a custom message for "${params?.policyRequirement}" policy of ${property}`, + }, + expectedString: `this is a custom message for "CUSTOM_POLICY" policy of ${property}`, + policy: { + policyRequirement: 'CUSTOM_POLICY', + }, + }; + const message = JourneyPolicy.parsePolicyRequirement( + property, + test.policy, + test.customMessage as MessageCreator, + ); + expect(message).toBe(test.expectedString); + }); + + it('error handling is overwritable by customer', () => { + const test = { + customMessage: { + [PolicyKey.Unique]: (property: string): string => + `this is a custom message for "UNIQUE" policy of ${property}`, + }, + expectedString: `this is a custom message for "UNIQUE" policy of ${property}`, + policy: { + policyRequirement: 'UNIQUE', + }, + }; + const message = JourneyPolicy.parsePolicyRequirement(property, test.policy, test.customMessage); + expect(message).toBe(test.expectedString); + }); + + it('groups failed policies for one property', () => { + const policy = { + policyRequirements: [ + { + policyRequirement: 'UNIQUE', + }, + { + params: { + minLength: 6, + }, + policyRequirement: 'MIN_LENGTH', + }, + ], + property: 'userName', + }; + + const messageArray = JourneyPolicy.parseFailedPolicyRequirement(policy); + expect(messageArray).toEqual([ + 'userName must be unique', + 'userName must be at least 6 characters', + ]); + }); + + it('returns an object array with a human readable error and the server error', () => { + const errorResponse = { + code: 403, + reason: 'Forbidden', + message: 'Policy validation failed', + detail: { + failedPolicyRequirements: [ + { + policyRequirements: [ + { + policyRequirement: 'UNIQUE', + }, + { + params: { + minLength: 6, + }, + policyRequirement: 'MIN_LENGTH', + }, + { + policyRequirement: 'CUSTOM_POLICY', + }, + ], + property: 'userName', + }, + { + policyRequirements: [ + { + params: { + numCaps: 1, + }, + policyRequirement: 'AT_LEAST_X_CAPITAL_LETTERS', + }, + { + params: { + minLength: 6, + }, + policyRequirement: 'MIN_LENGTH', + }, + ], + property: 'password', + }, + ], + result: false, + }, + }; + const customMessage: MessageCreator = { + [PolicyKey.Unique]: (property: string): string => + `this is a custom message for "UNIQUE" policy of ${property}`, + CUSTOM_POLICY: (property: string, params?: { [key: string]: unknown }): string => + `this is a custom message for "${params?.policyRequirement}" policy of ${property}`, + }; + const expected = [ + { + messages: [ + 'this is a custom message for "UNIQUE" policy of userName', + 'userName must be at least 6 characters', + 'this is a custom message for "CUSTOM_POLICY" policy of userName', + ], + detail: { + policyRequirements: [ + { policyRequirement: 'UNIQUE' }, + { params: { minLength: 6 }, policyRequirement: 'MIN_LENGTH' }, + { policyRequirement: 'CUSTOM_POLICY' }, + ], + property: 'userName', + }, + }, + { + messages: [ + 'password must contain at least 1 capital letter', + 'password must be at least 6 characters', + ], + detail: { + policyRequirements: [ + { + params: { numCaps: 1 }, + policyRequirement: 'AT_LEAST_X_CAPITAL_LETTERS', + }, + { params: { minLength: 6 }, policyRequirement: 'MIN_LENGTH' }, + ], + property: 'password', + }, + }, + ]; + + const errorObjArr = JourneyPolicy.parseErrors(errorResponse, customMessage); + expect(errorObjArr).toEqual(expected); + }); +}); diff --git a/packages/journey-client/src/lib/journey-policy/message-creator.ts b/packages/journey-client/src/lib/journey-policy/message-creator.ts new file mode 100644 index 0000000000..c0b30f205f --- /dev/null +++ b/packages/journey-client/src/lib/journey-policy/message-creator.ts @@ -0,0 +1,67 @@ +/* + * @forgerock/javascript-sdk + * + * message-creator.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { getProp, plural } from '@forgerock/sdk-utilities'; +import { PolicyKey } from '@forgerock/sdk-types'; +import type { MessageCreator } from './interfaces.js'; + +const defaultMessageCreator: MessageCreator = { + [PolicyKey.CannotContainCharacters]: (property: string, params?: { forbiddenChars?: string }) => { + const forbiddenChars = getProp(params, 'forbiddenChars', ''); + return `${property} must not contain following characters: "${forbiddenChars}"`; + }, + [PolicyKey.CannotContainDuplicates]: (property: string, params?: { duplicateValue?: string }) => { + const duplicateValue = getProp(params, 'duplicateValue', ''); + return `${property} must not contain duplicates: "${duplicateValue}"`; + }, + [PolicyKey.CannotContainOthers]: (property: string, params?: { disallowedFields?: string }) => { + const disallowedFields = getProp(params, 'disallowedFields', ''); + return `${property} must not contain: "${disallowedFields}"`; + }, + [PolicyKey.LeastCapitalLetters]: (property: string, params?: { numCaps?: number }) => { + const numCaps = getProp(params, 'numCaps', 0); + return `${property} must contain at least ${numCaps} capital ${plural(numCaps, 'letter')}`; + }, + [PolicyKey.LeastNumbers]: (property: string, params?: { numNums?: number }) => { + const numNums = getProp(params, 'numNums', 0); + return `${property} must contain at least ${numNums} numeric ${plural(numNums, 'value')}`; + }, + [PolicyKey.MatchRegexp]: (property: string) => `${property} has failed the "MATCH_REGEXP" policy`, + [PolicyKey.MaximumLength]: (property: string, params?: { maxLength?: number }) => { + const maxLength = getProp(params, 'maxLength', 0); + return `${property} must be at most ${maxLength} ${plural(maxLength, 'character')}`; + }, + [PolicyKey.MaximumNumber]: (property: string) => + `${property} has failed the "MAXIMUM_NUMBER_VALUE" policy`, + [PolicyKey.MinimumLength]: (property: string, params?: { minLength?: number }) => { + const minLength = getProp(params, 'minLength', 0); + return `${property} must be at least ${minLength} ${plural(minLength, 'character')}`; + }, + [PolicyKey.MinimumNumber]: (property: string) => + `${property} has failed the "MINIMUM_NUMBER_VALUE" policy`, + [PolicyKey.Required]: (property: string) => `${property} is required`, + [PolicyKey.Unique]: (property: string) => `${property} must be unique`, + [PolicyKey.UnknownPolicy]: (property: string, params?: { policyRequirement?: string }) => { + const policyRequirement = getProp(params, 'policyRequirement', 'Unknown'); + return `${property}: Unknown policy requirement "${policyRequirement}"`; + }, + [PolicyKey.ValidArrayItems]: (property: string) => + `${property} has failed the "VALID_ARRAY_ITEMS" policy`, + [PolicyKey.ValidDate]: (property: string) => `${property} has an invalid date`, + [PolicyKey.ValidEmailAddress]: (property: string) => `${property} has an invalid email address`, + [PolicyKey.ValidNameFormat]: (property: string) => `${property} has an invalid name format`, + [PolicyKey.ValidNumber]: (property: string) => `${property} has an invalid number`, + [PolicyKey.ValidPhoneFormat]: (property: string) => `${property} has an invalid phone number`, + [PolicyKey.ValidQueryFilter]: (property: string) => + `${property} has failed the "VALID_QUERY_FILTER" policy`, + [PolicyKey.ValidType]: (property: string) => `${property} has failed the "VALID_TYPE" policy`, +}; + +export default defaultMessageCreator; diff --git a/packages/journey-client/src/lib/journey-qrcode/journey-qr-code.mock.data.ts b/packages/journey-client/src/lib/journey-qrcode/journey-qr-code.mock.data.ts new file mode 100644 index 0000000000..9a72c7e5f9 --- /dev/null +++ b/packages/journey-client/src/lib/journey-qrcode/journey-qr-code.mock.data.ts @@ -0,0 +1,184 @@ +/* + * @forgerock/javascript-sdk + * + * fr-qr-code.mock.data.ts + * + * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; + +export const otpQRCodeStep = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + 'Scan the QR code image below with the ForgeRock Authenticator app to register your ' + + 'device with your login.', + }, + { + name: 'messageType', + value: '0', + }, + ], + }, + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + `window.QRCodeReader.createCode({\n id: 'callback_0',\n text: 'otpauth\\x3A\\x` + + `2F\\x2Ftotp\\x2FForgeRock\\x3Ajlowery\\x3Fperiod\\x3D30\\x26b\\x3D032b75\\x26` + + `digits\\x3D6\\x26secret\\QITSTC234FRIU8DD987DW3VPICFY\\x3D\\x3D\\x3D\\x3D\\x3` + + `D\\x',\n 3D\\x26issuer\\x3DForgeRock version: '20',\n code: 'L'\n});`, + }, + { + name: 'messageType', + value: '4', + }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { + name: 'value', + value: + 'otpauth://totp/ForgeRock:jlowery?secret=QITSTC234FRIU8DD987DW3VPICFY======&issue' + + 'r=ForgeRock&period=30&digits=6&b=032b75', + }, + { + name: 'id', + value: 'mfaDeviceRegistration', + }, + ], + input: [ + { + name: 'IDToken3', + value: 'mfaDeviceRegistration', + }, + ], + }, + { + type: callbackType.ConfirmationCallback, + output: [ + { + name: 'prompt', + value: '', + }, + { + name: 'messageType', + value: 0, + }, + { + name: 'options', + value: ['Next'], + }, + { + name: 'optionType', + value: -1, + }, + { + name: 'defaultOption', + value: 0, + }, + ], + input: [ + { + name: 'IDToken4', + value: 0, + }, + ], + }, + ], +}; + +export const pushQRCodeStep = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + 'Scan the QR code image below with the ForgeRock Authenticator app to register ' + + 'your device with your login.', + }, + { + name: 'messageType', + value: '0', + }, + ], + }, + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + `window.QRCodeReader.createCode({\n id: 'callback_0',\n text: 'pushauth\\x` + + `3A\\x2F\\x2Fpush\\x2Fforgerock\\x3AJustin\\x2520Lowery\\x3Fa\\x3DaHR0cHM6Ly9vc` + + `GVuYW0tZm9yZ2Vycm9jay1zZGtzLmZvcmdlYmxvY2tzLmNvbTo0NDMvYW0vanNvbi9hbHBoYS9wdXN` + + `oL3Nucy9tZXNzYWdlP19hY3Rpb249YXV0aGVudGljYXRl\\x26r\\x3DaHR0cHM6Ly9vcGVuYW0tZm` + + `9yZ2Vycm9jay1zZGtzLmZvcmdlYmxvY2tzLmNvbTo0NDMvYW0vanNvbi9hbHBoYS9wdXNoL3Nucy9t` + + `ZXNzYWdlP19hY3Rpb249cmVnaXN0ZXI\\x26b\\x3D032b75\\x26s\\x3DFoxEr5uAzrys1yBmuyg` + + `PbxrVjysElmzsmqifi6eO_AI\\x26c\\x3DXD\\x2DMxsK2sRGa7sUw7kinSKoUDf_eNYMZUV2f0z5` + + `kjgw\\x26l\\x3DYW1sYmNvb2tpZT0wMQ\\x26m\\x3DREGISTER\\x3A53b85112\\x2D8ba9\\x2` + + `D4b7e\\x2D9107\\x2Decbca2d65f7b1695151603616\\x26issuer\\x3DRm9yZ2VSb2Nr',\n ` + + ` version: '20',\n code: 'L'\n});`, + }, + { + name: 'messageType', + value: '4', + }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { + name: 'value', + value: + 'pushauth://push/forgerock:Justin%20Lowery?l=YW1sYmNvb2tpZT0wMQ&issuer=Rm9yZ2VSb' + + '2Nr&m=REGISTER:53b85112-8ba9-4b7e-9107-ecbca2d65f7b1695151603616&s=FoxEr5uAzrys' + + '1yBmuygPbxrVjysElmzsmqifi6eO_AI&c=XD-MxsK2sRGa7sUw7kinSKoUDf_eNYMZUV2f0z5kjgw&r' + + '=aHR0cHM6Ly9vcGVuYW0tZm9yZ2Vycm9jay1zZGtzLmZvcmdlYmxvY2tzLmNvbTo0NDMvYW0vanNvbi' + + '9hbHBoYS9wdXNoL3Nucy9tZXNzYWdlP19hY3Rpb249cmVnaXN0ZXI&a=aHR0cHM6Ly9vcGVuYW0tZm9' + + 'yZ2Vycm9jay1zZGtzLmZvcmdlYmxvY2tzLmNvbTo0NDMvYW0vanNvbi9hbHBoYS9wdXNoL3Nucy9tZ' + + 'XNzYWdlP19hY3Rpb249YXV0aGVudGljYXRl&b=032b75', + }, + { + name: 'id', + value: 'mfaDeviceRegistration', + }, + ], + input: [ + { + name: 'IDToken3', + value: 'mfaDeviceRegistration', + }, + ], + }, + { + type: callbackType.PollingWaitCallback, + output: [ + { + name: 'waitTime', + value: '5000', + }, + { + name: 'message', + value: 'Waiting for response...', + }, + ], + }, + ], +}; diff --git a/packages/journey-client/src/lib/journey-qrcode/journey-qrcode.test.ts b/packages/journey-client/src/lib/journey-qrcode/journey-qrcode.test.ts new file mode 100644 index 0000000000..9ab92be556 --- /dev/null +++ b/packages/journey-client/src/lib/journey-qrcode/journey-qrcode.test.ts @@ -0,0 +1,78 @@ +/* + * @forgerock/javascript-sdk + * + * index.test.ts + * + * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { createJourneyStep } from '../journey-step.utils.js'; +import JourneyQRCode from './journey-qrcode.js'; +import { otpQRCodeStep, pushQRCodeStep } from '../journey-qrcode/journey-qr-code.mock.data.js'; +// import WebAuthn step as it's similar in structure for testing non-QR Code steps +import { webAuthnRegJSCallback70 } from '../journey-webauthn/journey-webauthn.mock.data.js'; + +describe('Class for managing QR Codes', () => { + it('should return true for step containing OTP QR Code callbacks', () => { + const expected = true; + const step = createJourneyStep(otpQRCodeStep); + const result = JourneyQRCode.isQRCodeStep(step); + + expect(result).toBe(expected); + }); + + it('should return true for step containing Push QR Code callbacks', () => { + const expected = true; + const step = createJourneyStep(pushQRCodeStep); + const result = JourneyQRCode.isQRCodeStep(step); + + expect(result).toBe(expected); + }); + + it('should return false for step containing WebAuthn step', () => { + const expected = false; + const step = createJourneyStep(webAuthnRegJSCallback70); + const result = JourneyQRCode.isQRCodeStep(step); + + expect(result).toBe(expected); + }); + + it('should return an object with OTP QR Code data', () => { + const expected = { + message: + 'Scan the QR code image below with the ForgeRock Authenticator app to register your ' + + 'device with your login.', + use: 'otp', + uri: + 'otpauth://totp/ForgeRock:jlowery?secret=QITSTC234FRIU8DD987DW3VPICFY======&issue' + + 'r=ForgeRock&period=30&digits=6&b=032b75', + }; + const step = createJourneyStep(otpQRCodeStep); + const result = JourneyQRCode.getQRCodeData(step); + + expect(result).toStrictEqual(expected); + }); + + it('should return an object with Push QR Code data', () => { + const expected = { + message: + 'Scan the QR code image below with the ForgeRock Authenticator app to register ' + + 'your device with your login.', + use: 'push', + uri: + 'pushauth://push/forgerock:Justin%20Lowery?l=YW1sYmNvb2tpZT0wMQ&issuer=Rm9yZ2VSb' + + '2Nr&m=REGISTER:53b85112-8ba9-4b7e-9107-ecbca2d65f7b1695151603616&s=FoxEr5uAzrys' + + '1yBmuygPbxrVjysElmzsmqifi6eO_AI&c=XD-MxsK2sRGa7sUw7kinSKoUDf_eNYMZUV2f0z5kjgw&r' + + '=aHR0cHM6Ly9vcGVuYW0tZm9yZ2Vycm9jay1zZGtzLmZvcmdlYmxvY2tzLmNvbTo0NDMvYW0vanNvbi' + + '9hbHBoYS9wdXNoL3Nucy9tZXNzYWdlP19hY3Rpb249cmVnaXN0ZXI&a=aHR0cHM6Ly9vcGVuYW0tZm9' + + 'yZ2Vycm9jay1zZGtzLmZvcmdlYmxvY2tzLmNvbTo0NDMvYW0vanNvbi9hbHBoYS9wdXNoL3Nucy9tZ' + + 'XNzYWdlP19hY3Rpb249YXV0aGVudGljYXRl&b=032b75', + }; + const step = createJourneyStep(pushQRCodeStep); + const result = JourneyQRCode.getQRCodeData(step); + + expect(result).toStrictEqual(expected); + }); +}); diff --git a/packages/journey-client/src/lib/journey-qrcode/journey-qrcode.ts b/packages/journey-client/src/lib/journey-qrcode/journey-qrcode.ts new file mode 100644 index 0000000000..2c5675488b --- /dev/null +++ b/packages/journey-client/src/lib/journey-qrcode/journey-qrcode.ts @@ -0,0 +1,96 @@ +/* + * @forgerock/javascript-sdk + * + * index.ts + * + * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; +import { JourneyStep } from '../journey-step.utils.js'; +import TextOutputCallback from '../callbacks/text-output-callback.js'; +import HiddenValueCallback from '../callbacks/hidden-value-callback.js'; + +export type QRCodeData = { + message: string; + use: string; + uri: string; +}; + +/** + * @class JourneyQRCode - A utility class for handling QR Code steps + * + * Example: + * + * ```js + * const isQRCodeStep = JourneyQRCode.isQRCodeStep(step); + * let qrCodeData; + * if (isQRCodeStep) { + * qrCodeData = JourneyQRCode.getQRCodeData(step); + * } + * ``` + */ +abstract class JourneyQRCode { + /** + * @method isQRCodeStep - determines if step contains QR Code callbacks + * @param {JourneyStep} step - step object from AM response + * @returns {boolean} + */ + public static isQRCodeStep(step: JourneyStep): boolean { + const hiddenValueCb = step.getCallbacksOfType(callbackType.HiddenValueCallback); + + // QR Codes step should have at least one HiddenValueCallback + if (hiddenValueCb.length === 0) { + return false; + } + return !!this.getQRCodeURICb(hiddenValueCb); + } + + /** + * @method getQRCodeData - gets the necessary information from the QR Code callbacks + * @param {JourneyStep} step - step object from AM response + * @returns {QRCodeData} + */ + public static getQRCodeData(step: JourneyStep): QRCodeData { + const hiddenValueCb = step.getCallbacksOfType(callbackType.HiddenValueCallback); + + // QR Codes step should have at least one HiddenValueCallback + if (hiddenValueCb.length === 0) { + throw new Error( + 'QR Code step must contain a HiddenValueCallback. Use `FRQRCode.isQRCodeStep` to guard.', + ); + } + const qrCodeURICb = this.getQRCodeURICb(hiddenValueCb) as HiddenValueCallback; + const outputValue = qrCodeURICb ? qrCodeURICb.getOutputValue('value') : ''; + const qrCodeUse = + typeof outputValue === 'string' && outputValue.includes('otpauth://') ? 'otp' : 'push'; + + const messageCbs = step.getCallbacksOfType(callbackType.TextOutputCallback); + const displayMessageCb = messageCbs.find((cb) => { + const textOutputCallback = cb as TextOutputCallback; + return textOutputCallback.getMessageType() !== '4'; + }) as TextOutputCallback | null; + + return { + message: displayMessageCb ? displayMessageCb.getMessage() : '', + use: qrCodeUse, + uri: typeof outputValue === 'string' ? outputValue : '', + }; + } + + private static getQRCodeURICb(hiddenValueCbs: HiddenValueCallback[]) { + // Look for a HiddenValueCallback with an OTP URI + return hiddenValueCbs.find((cb) => { + const outputValue = cb.getOutputValue('value'); + + if (typeof outputValue === 'string') { + return outputValue?.includes('otpauth://') || outputValue?.includes('pushauth://'); + } + return false; + }); + } +} + +export default JourneyQRCode; diff --git a/packages/journey-client/src/lib/journey-step.test.ts b/packages/journey-client/src/lib/journey-step.test.ts new file mode 100644 index 0000000000..fef55c6e46 --- /dev/null +++ b/packages/journey-client/src/lib/journey-step.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, it, expect } from 'vitest'; +import { callbackType, type Step } from '@forgerock/sdk-types'; +import { createJourneyStep } from './journey-step.utils.js'; +import NameCallback from './callbacks/name-callback.js'; + +describe('fr-step.ts', () => { + const stepPayload: Step = { + authId: '123', + callbacks: [ + { + type: callbackType.NameCallback, + input: [{ name: 'IDToken1', value: '' }], + output: [{ name: 'prompt', value: 'Username' }], + }, + { + type: callbackType.PasswordCallback, + input: [{ name: 'IDToken2', value: '' }], + output: [{ name: 'prompt', value: 'Password' }], + }, + { + type: callbackType.NameCallback, // Duplicate for testing + input: [{ name: 'IDToken3', value: '' }], + output: [{ name: 'prompt', value: 'Username 2' }], + }, + ], + description: 'Step description', + header: 'Step header', + stage: 'Step stage', + }; + + it('should correctly initialize with a payload', () => { + const step = createJourneyStep(stepPayload); + expect(step.payload).toEqual(stepPayload); + expect(step.callbacks).toHaveLength(3); + expect(step.callbacks[0]).toBeInstanceOf(NameCallback); + }); + + it('should get a single callback of a specific type', () => { + const singleCallbackPayload: Step = { ...stepPayload, callbacks: [stepPayload.callbacks![1]] }; + const step = createJourneyStep(singleCallbackPayload); + const cb = step.getCallbackOfType(callbackType.PasswordCallback); + expect(cb).toBeDefined(); + expect(cb.getType()).toBe(callbackType.PasswordCallback); + }); + + it('should throw an error if getCallbackOfType finds no matching callbacks', () => { + const step = createJourneyStep(stepPayload); + const err = `Expected 1 callback of type "TermsAndConditionsCallback", but found 0`; + expect(() => step.getCallbackOfType(callbackType.TermsAndConditionsCallback)).toThrow(err); + }); + + it('should throw an error if getCallbackOfType finds multiple matching callbacks', () => { + const step = createJourneyStep(stepPayload); + const err = `Expected 1 callback of type "NameCallback", but found 2`; + expect(() => step.getCallbackOfType(callbackType.NameCallback)).toThrow(err); + }); + + it('should get all callbacks of a specific type', () => { + const step = createJourneyStep(stepPayload); + const callbacks = step.getCallbacksOfType(callbackType.NameCallback); + expect(callbacks).toHaveLength(2); + expect(callbacks[0].getType()).toBe(callbackType.NameCallback); + expect(callbacks[1].getType()).toBe(callbackType.NameCallback); + }); + + it('should return an empty array if getCallbacksOfType finds no matches', () => { + const step = createJourneyStep(stepPayload); + const callbacks = step.getCallbacksOfType(callbackType.TermsAndConditionsCallback); + expect(callbacks).toHaveLength(0); + }); + + it('should set the value of a specific callback', () => { + const singleCallbackPayload: Step = { ...stepPayload, callbacks: [stepPayload.callbacks![1]] }; + const step = createJourneyStep(singleCallbackPayload); + step.setCallbackValue(callbackType.PasswordCallback, 'password123'); + const cb = step.getCallbackOfType(callbackType.PasswordCallback); + expect(cb.getInputValue()).toBe('password123'); + }); + + it('should return the description', () => { + const step = createJourneyStep(stepPayload); + expect(step.getDescription()).toBe('Step description'); + }); + + it('should return the header', () => { + const step = createJourneyStep(stepPayload); + expect(step.getHeader()).toBe('Step header'); + }); + + it('should return the stage', () => { + const step = createJourneyStep(stepPayload); + expect(step.getStage()).toBe('Step stage'); + }); +}); diff --git a/packages/journey-client/src/lib/journey-step.utils.ts b/packages/journey-client/src/lib/journey-step.utils.ts new file mode 100644 index 0000000000..8df5664db6 --- /dev/null +++ b/packages/journey-client/src/lib/journey-step.utils.ts @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { + type CallbackType, + type Callback, + type Step, + type AuthResponse, +} from '@forgerock/sdk-types'; +import type JourneyCallback from './callbacks/index.js'; +import type { JourneyCallbackFactory } from './callbacks/factory.js'; +import createCallback from './callbacks/factory.js'; +import { StepType } from '@forgerock/sdk-types'; + +type JourneyStep = AuthResponse & { + payload: Step; + callbacks: JourneyCallback[]; + getCallbackOfType: (type: CallbackType) => T; + getCallbacksOfType: (type: CallbackType) => T[]; + setCallbackValue: (type: CallbackType, value: unknown) => void; + getDescription: () => string | undefined; + getHeader: () => string | undefined; + getStage: () => string | undefined; +}; + +function getCallbacksOfType( + callbacks: JourneyCallback[], + type: CallbackType, +): T[] { + return callbacks.filter((x) => x.getType() === type) as T[]; +} + +function getCallbackOfType( + callbacks: JourneyCallback[], + type: CallbackType, +): T { + const callbacksOfType = getCallbacksOfType(callbacks, type); + if (callbacksOfType.length !== 1) { + throw new Error(`Expected 1 callback of type "${type}", but found ${callbacksOfType.length}`); + } + return callbacksOfType[0]; +} + +function setCallbackValue(callbacks: JourneyCallback[], type: CallbackType, value: unknown): void { + const callbacksToUpdate = getCallbacksOfType(callbacks, type); + if (callbacksToUpdate.length !== 1) { + throw new Error(`Expected 1 callback of type "${type}", but found ${callbacksToUpdate.length}`); + } + callbacksToUpdate[0].setInputValue(value); +} + +function getDescription(payload: Step): string | undefined { + return payload.description; +} + +function getHeader(payload: Step): string | undefined { + return payload.header; +} + +function getStage(payload: Step): string | undefined { + return payload.stage; +} + +function convertCallbacks( + callbacks: Callback[], + callbackFactory?: JourneyCallbackFactory, +): JourneyCallback[] { + const converted = callbacks.map((x: Callback) => { + // This gives preference to the provided factory and falls back to our default implementation + return (callbackFactory || createCallback)(x) || createCallback(x); + }); + return converted; +} + +function createJourneyStep(payload: Step, callbackFactory?: JourneyCallbackFactory): JourneyStep { + const callbacks = payload.callbacks ? convertCallbacks(payload.callbacks, callbackFactory) : []; + return { + payload, + callbacks, + type: StepType.Step, + getCallbackOfType: (type: CallbackType) => + getCallbackOfType(callbacks, type), + getCallbacksOfType: (type: CallbackType) => + getCallbacksOfType(callbacks, type), + setCallbackValue: (type: CallbackType, value: unknown) => + setCallbackValue(callbacks, type, value), + getDescription: () => getDescription(payload), + getHeader: () => getHeader(payload), + getStage: () => getStage(payload), + }; +} + +/** + * A function that can populate the provided authentication tree step. + */ + +type JourneyStepHandler = (step: JourneyStep) => void; + +export { createJourneyStep, JourneyStep, JourneyStepHandler }; diff --git a/packages/journey-client/src/lib/journey-webauthn/enums.ts b/packages/journey-client/src/lib/journey-webauthn/enums.ts new file mode 100644 index 0000000000..13f4c7c092 --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/enums.ts @@ -0,0 +1,36 @@ +/* + * @forgerock/javascript-sdk + * + * enums.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +enum WebAuthnOutcome { + Error = 'ERROR', + Unsupported = 'unsupported', +} + +enum WebAuthnOutcomeType { + AbortError = 'AbortError', + DataError = 'DataError', + ConstraintError = 'ConstraintError', + EncodingError = 'EncodingError', + InvalidError = 'InvalidError', + NetworkError = 'NetworkError', + NotAllowedError = 'NotAllowedError', + NotSupportedError = 'NotSupportedError', + SecurityError = 'SecurityError', + TimeoutError = 'TimeoutError', + UnknownError = 'UnknownError', +} + +enum WebAuthnStepType { + None = 0, + Authentication = 1, + Registration = 2, +} + +export { WebAuthnOutcome, WebAuthnOutcomeType, WebAuthnStepType }; diff --git a/packages/journey-client/src/lib/journey-webauthn/helpers.mock.data.ts b/packages/journey-client/src/lib/journey-webauthn/helpers.mock.data.ts new file mode 100644 index 0000000000..1113a31dd6 --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/helpers.mock.data.ts @@ -0,0 +1,24 @@ +/* + * @forgerock/javascript-sdk + * + * helpers.mock.data.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +export const allowCredentials70 = + 'allowCredentials: [{ "type": "public-key", "id": new Int8Array([1, -16, 9, 79, 6, -2, -82, 51, 124, -94, 95, 23, -86, 70, -43, 89, 91, -9, 45, -22, 91, -51, 84, 93, 24, -64, 38, 101, 126, -53, 87, 70, -49, -88, -105, 116, 33, 75, -39, -92, -5, 115, 12, 52, 124, -100, 85, 104, -15, 5, -13, 25, -74, 101, 71, -115, -102, 16, 10, -9, -19, -110, 65, 118, -28, 89, -15, -115, -81, 22, -104, 123, 17, -92, 49, 109, -38, -51, 100, 96, -65, 25, -48, 28, 106, -45, 17, -45, -37, 46, -5, -6, -26, -23, -108, 13, -66, -55, -117, -107, 119, 7, -32, 34, 46, 0, -29, -111, -32, 45, -15, -113, 110, 123, -44, 6, 10, 65, 99, 25, 105, 69, -127, 76, 127, -33, -89, -56, 74, 25, 43, -43, -56, 9, 87, 80, 124, -32, -39, 115, 17, 18, 78, 121, 69, -36, -44, -28, -109, -126, 58, 64, 80, -4, 21, 63, -19]).buffer }]'; + +export const allowMultipleCredentials70 = + 'allowCredentials: [{ "type": "public-key", "id": new Int8Array([-33, 120, 18, 124, 16, 5, -127, -51, 68, 85, 76, -92, -3, 25, 116, 91, 71, 53, 106, -114, -86, 9, -81, -96, -32, -110, 66, -23, -5, 104, 96, 46, 43, -66, -110, 8, -70, 11, 51, -93, 19, 124, 81, 78, 58, -97, 89, -87, -26, -112, 95, -83, 118, -25, 118, 3, 35, -96, 120, -76, -87, 83, -101, 82]).buffer },{ "type": "public-key", "id": new Int8Array([1, 73, 82, 23, 76, -5, -53, -48, -104, -17, -42, 45, 7, 35, 35, -72, -7, 37, 9, 37, 117, 42, 66, 116, 58, 25, -68, 53, 113, -10, 102, 3, -60, -81, -74, 96, -5, 111, -56, 110, -101, -54, -31, -123, -100, 3, 37, -69, -114, -19, -25, -62, 18, 122, 39, 11, 83, 60, -58, 3, 116, 10, -80, -35, 6, -128, -51, -92, 100, -115, -22, -122, 21, -65, 97, 67, -49, 26, 42, -11, 90, 121, -63, 112, -16, 118, -99, -73, -89, -67, 72, -80, 18, -72, 109, 4, -14, 1, -93, 17, -17, -70, -2, -5, -116, 111, -128, 7, 62, -34, -43, 110, 89, 113, -65, -95, 113, -5, -104, -100, -73, 42, 2, 112, -21, 41, 41, 91, 108, -102, 47, -77, -52, 70, 107, -4, 25, -120, 114, 30, 23, 103, 120, 17, 55, 91, -110, -58, -110, 13, -56, 57, -126, 36, 40, 89, -9]).buffer }]'; + +export const acceptableCredentials653 = + '{ "type": "public-key", "id": new Int8Array([53, -21, 26, -99, 5, 4, -112, -76, -126, 90, -35, -7, -31, -92, 19, -71, -39, 73, 52, -10, -14, -7, -59, -7, -36, -111, 64, 101, 29, 89, 90, -56, 108, 42, 32, -19, -113, 118, -114, 49, 109, -70, 68, -89, 36, -36, -103, -128, 34, -24, -40, -71, -125, -120, -80, 63, 25, -33, 2, 26, 111, -52, -15, -52]).buffer }'; + +export const acceptableMultipleCredentials653 = + '{ "type": "public-key", "id": new Int8Array([17, 88, -12, 26, -50, -48, -38, 36, -69, -105, -68, -38, 66, -53, -37, -50, -109, -126, 122, 26, 25, -45, 96, 37, -124, 102, 124, 94, -98, -59, 113, 94, 115, -111, -69, 45, 37, -83, 118, -115, -4, -49, 34, 115, -24, -49, -37, -17, -127, -15, 62, 18, 93, 122, -109, 53, -52, 44, -63, -74, 109, 2, -110, 45]).buffer },{ "type": "public-key", "id": new Int8Array([1, 83, -98, 32, 110, -62, 78, 53, -63, -118, 12, 122, -72, -15, 85, 48, -39, -97, -73, 108, -122, -60, 56, -112, -89, -118, 111, 0, -4, 13, -50, -43, -53, 28, 114, 82, 22, -76, 15, 51, -95, 26, -90, -93, -51, 115, -28, 85, -105, -27, 111, 70, 106, -28, 45, 126, 44, 63, -16, 97, -55, 31, -24, 57, -92, 48, 26, 127, -39, 75, 12, 13, 100, -77, 13, -48, 49, 52, 31, 85, 9, 63, -122, -90, -54, -87, -110, -1, 115, -122, -69, -15, 83, 57, 95, -31, -92, -116, 89, -109, -98, 21, 24, -80, -28, 103, -28, -82, -39, 114, -29, -46, -76, 123, -69, -44, 124, 10, 53, -103, 19, -43, -12, 62, -83, -86, 95, -78, 70, -105, 116, -25, 106, 54, -72, -119, 91, 91, -71, -49, 22, 25, -108, 112, -14, 55, 9, 75, 89, -91, -59, 45]).buffer }'; + +export const pubKeyCredParamsStr = + '[ { "type": "public-key", "alg": -257 }, { "type": "public-key", "alg": -7 } ]'; diff --git a/packages/journey-client/src/lib/journey-webauthn/helpers.test.ts b/packages/journey-client/src/lib/journey-webauthn/helpers.test.ts new file mode 100644 index 0000000000..bd2ad9ce68 --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/helpers.test.ts @@ -0,0 +1,54 @@ +/* + * @forgerock/javascript-sdk + * + * helpers.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { parseCredentials, parsePubKeyArray } from './helpers.js'; +import { + allowCredentials70, + allowMultipleCredentials70, + acceptableCredentials653, + acceptableMultipleCredentials653, + pubKeyCredParamsStr, +} from './helpers.mock.data.js'; + +describe('Test WebAuthn helper functions', () => { + it('should parse the one credential in the MetadataCallback 7.0', () => { + const credentials = parseCredentials(allowCredentials70); + expect(credentials[0].id.toString()).toBe('[object ArrayBuffer]'); + expect(credentials[0].type).toBe('public-key'); + }); + + it('should parse the two credentials in the MetadataCallback 7.0', () => { + const credentials = parseCredentials(allowMultipleCredentials70); + expect(credentials[0].id.toString()).toBe('[object ArrayBuffer]'); + expect(credentials[0].type).toBe('public-key'); + expect(credentials[1].id.toString()).toBe('[object ArrayBuffer]'); + expect(credentials[1].type).toBe('public-key'); + }); + + it('should parse the one credential in the MetadataCallback 6.5', () => { + const credentials = parseCredentials(acceptableCredentials653); + expect(credentials[0].id.toString()).toBe('[object ArrayBuffer]'); + expect(credentials[0].type).toBe('public-key'); + }); + + it('should parse the two credentials in the MetadataCallback 6.5', () => { + const credentials = parseCredentials(acceptableMultipleCredentials653); + expect(credentials[0].id.toString()).toBe('[object ArrayBuffer]'); + expect(credentials[0].type).toBe('public-key'); + expect(credentials[1].id.toString()).toBe('[object ArrayBuffer]'); + expect(credentials[1].type).toBe('public-key'); + }); + + it('should parse the pubKeyCredParams in the MetadataCallback 7.0 & 6.5.3', () => { + const pubKeyCredParams = parsePubKeyArray(pubKeyCredParamsStr); + expect(pubKeyCredParams).toContainEqual({ type: 'public-key', alg: -7 }); + expect(pubKeyCredParams).toContainEqual({ type: 'public-key', alg: -257 }); + }); +}); diff --git a/packages/journey-client/src/lib/journey-webauthn/helpers.ts b/packages/journey-client/src/lib/journey-webauthn/helpers.ts new file mode 100644 index 0000000000..079ecb9599 --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/helpers.ts @@ -0,0 +1,124 @@ +/* + * @forgerock/javascript-sdk + * + * helpers.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +/** + * @module + * @ignore + * These are private utility functions for HttpClient + */ +import { WebAuthnOutcomeType } from './enums.js'; +import type { ParsedCredential } from './interfaces.js'; + +function ensureArray(arr: RegExpMatchArray | null): string[] { + return arr || []; +} + +function arrayBufferToString(arrayBuffer: ArrayBuffer): string { + // https://goo.gl/yabPex - To future-proof, we'll pass along whatever the browser + // gives us and let AM disregard randomly-injected properties + const uint8Array = new Uint8Array(arrayBuffer); + const txtDecoder = new TextDecoder(); + + const json = txtDecoder.decode(uint8Array); + return json; +} + +function getIndexOne(arr: RegExpMatchArray | null): string { + return arr ? arr[1] : ''; +} + +// TODO: Remove this once AM is providing fully-serialized JSON +function parseCredentials(value: string): ParsedCredential[] { + try { + const creds = value + .split('}') + .filter((x) => !!x && x !== ']') + .map((x) => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const idArray = parseNumberArray(x); + return { + id: new Int8Array(idArray).buffer, + type: 'public-key' as PublicKeyCredentialType, + }; + }); + return creds; + } catch { + const e = new Error('Transforming credential object to string failed'); + e.name = WebAuthnOutcomeType.EncodingError; + throw e; + } +} + +function parseNumberArray(value: string): number[] { + const matches = /new Int8Array\((.+)\)/.exec(value); + if (matches === null || matches.length < 2) { + return []; + } + return JSON.parse(matches[1]); +} + +function parsePubKeyArray(value: string | unknown[]): PublicKeyCredentialParameters[] | undefined { + if (Array.isArray(value)) { + return value as PublicKeyCredentialParameters[]; + } + if (typeof value !== 'string') { + return undefined; + } + if (value && value[0] === '[') { + return JSON.parse(value); + } + value = value.replace(/(\w+):/g, '"$1":'); + return JSON.parse(`[${value}]`); +} + +function parseAllowCredentialsArray( + value: string | unknown[], +): PublicKeyCredentialDescriptor[] | undefined { + if (!value) { + return undefined; + } + if (Array.isArray(value)) { + return value as PublicKeyCredentialDescriptor[]; + } + if (typeof value !== 'string') { + return undefined; + } + if (value && value[0] === '[') { + return JSON.parse(value); + } + value = value.replace(/(\w+):/g, '"$1":'); + return JSON.parse(`[${value}]`); +} + +/** + * AM is currently serializing RP as one of the following formats, depending on + * whether RP ID has been configured: + * "relyingPartyId":"" + * "relyingPartyId":"rpId: \"foo\"," + * This regex handles both formats, but should be removed once AM is fixed. + */ +function parseRelyingPartyId(relyingPartyId: string): string { + if (relyingPartyId.includes('rpId')) { + return relyingPartyId.replace(/rpId: "(.+)",/, '$1'); + } else { + return relyingPartyId.replace(/id: "(.+)",/, '$1'); + } +} + +export { + ensureArray, + arrayBufferToString, + getIndexOne, + parseCredentials, + parseNumberArray, + parseAllowCredentialsArray, + parsePubKeyArray, + parseRelyingPartyId, +}; diff --git a/packages/journey-client/src/lib/journey-webauthn/index.ts b/packages/journey-client/src/lib/journey-webauthn/index.ts new file mode 100644 index 0000000000..e2598a3817 --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/index.ts @@ -0,0 +1,517 @@ +/* + * @forgerock/javascript-sdk + * + * index.ts + * + * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; +import type HiddenValueCallback from '../callbacks/hidden-value-callback.js'; +import type MetadataCallback from '../callbacks/metadata-callback.js'; +import type { JourneyStep } from '../journey-step.utils.js'; +import { WebAuthnOutcome, WebAuthnOutcomeType, WebAuthnStepType } from './enums.js'; +import { + arrayBufferToString, + parseCredentials, + parsePubKeyArray, + parseRelyingPartyId, +} from './helpers.js'; +import type { + AttestationType, + RelyingParty, + WebAuthnAuthenticationMetadata, + WebAuthnCallbacks, + WebAuthnRegistrationMetadata, + WebAuthnTextOutputRegistration, +} from './interfaces.js'; +import type TextOutputCallback from '../callbacks/text-output-callback.js'; +import { parseWebAuthnAuthenticateText, parseWebAuthnRegisterText } from './script-parser.js'; + +// :::::: +type OutcomeWithName< + ClientId extends string, + Attestation extends AttestationType, + PubKeyCred extends PublicKeyCredential, + Name = '', +> = Name extends infer P extends string + ? `${ClientId}::${Attestation}::${PubKeyCred['id']}${P extends '' ? '' : `::${P}`}` + : never; +// JSON-based WebAuthn +type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMetadata; + +/** + * Utility for integrating a web browser's WebAuthn API. + * + * Example: + * + * ```js + * // Determine if a step is a WebAuthn step + * const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + * if (stepType === WebAuthnStepType.Registration) { + * // Register a new device + * await JourneyWebAuthn.register(step); + * } else if (stepType === WebAuthnStepType.Authentication) { + * // Authenticate with a registered device + * await JourneyWebAuthn.authenticate(step); + * } + * ``` + */ +abstract class JourneyWebAuthn { + /** + * Determines if the given step is a WebAuthn step. + * + * @param step The step to evaluate + * @return A WebAuthnStepType value + */ + public static getWebAuthnStepType(step: JourneyStep): WebAuthnStepType { + const outcomeCallback = this.getOutcomeCallback(step); + const metadataCallback = this.getMetadataCallback(step); + const textOutputCallback = this.getTextOutputCallback(step); + + if (outcomeCallback && metadataCallback) { + const metadata = metadataCallback.getOutputValue('data') as { + pubKeyCredParams?: []; + }; + if (metadata?.pubKeyCredParams) { + return WebAuthnStepType.Registration; + } + + return WebAuthnStepType.Authentication; + } + + if (outcomeCallback && textOutputCallback) { + const message = textOutputCallback.getMessage() as string | WebAuthnTextOutputRegistration; + + if (message.includes('pubKeyCredParams')) { + return WebAuthnStepType.Registration; + } + + return WebAuthnStepType.Authentication; + } else { + return WebAuthnStepType.None; + } + } + + /** + * Populates the step with the necessary authentication outcome. + * + * @param step The step that contains WebAuthn authentication data + * @return The populated step + */ + public static async authenticate(step: JourneyStep): Promise { + const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step); + if (hiddenCallback && (metadataCallback || textOutputCallback)) { + let outcome: ReturnType; + let credential: PublicKeyCredential | null = null; + + try { + let publicKey: PublicKeyCredentialRequestOptions; + if (metadataCallback) { + const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata; + publicKey = this.createAuthenticationPublicKey(meta); + + credential = await this.getAuthenticationCredential( + publicKey as PublicKeyCredentialRequestOptions, + ); + outcome = this.getAuthenticationOutcome(credential); + } else if (textOutputCallback) { + publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage()); + + credential = await this.getAuthenticationCredential( + publicKey as PublicKeyCredentialRequestOptions, + ); + outcome = this.getAuthenticationOutcome(credential); + } else { + throw new Error('No Credential found from Public Key'); + } + } catch (error) { + if (!(error instanceof Error)) throw error; + // NotSupportedError is a special case + if (error.name === WebAuthnOutcomeType.NotSupportedError) { + hiddenCallback.setInputValue(WebAuthnOutcome.Unsupported); + throw error; + } + hiddenCallback.setInputValue(`${WebAuthnOutcome.Error}::${error.name}:${error.message}`); + throw error; + } + + if (metadataCallback) { + const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata; + if (meta?.supportsJsonResponse && credential && 'authenticatorAttachment' in credential) { + hiddenCallback.setInputValue( + JSON.stringify({ + authenticatorAttachment: credential.authenticatorAttachment, + legacyData: outcome, + }), + ); + return step; + } + } + hiddenCallback.setInputValue(outcome); + return step; + } else { + const e = new Error('Incorrect callbacks for WebAuthn authentication'); + e.name = WebAuthnOutcomeType.DataError; + hiddenCallback?.setInputValue(`${WebAuthnOutcome.Error}::${e.name}:${e.message}`); + throw e; + } + } + /** + * Populates the step with the necessary registration outcome. + * + * @param step The step that contains WebAuthn registration data + * @return The populated step + */ + // Can make this generic const in Typescript 5.0 > and the name itself will + // be inferred from the type so `typeof deviceName` will not just return string + // but the actual name of the deviceName passed in as a generic. + public static async register( + step: JourneyStep, + deviceName?: T, + ): Promise { + const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step); + if (hiddenCallback && (metadataCallback || textOutputCallback)) { + let outcome: OutcomeWithName; + let credential: PublicKeyCredential | null = null; + + try { + let publicKey: PublicKeyCredentialRequestOptions; + if (metadataCallback) { + const meta = metadataCallback.getOutputValue('data') as WebAuthnRegistrationMetadata; + publicKey = this.createRegistrationPublicKey(meta); + credential = await this.getRegistrationCredential( + publicKey as PublicKeyCredentialCreationOptions, + ); + outcome = this.getRegistrationOutcome(credential); + } else if (textOutputCallback) { + publicKey = parseWebAuthnRegisterText(textOutputCallback.getMessage()); + credential = await this.getRegistrationCredential( + publicKey as PublicKeyCredentialCreationOptions, + ); + outcome = this.getRegistrationOutcome(credential); + } else { + throw new Error('No Credential found from Public Key'); + } + } catch (error) { + if (!(error instanceof Error)) throw error; + // NotSupportedError is a special case + if (error.name === WebAuthnOutcomeType.NotSupportedError) { + hiddenCallback.setInputValue(WebAuthnOutcome.Unsupported); + throw error; + } + hiddenCallback.setInputValue(`${WebAuthnOutcome.Error}::${error.name}:${error.message}`); + throw error; + } + + if (metadataCallback) { + const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata; + if (meta?.supportsJsonResponse && credential && 'authenticatorAttachment' in credential) { + hiddenCallback.setInputValue( + JSON.stringify({ + authenticatorAttachment: credential.authenticatorAttachment, + legacyData: + deviceName && deviceName.length > 0 ? `${outcome}::${deviceName}` : outcome, + }), + ); + return step; + } + } + + hiddenCallback.setInputValue( + deviceName && deviceName.length > 0 ? `${outcome}::${deviceName}` : outcome, + ); + return step; + } else { + const e = new Error('Incorrect callbacks for WebAuthn registration'); + e.name = WebAuthnOutcomeType.DataError; + hiddenCallback?.setInputValue(`${WebAuthnOutcome.Error}::${e.name}:${e.message}`); + throw e; + } + } + + /** + * Returns an object containing the two WebAuthn callbacks. + * + * @param step The step that contains WebAuthn callbacks + * @return The WebAuthn callbacks + */ + public static getCallbacks(step: JourneyStep): WebAuthnCallbacks { + const hiddenCallback = this.getOutcomeCallback(step); + const metadataCallback = this.getMetadataCallback(step); + const textOutputCallback = this.getTextOutputCallback(step); + + const returnObj: WebAuthnCallbacks = { + hiddenCallback, + }; + + if (metadataCallback) { + returnObj.metadataCallback = metadataCallback; + } else if (textOutputCallback) { + returnObj.textOutputCallback = textOutputCallback; + } + return returnObj; + } + + /** + * Returns the WebAuthn metadata callback containing data to pass to the browser + * Web Authentication API. + * + * @param step The step that contains WebAuthn callbacks + * @return The metadata callback + */ + public static getMetadataCallback(step: JourneyStep): MetadataCallback | undefined { + return step.getCallbacksOfType(callbackType.MetadataCallback).find((x) => { + const cb = x.getOutputByName('data', undefined); + // eslint-disable-next-line no-prototype-builtins + return cb && cb.hasOwnProperty('relyingPartyId'); + }); + } + + /** + * Returns the WebAuthn hidden value callback where the outcome should be populated. + * + * @param step The step that contains WebAuthn callbacks + * @return The hidden value callback + */ + public static getOutcomeCallback(step: JourneyStep): HiddenValueCallback | undefined { + return step + .getCallbacksOfType(callbackType.HiddenValueCallback) + .find((x) => x.getOutputByName('id', '') === 'webAuthnOutcome'); + } + + /** + * Returns the WebAuthn metadata callback containing data to pass to the browser + * Web Authentication API. + * + * @param step The step that contains WebAuthn callbacks + * @return The metadata callback + */ + public static getTextOutputCallback(step: JourneyStep): TextOutputCallback | undefined { + return step + .getCallbacksOfType(callbackType.TextOutputCallback) + .find((x) => { + const cb = x.getOutputByName('message', undefined); + return cb && cb.includes('webAuthnOutcome'); + }); + } + + /** + * Retrieves the credential from the browser Web Authentication API. + * + * @param options The public key options associated with the request + * @return The credential + */ + public static async getAuthenticationCredential( + options: PublicKeyCredentialRequestOptions, + ): Promise { + // Feature check before we attempt registering a device + if (!window.PublicKeyCredential) { + const e = new Error('PublicKeyCredential not supported by this browser'); + e.name = WebAuthnOutcomeType.NotSupportedError; + throw e; + } + const credential = await navigator.credentials.get({ publicKey: options }); + return credential as PublicKeyCredential; + } + + /** + * Converts an authentication credential into the outcome expected by OpenAM. + * + * @param credential The credential to convert + * @return The outcome string + */ + public static getAuthenticationOutcome( + credential: PublicKeyCredential | null, + ): + | OutcomeWithName + | OutcomeWithName { + if (credential === null) { + const e = new Error('No credential generated from authentication'); + e.name = WebAuthnOutcomeType.UnknownError; + throw e; + } + + try { + const clientDataJSON = arrayBufferToString(credential.response.clientDataJSON); + const assertionResponse = credential.response as AuthenticatorAssertionResponse; + const authenticatorData = new Int8Array( + assertionResponse.authenticatorData, + ).toString() as AttestationType; + const signature = new Int8Array(assertionResponse.signature).toString(); + + // Current native typing for PublicKeyCredential does not include `userHandle` + // eslint-disable-next-line + // @ts-ignore + const userHandle = arrayBufferToString(credential.response.userHandle); + + let stringOutput = + `${clientDataJSON}::${authenticatorData}::${signature}::${credential.id}` as OutcomeWithName< + string, + AttestationType, + PublicKeyCredential + >; + // Check if Username is stored on device + if (userHandle) { + stringOutput = `${stringOutput}::${userHandle}`; + return stringOutput as OutcomeWithName< + string, + AttestationType, + PublicKeyCredential, + string + >; + } + + return stringOutput; + } catch { + const e = new Error('Transforming credential object to string failed'); + e.name = WebAuthnOutcomeType.EncodingError; + throw e; + } + } + + /** + * Retrieves the credential from the browser Web Authentication API. + * + * @param options The public key options associated with the request + * @return The credential + */ + public static async getRegistrationCredential( + options: PublicKeyCredentialCreationOptions, + ): Promise { + // Feature check before we attempt registering a device + if (!window.PublicKeyCredential) { + const e = new Error('PublicKeyCredential not supported by this browser'); + e.name = WebAuthnOutcomeType.NotSupportedError; + throw e; + } + const credential = await navigator.credentials.create({ + publicKey: options, + }); + return credential as PublicKeyCredential; + } + + /** + * Converts a registration credential into the outcome expected by OpenAM. + * + * @param credential The credential to convert + * @return The outcome string + */ + public static getRegistrationOutcome( + credential: PublicKeyCredential | null, + ): OutcomeWithName { + if (credential === null) { + const e = new Error('No credential generated from registration'); + e.name = WebAuthnOutcomeType.UnknownError; + throw e; + } + + try { + const clientDataJSON = arrayBufferToString(credential.response.clientDataJSON); + const attestationResponse = credential.response as AuthenticatorAttestationResponse; + const attestationObject = new Int8Array( + attestationResponse.attestationObject, + ).toString() as AttestationType.Direct; + return `${clientDataJSON}::${attestationObject}::${credential.id}`; + } catch { + const e = new Error('Transforming credential object to string failed'); + e.name = WebAuthnOutcomeType.EncodingError; + throw e; + } + } + + /** + * Converts authentication tree metadata into options required by the browser + * Web Authentication API. + * + * @param metadata The metadata provided in the authentication tree MetadataCallback + * @return The Web Authentication API request options + */ + public static createAuthenticationPublicKey( + metadata: WebAuthnAuthenticationMetadata, + ): PublicKeyCredentialRequestOptions { + const { + acceptableCredentials, + allowCredentials, + challenge, + relyingPartyId, + timeout, + userVerification, + } = metadata; + const rpId = parseRelyingPartyId(relyingPartyId); + const allowCredentialsValue = parseCredentials(allowCredentials || acceptableCredentials || ''); + + return { + challenge: Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)).buffer, + timeout, + // only add key-value pair if proper value is provided + ...(allowCredentialsValue && { allowCredentials: allowCredentialsValue }), + ...(userVerification && { userVerification }), + ...(rpId && { rpId }), + }; + } + + /** + * Converts authentication tree metadata into options required by the browser + * Web Authentication API. + * + * @param metadata The metadata provided in the authentication tree MetadataCallback + * @return The Web Authentication API request options + */ + public static createRegistrationPublicKey( + metadata: WebAuthnRegistrationMetadata, + ): PublicKeyCredentialCreationOptions { + const { pubKeyCredParams: pubKeyCredParamsString } = metadata; + const pubKeyCredParams = parsePubKeyArray(pubKeyCredParamsString); + if (!pubKeyCredParams) { + const e = new Error('Missing pubKeyCredParams property from registration options'); + e.name = WebAuthnOutcomeType.DataError; + throw e; + } + const excludeCredentials = parseCredentials(metadata.excludeCredentials); + + const { + attestationPreference, + authenticatorSelection, + challenge, + relyingPartyId, + relyingPartyName, + timeout, + userId, + userName, + displayName, + } = metadata; + const rpId = parseRelyingPartyId(relyingPartyId); + const rp: RelyingParty = { + name: relyingPartyName, + ...(rpId && { id: rpId }), + }; + + return { + attestation: attestationPreference, + authenticatorSelection: JSON.parse(authenticatorSelection), + challenge: Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)).buffer, + ...(excludeCredentials.length && { excludeCredentials }), + pubKeyCredParams, + rp, + timeout, + user: { + displayName: displayName || userName, + id: Int8Array.from(userId.split('').map((c: string) => c.charCodeAt(0))), + name: displayName || userName, + }, + }; + } +} + +export default JourneyWebAuthn; +export type { + RelyingParty, + WebAuthnAuthenticationMetadata, + WebAuthnCallbacks, + WebAuthnRegistrationMetadata, +}; +export { WebAuthnOutcome, WebAuthnStepType }; diff --git a/packages/journey-client/src/lib/journey-webauthn/interfaces.ts b/packages/journey-client/src/lib/journey-webauthn/interfaces.ts new file mode 100644 index 0000000000..3521cb621a --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/interfaces.ts @@ -0,0 +1,125 @@ +/* + * @forgerock/javascript-sdk + * + * interfaces.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import type HiddenValueCallback from '../callbacks/hidden-value-callback.js'; +import type MetadataCallback from '../callbacks/metadata-callback.js'; +import type TextOutputCallback from '../callbacks/text-output-callback.js'; + +enum AttestationType { + Direct = 'direct', + Indirect = 'indirect', + None = 'none', +} + +interface DeviceStepState extends StepState { + value1: number; + value2: number; +} + +enum UserVerificationType { + Discouraged = 'discouraged', + Preferred = 'preferred', + Required = 'required', +} + +interface RelyingParty { + name: string; + id?: string; +} + +interface ResponseCredential { + response: { clientDataJSON: ArrayBuffer }; +} + +interface Step { + data?: TData; + state: TState; + type: StepType; +} + +interface StepState { + authId: string; +} + +enum StepType { + DeviceAuthentication = 'DeviceAuthentication', + DeviceRegistration = 'DeviceRegistration', + DeviceRegistrationChoice = 'DeviceRegistrationChoice', + LoginFailure = 'LoginFailure', + LoginSuccess = 'LoginSuccess', + OneTimePassword = 'OneTimePassword', + SecondFactorChoice = 'SecondFactorChoice', + Username = 'Username', + UsernamePassword = 'UsernamePassword', + UserPassword = 'UserPassword', +} + +interface WebAuthnRegistrationMetadata { + attestationPreference: 'none' | 'indirect' | 'direct'; + authenticatorSelection: string; + challenge: string; + excludeCredentials: string; + pubKeyCredParams: string; + relyingPartyId: string; + relyingPartyName: string; + timeout: number; + userId: string; + userName: string; + displayName?: string; + supportsJsonResponse?: boolean; +} + +interface WebAuthnAuthenticationMetadata { + acceptableCredentials?: string; + allowCredentials?: string; + challenge: string; + relyingPartyId: string; + timeout: number; + userVerification: UserVerificationType; + supportsJsonResponse?: boolean; +} + +interface WebAuthnCallbacks { + hiddenCallback?: HiddenValueCallback; + metadataCallback?: MetadataCallback; + textOutputCallback?: TextOutputCallback; +} + +type WebAuthnTextOutputRegistration = string; + +interface ParsedCredential { + /** + * The WebAuthn API (specifically `PublicKeyCredentialDescriptor['id']`) expects a `BufferSource` type. + * In current TypeScript environments, `SharedArrayBuffer` is not directly assignable to `BufferSource` + * due to missing properties like `resizable`, `resize`, etc. + * Although `SharedArrayBuffer` might have been implicitly compatible in older environments, + * explicitly using `ArrayBuffer` ensures strict type compatibility with the WebAuthn API. + * The `script-parser.ts` already converts the ID to an `ArrayBuffer` before use. + * + * See: + * - W3C WebAuthn Level 3: https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialdescriptor + * - MDN BufferSource: https://developer.mozilla.org/en-US/docs/Web/API/BufferSource + */ + id: ArrayBuffer; + type: 'public-key'; +} + +export type { + DeviceStepState, + ParsedCredential, + RelyingParty, + ResponseCredential, + Step, + WebAuthnCallbacks, + WebAuthnAuthenticationMetadata, + WebAuthnRegistrationMetadata, + WebAuthnTextOutputRegistration, +}; +export { AttestationType, StepType, UserVerificationType }; diff --git a/packages/journey-client/src/lib/journey-webauthn/journey-webauthn.mock.data.ts b/packages/journey-client/src/lib/journey-webauthn/journey-webauthn.mock.data.ts new file mode 100644 index 0000000000..3aedfc83b0 --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/journey-webauthn.mock.data.ts @@ -0,0 +1,478 @@ +/* + * @forgerock/javascript-sdk + * + * fr-webauthn.mock.data.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; + +export const webAuthnRegJSCallback653 = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018-2020 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n */\n\nif (!window.PublicKeyCredential) {\n document.getElementById(\'webAuthnOutcome\').value = "unsupported";\n document.getElementById("loginButton_0").click();\n}\n\nvar publicKey = {\n challenge: new Int8Array([63, -71, 8, -32, 51, 11, 35, -85, -19, -93, -17, 9, -10, 104, 96, -5, -43, -94, 126, 123, 18, 44, -53, 27, 69, -59, -45, -113, 4, -120, -12, -17]).buffer,\n // Relying Party:\n rp: {\n \n name: "ForgeRock"\n },\n // User:\n user: {\n id: Uint8Array.from("sgsP5DNBy2TvEhwnWHu1BFRw2_LQepAdjkOfC1z6nxU", function (c) { return c.charCodeAt(0) }),\n name: "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629",\n displayName: "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629"\n },\n pubKeyCredParams: [ { "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 } ],\n attestation: "none",\n timeout: 60000,\n excludeCredentials: [],\n authenticatorSelection: {"userVerification":"discouraged"}\n};\n\nnavigator.credentials.create({publicKey: publicKey})\n .then(function (newCredentialInfo) {\n var rawId = newCredentialInfo.id;\n var clientData = String.fromCharCode.apply(null, new Uint8Array(newCredentialInfo.response.clientDataJSON));\n var keyData = new Int8Array(newCredentialInfo.response.attestationObject).toString();\n document.getElementById(\'webAuthnOutcome\').value = clientData + "::" + keyData + "::" + rawId;\n document.getElementById("loginButton_0").click();\n }).catch(function (err) {\n document.getElementById(\'webAuthnOutcome\').value = "ERROR" + "::" + err;\n document.getElementById("loginButton_0").click();\n });\n', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n *\n */\n\n/*\n * Note:\n *\n * When a ConfirmationCallback is used (e.g. during recovery code use), the XUI does not render a loginButton. However\n * the webAuthn script needs to call loginButton.click() to execute the appropriate data reformatting prior to sending\n * the request into AM. Here we query whether the loginButton exists and add it to the DOM if it doesn\'t.\n */\n\nvar newLocation = document.getElementById("wrapper");\n\nvar script = "
\\n" +\n "
\\n" +\n "
\\n" +\n "

Waiting for local device...

\\n" +\n "
\\n" +\n "
\\n";\n\nif (!document.getElementById("loginButton_0")) {\n script += "";\n} else {\n document.getElementById("loginButton_0").style.visibility=\'hidden\';\n}\n\nscript += "
";\n\nnewLocation.getElementsByTagName("fieldset")[0].innerHTML += script;\ndocument.body.appendChild(newLocation);', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken3', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnAuthJSCallback653 = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018-2020 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n */\n\nif (!window.PublicKeyCredential) {\n document.getElementById(\'webAuthnOutcome\').value = "unsupported";\n document.getElementById("loginButton_0").click();\n}\n\nvar acceptableCredentials = [\n { "type": "public-key", "id": new Int8Array([1, 97, 2, 123, -105, -19, -106, 10, -86, 82, -23, 5, 52, 63, 103, 110, -71, 53, 107, 104, 76, -42, -49, 96, 67, -114, -97, 19, -59, 89, -102, -115, -110, -101, -6, -98, 39, -75, 2, 74, 23, -105, 67, 6, -112, 21, -3, 36, -114, 52, 35, 75, 74, 82, -8, 115, -128, -34, -105, 110, 124, 41, -79, -53, -90, 81, -11, -7, 91, -45, -67, -82, 106, 74, 30, 112, 100, -47, 54, -12, 95, 81, 97, 36, 123, -15, -91, 87, -82, 87, -45, -103, -80, 109, -111, 82, 109, 58, 50, 19, -21, -102, 54, -108, -68, 12, -101, -53, -65, 11, -94, -36, 112, -101, -95, -90, -118, 68, 13, 8, -49, -77, -28, -82, -97, 126, -71, 33, -58, 19, 58, -118, 36, -28, 22, -55, 64, -72, -80, -9, -48, -50, 58, -52, 64, -64, -27, -5, -12, 110, -95, -17]).buffer }\n];\n\nvar options = {\n \n challenge: new Int8Array([109, 14, 35, -101, 97, -69, -105, -89, -58, 14, 108, 59, 45, 87, 109, -78, -51, 64, 90, 124, -97, 43, -84, -108, -69, -117, 101, -4, -36, -69, -106, 103]).buffer,\n timeout: 60000,\n userVerification: "discouraged",\n allowCredentials: acceptableCredentials\n};\n\nnavigator.credentials.get({ "publicKey" : options })\n .then(function (assertion) {\n var clientData = String.fromCharCode.apply(null, new Uint8Array(assertion.response.clientDataJSON));\n var authenticatorData = new Int8Array(assertion.response.authenticatorData).toString();\n var signature = new Int8Array(assertion.response.signature).toString();\n var rawId = assertion.id;\n document.getElementById(\'webAuthnOutcome\').value = clientData + "::" + authenticatorData + "::" + signature + "::" + rawId;\n document.getElementById("loginButton_0").click();\n }).catch(function (err) {\n document.getElementById(\'webAuthnOutcome\').value = "ERROR" + "::" + err;\n document.getElementById("loginButton_0").click();\n });\n', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n *\n */\n\n/*\n * Note:\n *\n * When a ConfirmationCallback is used (e.g. during recovery code use), the XUI does not render a loginButton. However\n * the webAuthn script needs to call loginButton.click() to execute the appropriate data reformatting prior to sending\n * the request into AM. Here we query whether the loginButton exists and add it to the DOM if it doesn\'t.\n */\n\nvar newLocation = document.getElementById("wrapper");\n\nvar script = "
\\n" +\n "
\\n" +\n "
\\n" +\n "

Waiting for local device...

\\n" +\n "
\\n" +\n "
\\n";\n\nif (!document.getElementById("loginButton_0")) {\n script += "";\n} else {\n document.getElementById("loginButton_0").style.visibility=\'hidden\';\n}\n\nscript += "
";\n\nnewLocation.getElementsByTagName("fieldset")[0].innerHTML += script;\ndocument.body.appendChild(newLocation);', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken3', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnRegJSCallback70 = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018-2020 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n */\n\nif (!window.PublicKeyCredential) {\n document.getElementById(\'webAuthnOutcome\').value = "unsupported";\n document.getElementById("loginButton_0").click();\n}\n\nvar publicKey = {\n challenge: new Int8Array([87, -95, 18, -17, -59, -3, -72, -9, -109, 77, -66, 67, 101, -59, -29, -92, -31, -58, 117, -14, 3, -123, 1, -54, -69, -122, 44, 111, 30, 49, 12, 81]).buffer,\n // Relying Party:\n rp: {\n id: "https://user.example.com:3002",\n name: "ForgeRock"\n },\n // User:\n user: {\n id: Uint8Array.from("NTdhNWI0ZTQtNjk5OS00YjQ1LWJmODYtYTRmMmU1ZDRiNjI5", function (c) { return c.charCodeAt(0) }),\n name: "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629",\n displayName: "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629"\n },\n pubKeyCredParams: [ { "type": "public-key", "alg": -257 }, { "type": "public-key", "alg": -7 } ],\n attestation: "none",\n timeout: 60000,\n excludeCredentials: [{ "type": "public-key", "id": new Int8Array([49, -96, -107, 113, 106, 5, 115, 22, 68, 121, -85, -27, 8, -58, -113, 127, -105, -37, -10, -12, -58, -25, 29, -82, -18, 69, -99, 125, 33, 82, 38, -66, -27, -128, -91, -86, 87, 68, 94, 0, -78, 70, -11, -70, -14, -53, 38, -60, 46, 27, 66, 46, 21, -125, -70, 123, -46, -124, 86, -2, 102, 70, -52, 54]).buffer },{ "type": "public-key", "id": new Int8Array([64, 17, -15, -123, -21, 127, 76, -120, 90, -112, -5, 54, 105, 93, 82, -104, -79, 107, -69, -3, -113, -94, -59, -4, 126, -33, 117, 32, -44, 122, -97, 8, -112, 105, -96, 96, 90, 44, -128, -121, 107, 79, -98, -68, -93, 11, -105, -47, 102, 13, 110, 84, 59, -91, -30, 37, -3, -22, 39, 111, -10, 87, -50, -35]).buffer }],\n authenticatorSelection: {"userVerification":"preferred"}\n};\n\nnavigator.credentials.create({publicKey: publicKey})\n .then(function (newCredentialInfo) {\n var rawId = newCredentialInfo.id;\n var clientData = String.fromCharCode.apply(null, new Uint8Array(newCredentialInfo.response.clientDataJSON));\n var keyData = new Int8Array(newCredentialInfo.response.attestationObject).toString();\n document.getElementById(\'webAuthnOutcome\').value = clientData + "::" + keyData + "::" + rawId;\n document.getElementById("loginButton_0").click();\n }).catch(function (err) {\n document.getElementById(\'webAuthnOutcome\').value = "ERROR" + "::" + err;\n document.getElementById("loginButton_0").click();\n });', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n *\n */\n\n/*\n * Note:\n *\n * When a ConfirmationCallback is used (e.g. during recovery code use), the XUI does not render a loginButton. However\n * the webAuthn script needs to call loginButton.click() to execute the appropriate data reformatting prior to sending\n * the request into AM. Here we query whether the loginButton exists and add it to the DOM if it doesn\'t.\n */\n\nvar newLocation = document.getElementById("wrapper");\n\nvar script = "
\\n" +\n "
\\n" +\n "
\\n" +\n "

Waiting for local device...

\\n" +\n "
\\n" +\n "
\\n";\n\nif (!document.getElementById("loginButton_0")) {\n script += "";\n} else {\n document.getElementById("loginButton_0").style.visibility=\'hidden\';\n}\n\nscript += "
";\n\nnewLocation.getElementsByTagName("fieldset")[0].innerHTML += script;\ndocument.body.appendChild(newLocation);', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken3', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnAuthJSCallback70 = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018-2020 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n */\n\nif (!window.PublicKeyCredential) {\n document.getElementById(\'webAuthnOutcome\').value = "unsupported";\n document.getElementById("loginButton_0").click();\n}\n\nvar options = {\n \n challenge: new Int8Array([-2, 85, 78, -80, -124, -6, -118, 15, 77, -30, -76, -27, -43, -19, -51, -68, 60, -80, -64, -102, 73, -103, 76, -77, -96, -28, 5, -23, -59, -36, 1, -1]).buffer,\n timeout: 60000,\n allowCredentials: [{ "type": "public-key", "id": new Int8Array([-107, 93, 68, -67, -5, 107, 18, 16, -25, -30, 80, 103, -75, -53, -2, -95, 102, 42, 47, 126, -1, 85, 93, 45, -85, 8, -108, 107, 47, -25, 66, 12, -96, 81, 104, -127, 26, -59, -69, -23, 75, 89, 58, 124, -93, 4, 28, -128, 121, 35, 39, 103, -86, -86, 123, -67, -7, -4, 79, -49, 127, -19, 7, 4]).buffer }]\n};\n\nnavigator.credentials.get({ "publicKey" : options })\n .then(function (assertion) {\n var clientData = String.fromCharCode.apply(null, new Uint8Array(assertion.response.clientDataJSON));\n var authenticatorData = new Int8Array(assertion.response.authenticatorData).toString();\n var signature = new Int8Array(assertion.response.signature).toString();\n var rawId = assertion.id;\n var userHandle = String.fromCharCode.apply(null, new Uint8Array(assertion.response.userHandle));\n document.getElementById(\'webAuthnOutcome\').value = clientData + "::" + authenticatorData + "::" + signature + "::" + rawId + "::" + userHandle;\n document.getElementById("loginButton_0").click();\n }).catch(function (err) {\n document.getElementById(\'webAuthnOutcome\').value = "ERROR" + "::" + err;\n document.getElementById("loginButton_0").click();\n });\n', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n *\n */\n\n/*\n * Note:\n *\n * When a ConfirmationCallback is used (e.g. during recovery code use), the XUI does not render a loginButton. However\n * the webAuthn script needs to call loginButton.click() to execute the appropriate data reformatting prior to sending\n * the request into AM. Here we query whether the loginButton exists and add it to the DOM if it doesn\'t.\n */\n\nvar newLocation = document.getElementById("wrapper");\n\nvar script = "
\\n" +\n "
\\n" +\n "
\\n" +\n "

Waiting for local device...

\\n" +\n "
\\n" +\n "
\\n";\n\nif (!document.getElementById("loginButton_0")) {\n script += "";\n} else {\n document.getElementById("loginButton_0").style.visibility=\'hidden\';\n}\n\nscript += "
";\n\nnewLocation.getElementsByTagName("fieldset")[0].innerHTML += script;\ndocument.body.appendChild(newLocation);', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken3', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnRegJSCallback70StoredUsername = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018-2020 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n */\n\nif (!window.PublicKeyCredential) {\n document.getElementById(\'webAuthnOutcome\').value = "unsupported";\n document.getElementById("loginButton_0").click();\n}\n\nvar publicKey = {\n challenge: new Int8Array([-90, -30, 14, -111, 43, -115, -125, 43, -96, 124, -109, -1, -100, -64, -52, -56, -15, -9, 41, 22, -111, -116, -65, -88, 108, -60, -58, 53, 62, -66, -34, 104]).buffer,\n // Relying Party:\n rp: {\n \n name: "ForgeRock"\n },\n // User:\n user: {\n id: Uint8Array.from("NTdhNWI0ZTQtNjk5OS00YjQ1LWJmODYtYTRmMmU1ZDRiNjI5", function (c) { return c.charCodeAt(0) }),\n name: "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629",\n displayName: "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629"\n },\n pubKeyCredParams: [ { "type": "public-key", "alg": -257 }, { "type": "public-key", "alg": -7 } ],\n attestation: "none",\n timeout: 60000,\n excludeCredentials: [],\n authenticatorSelection: {"userVerification":"preferred","requireResidentKey":true}\n};\n\nnavigator.credentials.create({publicKey: publicKey})\n .then(function (newCredentialInfo) {\n var rawId = newCredentialInfo.id;\n var clientData = String.fromCharCode.apply(null, new Uint8Array(newCredentialInfo.response.clientDataJSON));\n var keyData = new Int8Array(newCredentialInfo.response.attestationObject).toString();\n document.getElementById(\'webAuthnOutcome\').value = clientData + "::" + keyData + "::" + rawId;\n document.getElementById("loginButton_0").click();\n }).catch(function (err) {\n document.getElementById(\'webAuthnOutcome\').value = "ERROR" + "::" + err;\n document.getElementById("loginButton_0").click();\n });', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n *\n */\n\n/*\n * Note:\n *\n * When a ConfirmationCallback is used (e.g. during recovery code use), the XUI does not render a loginButton. However\n * the webAuthn script needs to call loginButton.click() to execute the appropriate data reformatting prior to sending\n * the request into AM. Here we query whether the loginButton exists and add it to the DOM if it doesn\'t.\n */\n\nvar newLocation = document.getElementById("wrapper");\n\nvar script = "
\\n" +\n "
\\n" +\n "
\\n" +\n "

Waiting for local device...

\\n" +\n "
\\n" +\n "
\\n";\n\nif (!document.getElementById("loginButton_0")) {\n script += "";\n} else {\n document.getElementById("loginButton_0").style.visibility=\'hidden\';\n}\n\nscript += "
";\n\nnewLocation.getElementsByTagName("fieldset")[0].innerHTML += script;\ndocument.body.appendChild(newLocation);', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken3', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnRegMetaCallback70 = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.MetadataCallback, + output: [ + { + name: 'data', + value: { + relyingPartyName: 'ForgeRock', + attestationPreference: 'none', + displayName: '57a5b4e4-6999-4b45-bf86-a4f2e5d4b629', + _type: 'WebAuthn', + relyingPartyId: '', + userName: '57a5b4e4-6999-4b45-bf86-a4f2e5d4b629', + authenticatorSelection: '{"userVerification":"preferred"}', + userId: 'NTdhNWI0ZTQtNjk5OS00YjQ1LWJmODYtYTRmMmU1ZDRiNjI5', + timeout: '60000', + excludeCredentials: '', + pubKeyCredParams: + '[ { "type": "public-key", "alg": -257 }, { "type": "public-key", "alg": -7 } ]', + challenge: 'PiIwSUMSo5qN7ahxaBVzWCHnpIxiWZPBix3PDI4/O8k=', + }, + }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken2', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnAuthMetaCallback70 = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.MetadataCallback, + output: [ + { + name: 'data', + value: { + userVerification: 'preferred', + _type: 'WebAuthn', + challenge: 'J7kVW1EpFY3thLYVMAAJuR9dswysFhqgrBT6vvSuidE=', + relyingPartyId: '', + allowCredentials: + 'allowCredentials: [{ "type": "public-key", "id": new Int8Array([1, 122, 110, -32, -105, -95, -90, 81, 20, -122, -96, -115, -115, 38, -7, 15, -127, 48, 48, 97, 94, -23, -54, 74, 3, -41, -118, -124, 112, 5, -77, 87, -11, 102, -86, 93, 27, 112, -128, 103, -58, -75, 68, -62, -62, 72, -27, 108, -59, 0, -124, -117, -121, -52, -97, -88, -112, 22, 122, 109, 104, -89, -10, 46, -95, 62, 64, 43, -42, 127, -53, -98, 88, -126, -68, -94, -5, 81, -71, -52, -54, -12, -55, 127, -125, 125, 53, -61, 61, 47, -66, -12, 25, 115, -24, -56, 95, 8, -20, -6, 4, 72, -45, -103, -52, 39, 123, 13, 9, -79, 99, -62, 84, -2, -41, 55, 125, 17, 126, -38, -80, -83, -28, 99, -26, -30, -18, 122, 92, -91, -128, -27, 4, 27, -39, 36, 117, 4, 120, 9, -24, -72, 84, 124, 25, 100, -40, 32, 63, -97, 119, 10, 73, 8, -46, 61, 56]).buffer },{ "type": "public-key", "id": new Int8Array([1, 15, 3, 3, 70, 54, 31, -27, -121, 121, 41, 83, -28, -49, 9, -113, -58, 117, -97, 18, 1, 100, -29, 6, -116, -93, 90, -91, 75, -120, -127, 50, 99, -37, -56, -41, 105, 42, 8, -87, -21, 37, -7, 96, -121, -125, -33, 79, 2, -10, 127, -117, 23, 46, 42, 29, 125, 91, 47, -101, 126, 44, 70, -84, -124, -94, -119, -87, 63, -116, 11, -28, 127, 76, -67, 36, -62, 62, -125, -82, 99, 71, 24, 32, -87, 93, 53, 97, -44, 18, -14, 77, 80, 77, 110, -80, -52, 18, 69, 127, 82, -27, -116, 42, -66, -53, -26, -29, 74, 75, 34, -88, -119, 118, -50, -70, -110, -68, -91, -15, 100, 113, 24, 13, -127, 39, -1, -85, 114, -125, 89, 89, -101, 94, -37, 82, -61, 15, -2, 3, -4, 9, 28, -75, -84, 96, 60, 85, -44, -98, -27, -29, 107, -115, -111, -3, -102]).buffer }]', + timeout: '60000', + }, + }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken2', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnRegMetaCallback70StoredUsername = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.MetadataCallback, + output: [ + { + name: 'data', + value: { + relyingPartyName: 'ForgeRock', + attestationPreference: 'none', + displayName: '57a5b4e4-6999-4b45-bf86-a4f2e5d4b629', + _type: 'WebAuthn', + relyingPartyId: '', + userName: '57a5b4e4-6999-4b45-bf86-a4f2e5d4b629', + authenticatorSelection: '{"userVerification":"preferred","requireResidentKey":true}', + userId: 'NTdhNWI0ZTQtNjk5OS00YjQ1LWJmODYtYTRmMmU1ZDRiNjI5', + timeout: '60000', + excludeCredentials: '', + pubKeyCredParams: + '[ { "type": "public-key", "alg": -257 }, { "type": "public-key", "alg": -7 } ]', + challenge: 'DfZ7CvBgBaApXZgcqSb+7/yA5ih/yRHhpDzrrWLMtZc=', + }, + }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken2', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnAuthMetaCallback70StoredUsername = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.MetadataCallback, + output: [ + { + name: 'data', + value: { + userVerification: 'preferred', + _type: 'WebAuthn', + challenge: 'OHmmFKfBhrUZKkuZJ84lf9N8TaRmQSjRdZyueeSIXAo=', + relyingPartyId: '', + allowCredentials: '', + timeout: '60000', + }, + }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken2', value: 'webAuthnOutcome' }], + }, + ], +}; + +export const webAuthnAuthMetaCallbackJsonResponse = { + authId: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoSW5kZXhWYWx1ZSI6IndlYmF1dGhuIiwib3RrIjoicXN1dTA0anNxZ2hmcGpubjFiM2IxdDh0NTQiLCJhdXRoSW5kZXhUeXBlIjoic2VydmljZSIsInJlYWxtIjoiLyIsInNlc3Npb25JZCI6IipBQUpUU1FBQ01ERUFCSFI1Y0dVQUNFcFhWRjlCVlZSSUFBSlRNUUFBKmV5SjBlWEFpT2lKS1YxUWlMQ0pqZEhraU9pSktWMVFpTENKaGJHY2lPaUpJVXpJMU5pSjkuWlhsS01HVllRV2xQYVVwTFZqRlJhVXhEU214aWJVMXBUMmxLUWsxVVNUUlJNRXBFVEZWb1ZFMXFWVEpKYVhkcFdWZDRia2xxYjJsYVIyeDVTVzR3TGk0M01qbDZSRXhsU0hwM1pXbFVPRll6VDB4cFgycEJMbGxpV1Y5WVl6WTJPVWxJU0MxT01VTk5Va05MZVROdFZFaHliVlJSVDJoV2VVSTBUVUUzYUVoRmFISjFTRU5SVWpWeWVsQnRZVzFpYlRNeFFqQlhSMlZxVEU5allUWllWMEUwTVROSlozRnVXV2gwV0hadlRHaE9VMkpDYUdkUlJFaGFSV1pLVmxaeWNsOTBTRUpJU1Y5elIyMVhNMEZYWm5wTk9WZElXblJMTm10ZmFITm9hemRNUTBkSVkwbzVhVGQ2Wnpob2FUbFROVm8wTVVkbk1rZFpXSFJJTnpoWlRGOVVaVTl5VWtOc1ozTlBkbDlWTWpGRFJ6STBReTFMWVRJNVMxRm9ibFYwTTNCVlozWldiamRCVUhWdWRFdEdaR2h6VERselJ6QlBTVnAwYzNoV1NqTXRPVmc1TmpSM2VrVldSV2w2Vnprek9XNTRlVm94VjNWVVgzWmpTRzFFV2t4eE0yWTJXamswVlVWTU5VNDJjMjVMTWs5U01XeDBOR3BrVld3eWVVMVRaVXR6YzJkb2VIRmlNMnhoZW1WQlVtMDBWM3BUVld4c1JFUTRVVVJuWDNoSFozSktlVmhQZWxCa1RWUnZYeTB0U1c0MFkxVjBTeloxTVdWSVkxOWhZbkZLTlhsRVpWcEpaM04yYkU5eE5qVTJkVU5KV0dzNU9GbElWMHBEWkhSR1MzcGtWV1k1ZG0xNlJIWk9iMmxXTlZnd2RXd3hiRzlTV0dOaFZtTkhVMDlaTlVGNFdITkJkSGd5UVhkVlVXUnViR0pmYTJodWN5MUhXblowZUVOM1lYRldlR2h1T1RsdVVWY3ljWFphUjNCTk1raFVPRzFMYUU5SVIyOVJOQzFWVkZrNVVWbDBNbGcwZGpaZlQzSk5kemxUZEdwSVl6RnRjMTkxVTI1VWVtVmpUR2RYUkVZdFVFNXNVM0J1Ymkxc1EyRlljRXREVmxsS1FVeDVUbWhoWDBJeGQwNTRSRzV0WW5vNVdYVjFXakZMYzFWTkxVZHJjVlJZYkY5c2JqUlBMVEpXVUhoTVFYSjFVblZOZW0xaVgxQndRMjlqY1d4T1Z6Sm1jWHBPV25seVlteE9RVEZXUkdaM04yYzJNMnhmTkhvd05UWkhlRXhOVjBOck5rOTZVQzFMY1RJMVlXTmxSa0ZQWWpGd1JtMXBkVGgzWW5kUGVITmtZa0ZLVW0xSWMxVlJWVzlQWm5aQlpURldORmcyUW5veGNFeG9SV3d0UzNGblkwMDBjMjluYTFab1YyRkhZWFpyVFUxSVFTNVlWRVpOV1d4R2F6bHFSV1V5VG5CamFIZDVZVzVuLmYyS2t1RlhnM05MUU1NbGNnMU1HU2Y2YTZQVmdJalhtUC1wcmJhQTNtTnciLCJleHAiOjE2MTM0OTc0OTksImlhdCI6MTYxMzQ5NzE5OX0.EuDmsY3C6I6vc_x7KlkW4rSQJY1FWevbGGmxkSu4HVU', + callbacks: [ + { + type: 'MetadataCallback', + output: [ + { + name: 'data', + value: { + _action: 'webauthn_authentication', + challenge: 'qnMsxgya8h6mUc6OyRu8jJ6Oq16tHV3cgE7juXGMDbg=', + allowCredentials: '', + _allowCredentials: [], + timeout: '60000', + userVerification: 'preferred', + relyingPartyId: 'rpId: "humorous-cuddly-carrot.glitch.me",', + _relyingPartyId: 'humorous-cuddly-carrot.glitch.me', + _type: 'WebAuthn', + supportsJsonResponse: true, + }, + }, + ], + }, + { + type: 'HiddenValueCallback', + output: [ + { + name: 'value', + value: 'false', + }, + { + name: 'id', + value: 'webAuthnOutcome', + }, + ], + input: [ + { + name: 'IDToken2', + value: 'webAuthnOutcome', + }, + ], + }, + ], +}; + +export const webAuthnRegMetaCallbackJsonResponse = { + authId: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ3aGl0ZWxpc3Qtc3RhdGUiOiJjMjMzNDRkMC04ZTlhLTRhM2QtODZkMS1mNTIwNTExZmM3NjciLCJhdXRoSW5kZXhWYWx1ZSI6IkFuZHlXZWJBdXRobiIsIm90ayI6ImFzdTNjMmo4YThta2w0aWQyN3FndGFuaTVqIiwiYXV0aEluZGV4VHlwZSI6InNlcnZpY2UiLCJyZWFsbSI6Ii9hbHBoYSIsInNlc3Npb25JZCI6IipBQUpUU1FBQ01ESUFCSFI1Y0dVQUNFcFhWRjlCVlZSSUFBSlRNUUFDTURFLipleUowZVhBaU9pSktWMVFpTENKamRIa2lPaUpLVjFRaUxDSmhiR2NpT2lKSVV6STFOaUo5LlpYbEtNR1ZZUVdsUGFVcExWakZSYVV4RFNteGliVTFwVDJsS1FrMVVTVFJSTUVwRVRGVm9WRTFxVlRKSmFYZHBXVmQ0YmtscWIybGFSMng1U1c0d0xpNVJkVXBMWTBnNGIwUjViemxZV0ZKcE0wNDJjQzFCTG0xRU0xOWFOVlZrZURGeVYxUk9lR2MwVUVWNFNHMW1OMGN5TkdoSU1YRXpPV0ZhVDNoaWREbFlUa000VEZscU1VRm1la1pXUzBkME1ubEJVMmxvT1hCNmJIRjNOVTQ0Y0ZsblZXRnplV2g1VEdWUFdtWldaVkpqVldNMmNGRjJOVmxhZEROdGNHWnZOMWRuV1hsWVNrRnZRWGQxZEhka2NYVlhaVXBYWkRScVl6TlBkRTFOTlVOV1NWaGtTRFZqVURGTVlrRjVTblJHWVd4NlIyWkplVGRtWW0xQll6SkZTbGxLUVdGNFZqTTRSa0l0TjFWVFFWZEVaMWxsUW14elYzQXRjWEZSYlRSdFIyUnBkRlpwYzBFdGNWRTFUbEpaU0cxQlJXSnpVVXBtWVdoRVQzSllUMFo1ZVZNM2VUUnVaV3BHUjNaWExVaFFRV054YjJ0TGNFMWZhVFkyWVRrdFZrbE9UMWxaY0RoWFQxaFFiR2hVTWt4YVIwVk1SVEpuVUhsWFkzcDJNV2xCVjBwcU4zVlVjVWx2UjFCTllVNUxSSEJKZGpaTk4wdFphVnA2YzJoUE9IRmxTMWw1VjJ3emNWWk9Tek4wWjJOamNGbERZVkJmT0ZGcGNuZG9iMkV3UjNOSWRIRk9SR04yYlhCUldIbE1ZM1Z3VGpkdmJGbDNhREV5WlVsTU9HaEZla0pLUldkMlVFaGpTM0JDYmxKVVYwOWlkemhJUlZobFJFZzRNbGxDWmxJdGVXMXpYMjlZT0c1dFpURldhbFJvUVZnMlRHZGtkbkUwVTJ4MmJYVjFYMTlzZVZack9GQnVZV3RDTldvMWVHOUtkVkUwTjFwRlZERnBUbGsxVkhCQlVHNUNOMjVuVVhaeWExRnlWR1ZHZFZKdU1EUlFPV1E0TTJ0MFVXcEhaMlpyUVdkbVNrNWpiek54YUZOR1dGb3dWM2swWldkUlpHdGhUVFU0ZGxSVVJtRlhlRjl1VERkdk9XdGFha3hXUjA4eFh6Sk9Oa0ZGVW5SVWFpMU9MVll6UmxwMlJYTnpRbnBTV1V0V2VGUm5UMTlRUTNWVmMxaDJjVzFVU1ZkcFNUQnhYekE0ZFhSVlVXcGpRMHhWUjNCVlZHRnVhR0pCVldKV1R6SnZVWHBVVTJ4bkxUTTJhakJWZVMxdlYxQTVNblo0ZVhvMWRUSkZkRVpwWkRaRGMxWmFMVVl3TFdkbWNFTnBkUzFGVlVzNFJITklhelZCUXpFNVp6VnRNMkY0Tmw5TFZuTlBkMmxIWDIxMU5tcHhOVzlsTlhkbFFtVm9iRE5RV0ZCTmFERTRTRWRrTTFOVVRHUmphVjl0VXpKTFJuYzJTM2xvWVd0a1dIcElXREZSTFdwRVIzWkRlRzF4YzJGcFZuUllhVlJSTXpSM1pVdGFhRk15VEVSVVpITklWM0JZTFdReFREa3lSMlJrVFc1UGJWRkhlV3A0WkRScU16TlFPVW96TVdGNGMyVXhZbDlFY1MxeWFXZHZZM2huTG1wR2VXWmxRVTlRVjBwMldUaHNaV3B1Wm1RemIyYy43YmdYcE5RNGRLSEpTeGpmUEVZZm44MGxZc3owaXBwNngyaURtRXlqd1JjIiwiZXhwIjoxNzQyODQzMDQ4LCJpYXQiOjE3NDI4NDI3NDh9.3zuPwPZVeFSwezhmzSZe-HW-22zo1HXwEPJO5jGl0Cg', + callbacks: [ + { + type: 'MetadataCallback', + output: [ + { + name: 'data', + value: { + _action: 'webauthn_registration', + challenge: 'QMmVc2lSU6G+jx6IYNOd6EaPz6X8jBzkxI9TMdVyWTw=', + attestationPreference: 'none', + userName: 'demo', + userId: 'NzcyNTI2NmMtYmZiZi00ZGFiLWFhYzEtNjY3NjUyMGIzNmZl', + relyingPartyName: 'ForgeRock', + authenticatorSelection: + '{"userVerification":"preferred","residentKey":"required","requireResidentKey":true}', + _authenticatorSelection: { + userVerification: 'preferred', + residentKey: 'required', + requireResidentKey: true, + }, + pubKeyCredParams: + '[ { "type": "public-key", "alg": -257 }, { "type": "public-key", "alg": -7 } ]', + _pubKeyCredParams: [ + { + type: 'public-key', + alg: -257, + }, + { + type: 'public-key', + alg: -7, + }, + ], + timeout: '60000', + excludeCredentials: '', + _excludeCredentials: [], + displayName: 'demo', + relyingPartyId: 'id: "idc.petrov.ca",', + _relyingPartyId: 'idc.petrov.ca', + extensions: {}, + _type: 'WebAuthn', + supportsJsonResponse: true, + }, + }, + ], + }, + { + type: 'HiddenValueCallback', + output: [ + { + name: 'value', + value: 'false', + }, + { + name: 'id', + value: 'webAuthnOutcome', + }, + ], + input: [ + { + name: 'IDToken2', + value: 'webAuthnOutcome', + }, + ], + }, + ], +}; + +export const webAuthnAuthJSCallback70StoredUsername = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018-2020 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n */\n\nif (!window.PublicKeyCredential) {\n document.getElementById(\'webAuthnOutcome\').value = "unsupported";\n document.getElementById("loginButton_0").click();\n}\n\nvar options = {\n \n challenge: new Int8Array([50, -11, 63, -112, 37, -61, 57, 126, 83, -127, 122, -42, -102, -82, 26, -95, -75, -37, 16, 52, 27, 54, -101, 124, -16, 99, 33, 92, 63, 10, -110, 102]).buffer,\n timeout: 60000,\n userVerification: "preferred",\n \n};\n\nnavigator.credentials.get({ "publicKey" : options })\n .then(function (assertion) {\n var clientData = String.fromCharCode.apply(null, new Uint8Array(assertion.response.clientDataJSON));\n var authenticatorData = new Int8Array(assertion.response.authenticatorData).toString();\n var signature = new Int8Array(assertion.response.signature).toString();\n var rawId = assertion.id;\n var userHandle = String.fromCharCode.apply(null, new Uint8Array(assertion.response.userHandle));\n document.getElementById(\'webAuthnOutcome\').value = clientData + "::" + authenticatorData + "::" + signature + "::" + rawId + "::" + userHandle;\n document.getElementById("loginButton_0").click();\n }).catch(function (err) {\n document.getElementById(\'webAuthnOutcome\').value = "ERROR" + "::" + err;\n document.getElementById("loginButton_0").click();\n });\n', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n *\n */\n\n/*\n * Note:\n *\n * When a ConfirmationCallback is used (e.g. during recovery code use), the XUI does not render a loginButton. However\n * the webAuthn script needs to call loginButton.click() to execute the appropriate data reformatting prior to sending\n * the request into AM. Here we query whether the loginButton exists and add it to the DOM if it doesn\'t.\n */\n\nvar newLocation = document.getElementById("wrapper");\n\nvar script = "
\\n" +\n "
\\n" +\n "
\\n" +\n "

Waiting for local device...

\\n" +\n "
\\n" +\n "
\\n";\n\nif (!document.getElementById("loginButton_0")) {\n script += "";\n} else {\n document.getElementById("loginButton_0").style.visibility=\'hidden\';\n}\n\nscript += "
";\n\nnewLocation.getElementsByTagName("fieldset")[0].innerHTML += script;\ndocument.body.appendChild(newLocation);', + }, + { name: 'messageType', value: '4' }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken3', value: 'webAuthnOutcome' }], + }, + ], +}; diff --git a/packages/journey-client/src/lib/journey-webauthn/journey-webauthn.test.ts b/packages/journey-client/src/lib/journey-webauthn/journey-webauthn.test.ts new file mode 100644 index 0000000000..66111608a7 --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/journey-webauthn.test.ts @@ -0,0 +1,100 @@ +/* + * @forgerock/javascript-sdk + * + * fr-webauthn.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { WebAuthnStepType } from './enums.js'; +import JourneyWebAuthn from './index.js'; +import { + webAuthnRegJSCallback653, + webAuthnAuthJSCallback653, + webAuthnRegJSCallback70, + webAuthnAuthJSCallback70, + webAuthnRegMetaCallback70, + webAuthnAuthMetaCallback70, + webAuthnRegJSCallback70StoredUsername, + webAuthnAuthJSCallback70StoredUsername, + webAuthnRegMetaCallback70StoredUsername, + webAuthnAuthMetaCallback70StoredUsername, +} from './journey-webauthn.mock.data.js'; +import { createJourneyStep } from '../journey-step.utils.js'; + +describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => { + it('should return Registration type with register text-output callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnRegJSCallback653 as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + expect(stepType).toBe(WebAuthnStepType.Registration); + }); + + it('should return Authentication type with authenticate text-output callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthJSCallback653 as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + expect(stepType).toBe(WebAuthnStepType.Authentication); + }); +}); + +describe('Test FRWebAuthn class with 7.0 "Passwordless"', () => { + it('should return Registration type with register text-output callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnRegJSCallback70 as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + expect(stepType).toBe(WebAuthnStepType.Registration); + }); + + it('should return Authentication type with authenticate text-output callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthJSCallback70 as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + console.log('the step type', stepType, WebAuthnStepType.Authentication); + expect(stepType).toBe(WebAuthnStepType.Authentication); + }); + + it('should return Registration type with register metadata callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnRegMetaCallback70 as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + expect(stepType).toBe(WebAuthnStepType.Registration); + }); + + it('should return Authentication type with authenticate metadata callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthMetaCallback70 as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + expect(stepType).toBe(WebAuthnStepType.Authentication); + }); +}); + +describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => { + it('should return Registration type with register text-output callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnRegJSCallback70StoredUsername as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + expect(stepType).toBe(WebAuthnStepType.Registration); + }); + + it('should return Authentication type with authenticate text-output callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthJSCallback70StoredUsername as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + expect(stepType).toBe(WebAuthnStepType.Authentication); + }); + it('should return Registration type with register metadata callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnRegMetaCallback70StoredUsername as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + expect(stepType).toBe(WebAuthnStepType.Registration); + }); + it('should return Authentication type with authenticate metadata callbacks', () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthMetaCallback70StoredUsername as any); + const stepType = JourneyWebAuthn.getWebAuthnStepType(step); + expect(stepType).toBe(WebAuthnStepType.Authentication); + }); +}); diff --git a/packages/journey-client/src/lib/journey-webauthn/script-parser.test.ts b/packages/journey-client/src/lib/journey-webauthn/script-parser.test.ts new file mode 100644 index 0000000000..9988255548 --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/script-parser.test.ts @@ -0,0 +1,123 @@ +/* + * @forgerock/javascript-sdk + * + * script-parser.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { parseWebAuthnAuthenticateText, parseWebAuthnRegisterText } from './script-parser.js'; +import { + authenticateInputWithRpidAndAllowCredentials, + authenticateInputWithRpidAndAllowCredentialsAndAllowRecoveryCode, + authenticateInputWithRpidAllowCredentialsAndQuotes, + authenticateInputWithoutRpidAndAllowCredentials, + authenticateInputWithAcceptableCredentialsWithoutRpid, + registerInputWithRpid, + registerInputWithRpidAndQuotes, + registerOutputWithRpid, + registerInputWithoutRpid, + registerOutputWithoutRpid, + registerInputWithExcludeCreds, +} from './script-text.mock.data.js'; + +describe('Parsing of the WebAuthn script type text', () => { + it('should parse the WebAuthn authenticate block of text with rpid and allow credentials', () => { + const obj = parseWebAuthnAuthenticateText(authenticateInputWithRpidAndAllowCredentials); + expect(obj.allowCredentials).toBeDefined(); + if (obj.allowCredentials) { + expect(obj.allowCredentials[0].type).toBe('public-key'); + expect(obj.allowCredentials[0].id.byteLength > 0).toBe(true); + } + expect(obj.challenge.byteLength > 0).toBe(true); + expect(obj.timeout).toBe(60000); + expect(obj.rpId).toBe('example.com'); + }); + it('should parse the WebAuthn authenticate block of text with rpid and allow credentials & Allow recovery code', () => { + const obj = parseWebAuthnAuthenticateText( + authenticateInputWithRpidAndAllowCredentialsAndAllowRecoveryCode, + ); + expect(obj.allowCredentials).toBeDefined(); + if (obj.allowCredentials) { + expect(obj.allowCredentials[0].type).toBe('public-key'); + expect(obj.allowCredentials[0].id.byteLength > 0).toBe(true); + } + expect(obj.challenge.byteLength > 0).toBe(true); + expect(obj.timeout).toBe(60000); + expect(obj.userVerification).toBe('preferred'); + }); + it('should parse the WebAuthn authenticate block of text with quoted keys', () => { + const obj = parseWebAuthnAuthenticateText(authenticateInputWithRpidAllowCredentialsAndQuotes); + expect(obj.allowCredentials).toBeDefined(); + if (obj.allowCredentials) { + expect(obj.allowCredentials[0].type).toBe('public-key'); + expect(obj.allowCredentials[0].id.byteLength > 0).toBe(true); + } + expect(obj.challenge.byteLength > 0).toBe(true); + expect(obj.timeout).toBe(60000); + expect(obj.rpId).toBe('example.com'); + }); + + it('should parse the WebAuthn authenticate block from 6.5.3 text', () => { + const obj = parseWebAuthnAuthenticateText( + authenticateInputWithAcceptableCredentialsWithoutRpid, + ); + expect(obj.allowCredentials).toBeDefined(); + if (obj.allowCredentials) { + expect(obj.allowCredentials[0].type).toBe('public-key'); + expect(obj.allowCredentials[0].id.byteLength > 0).toBe(true); + } + expect(obj.challenge.byteLength > 0).toBe(true); + expect(obj.timeout).toBe(60000); + }); + + it('should parse the WebAuthn authenticate block of text', () => { + const obj = parseWebAuthnAuthenticateText(authenticateInputWithoutRpidAndAllowCredentials); + expect(obj.allowCredentials).toBe(undefined); + expect(obj.rpId).toBe(undefined); + }); + + it('should parse the WebAuthn register block of text with rpid', () => { + const obj = parseWebAuthnRegisterText(registerInputWithRpid); + expect(obj.attestation).toBe(registerOutputWithRpid.attestation); + expect(obj.authenticatorSelection).toStrictEqual(registerOutputWithRpid.authenticatorSelection); + expect(obj.challenge.byteLength > 0).toBe(true); + expect(obj.pubKeyCredParams).toContainEqual(registerOutputWithRpid.pubKeyCredParams[0]); + expect(obj.pubKeyCredParams).toContainEqual(registerOutputWithRpid.pubKeyCredParams[1]); + expect(obj.rp).toStrictEqual(registerOutputWithRpid.rp); + expect(obj.timeout).toBe(registerOutputWithRpid.timeout); + expect(obj.user.displayName).toStrictEqual(registerOutputWithRpid.user.displayName); + expect(obj.user.name).toBe(registerOutputWithRpid.user.name); + expect(obj.user.id.byteLength > 0).toBe(true); + }); + + it('should parse the WebAuthn register block of text with rpid and quoted keys', () => { + const obj = parseWebAuthnRegisterText(registerInputWithRpidAndQuotes); + expect(obj.attestation).toBe(registerOutputWithRpid.attestation); + expect(obj.authenticatorSelection).toStrictEqual(registerOutputWithRpid.authenticatorSelection); + expect(obj.challenge.byteLength > 0).toBe(true); + expect(obj.pubKeyCredParams).toContainEqual(registerOutputWithRpid.pubKeyCredParams[0]); + expect(obj.pubKeyCredParams).toContainEqual(registerOutputWithRpid.pubKeyCredParams[1]); + expect(obj.rp).toStrictEqual(registerOutputWithRpid.rp); + expect(obj.timeout).toBe(registerOutputWithRpid.timeout); + expect(obj.user.displayName).toStrictEqual(registerOutputWithRpid.user.displayName); + expect(obj.user.name).toBe(registerOutputWithRpid.user.name); + expect(obj.user.id.byteLength > 0).toBe(true); + }); + + it('should parse the WebAuthn register block of text withOUT rpid', () => { + const obj = parseWebAuthnRegisterText(registerInputWithoutRpid); + expect(obj.rp).toStrictEqual(registerOutputWithoutRpid.rp); + }); + + it('should parse the WebAuthn register block of text with exclude creds', () => { + const obj = parseWebAuthnRegisterText(registerInputWithExcludeCreds); + expect(obj.excludeCredentials).toBeDefined(); + if (obj.excludeCredentials) { + expect(obj.excludeCredentials.length).toBe(2); + expect(obj.excludeCredentials[0].type).toBe('public-key'); + } + }); +}); diff --git a/packages/journey-client/src/lib/journey-webauthn/script-parser.ts b/packages/journey-client/src/lib/journey-webauthn/script-parser.ts new file mode 100644 index 0000000000..3e3b54d9a6 --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/script-parser.ts @@ -0,0 +1,192 @@ +/* eslint-disable no-useless-escape */ +/* + * @forgerock/javascript-sdk + * + * script-parser.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { WebAuthnOutcomeType } from './enums.js'; +import { ensureArray, getIndexOne, parsePubKeyArray, parseCredentials } from './helpers.js'; +import type { AttestationType, UserVerificationType } from './interfaces.js'; + +function parseWebAuthnRegisterText(text: string): PublicKeyCredentialCreationOptions { + const txtEncoder = new TextEncoder(); + + // TODO: Incrementally move to `*` instead of `{0,}` + // e.g. `attestation: "none"` + const attestation = getIndexOne(text.match(/attestation"{0,}:\s{0,}"(\w+)"/)) as AttestationType; + // e.g. `timeout: 60000` + const timeout = Number(getIndexOne(text.match(/timeout"{0,}:\s{0,}(\d+)/))); + // e.g. from 7.0: `"userVerification":"preferred"` + // e.g. from 6.5: `userVerification: "preferred"` + const userVerification = getIndexOne( + text.match(/userVerification"{0,}:\s{0,}"(\w+)"/), + ) as UserVerificationType; + // e.g. `"requireResidentKey":true` + const requireResidentKey = getIndexOne( + text.match(/requireResidentKey"{0,}:\s{0,}(\w+)/), + ) as string; + // e.g. `"authenticatorAttachment":"cross-platform"` + const authenticatorAttachment = getIndexOne( + text.match(/authenticatorAttachment"{0,}:\s{0,}"([\w-]+)/), + ) as AuthenticatorAttachment; + + // e.g. `rp: {\n id: \"https://user.example.com:3002\",\n name: \"ForgeRock\"\n }` + const rp = getIndexOne(text.match(/rp"{0,}:\s{0,}{([^}]+)}/)).trim(); + // e.g. `id: \"example.com\" + const rpId = getIndexOne(rp.match(/id"{0,}:\s{0,}"([^"]*)"/)); + // e.g. `name: \"ForgeRock\"` + const rpName = getIndexOne(rp.match(/name"{0,}:\s{0,}"([^"]*)"/)); + + // e.g. `user: {\n id: Uint8Array.from(\"NTdhN...RiNjI5\", + // function (c) { return c.charCodeAt(0) }),\n + // name: \"57a5b4e4-...-a4f2e5d4b629\",\n + // displayName: \"57a5b4e4-...-a4f2e5d4b629\"\n }` + const user = getIndexOne(text.match(/user"{0,}:\s{0,}{([^]{0,})},/)).trim(); + // e.g `id: Uint8Array.from(\"NTdhN...RiNjI5\",` + const userId = getIndexOne(user.match(/id"{0,}:\s{0,}Uint8Array.from\("([^"]+)"/)); + // e.g. `name: \"57a5b4e4-...-a4f2e5d4b629\",` + const userName = getIndexOne(user.match(/name"{0,}:\s{0,}"([\d\w._-]+)"/)); + // e.g. `displayName: \"57a5b4e4-...-a4f2e5d4b629\"` + const userDisplayName = getIndexOne(user.match(/displayName"{0,}:\s{0,}"([\d\w\s.@_-]+)"/)); + + // e.g. `pubKeyCredParams: [ + // { \"type\": \"public-key\", \"alg\": -257 }, { \"type\": \"public-key\", \"alg\": -7 } + // ]` + const pubKeyCredParamsString = getIndexOne( + // Capture the `pubKeyCredParams` without also matching `excludeCredentials` as well. + // `excludeCredentials` values are very similar to this property, so we need to make sure + // our last value doesn't end with "buffer", so we are only capturing objects that + // end in a digit and possibly a space. + text.match(/pubKeyCredParams"*:\s*\[([^]+\d\s*})\s*]/), + ).trim(); + // e.g. `{ \"type\": \"public-key\", \"alg\": -257 }, { \"type\": \"public-key\", \"alg\": -7 }` + const pubKeyCredParams = parsePubKeyArray(pubKeyCredParamsString); + if (!pubKeyCredParams) { + const e = new Error('Missing pubKeyCredParams property from registration options'); + e.name = WebAuthnOutcomeType.DataError; + throw e; + } + + // e.g. `excludeCredentials: [{ + // \"type\": \"public-key\", \"id\": new Int8Array([-18, 69, -99, 82, 38, -66]).buffer }, + // { \"type\": \"public-key\", \"id\": new Int8Array([64, 17, -15, 56, -32, 91]).buffer }],\n` + const excludeCredentialsString = getIndexOne( + text.match(/excludeCredentials"{0,}:\s{0,}\[([^]+)\s{0,}]/), + ).trim(); + // e.g. `{ \"type\": \"public-key\", \"id\": new Int8Array([-18, 69, -99, 82, 38, -66]).buffer }, + // { \"type\": \"public-key\", \"id\": new Int8Array([64, 17, -15, 56, -32, 91]).buffer }` + const excludeCredentials = parseCredentials(excludeCredentialsString); + + // e.g. `challenge: new Int8Array([87, -95, 18, ... -3, 49, 12, 81]).buffer,` + const challengeArr: string[] = ensureArray( + text.match(/challenge"{0,}:\s{0,}new\s{0,}(Uint|Int)8Array\(([^\)]+)/), + ); + // e.g. `[87, -95, 18, ... -3, 49, 12, 81]` + const challengeJSON = JSON.parse(challengeArr[2]); + // e.g. [87, -95, 18, ... -3, 49, 12, 81] + const challenge = new Int8Array(challengeJSON).buffer; + + return { + attestation, + authenticatorSelection: { + userVerification, + // Only include authenticatorAttachment prop if the value is truthy + ...(authenticatorAttachment && { authenticatorAttachment }), + // Only include requireResidentKey prop if the value is of string "true" + ...(requireResidentKey === 'true' && { + requireResidentKey: !!requireResidentKey, + }), + }, + challenge, + ...(excludeCredentials.length && { excludeCredentials }), + pubKeyCredParams, + rp: { + name: rpName, + // only add key-value pair if truthy value is provided + ...(rpId && { id: rpId }), + }, + timeout, + user: { + displayName: userDisplayName, + id: txtEncoder.encode(userId), + name: userName, + }, + }; +} + +function parseWebAuthnAuthenticateText(text: string): PublicKeyCredentialRequestOptions { + let allowCredentials; + let allowCredentialsText; + + if (text.includes('acceptableCredentials')) { + // e.g. `var acceptableCredentials = [ + // { "type": "public-key", "id": new Int8Array([1, 97, 2, 123, ... -17]).buffer } + // ];` + allowCredentialsText = getIndexOne( + text.match(/acceptableCredentials"*\s*=\s*\[([^]+)\s*]/), + ).trim(); + } else { + // e.g. `allowCredentials: [ + // { \"type\": \"public-key\", + // \"id\": new Int8Array([-107, 93, 68, -67, ... -19, 7, 4]).buffer } + // ]` + allowCredentialsText = getIndexOne( + text.match(/allowCredentials"{0,}:\s{0,}\[([^]+)\s{0,}]/), + ).trim(); + } + // e.g. `"userVerification":"preferred"` + const userVerification = getIndexOne( + text.match(/userVerification"{0,}:\s{0,}"(\w+)"/), + ) as UserVerificationType; + + if (allowCredentialsText) { + // Splitting objects in array in case the user has multiple keys + const allowCredentialArr = allowCredentialsText.split('},') || [allowCredentialsText]; + // Iterating over array of substrings + allowCredentials = allowCredentialArr.map((str) => { + // e.g. `{ \"type\": \"public-key\", + const type = getIndexOne(str.match(/type"{0,}:\s{0,}"([\w-]+)"/)) as 'public-key'; + // e.g. \"id\": new Int8Array([-107, 93, 68, -67, ... -19, 7, 4]).buffer + const idArr = ensureArray(str.match(/id"{0,}:\s{0,}new\s{0,}(Uint|Int)8Array\(([^\)]+)/)); + // e.g. `[-107, 93, 68, -67, ... -19, 7, 4]` + const idJSON = JSON.parse(idArr[2]); + // e.g. [-107, 93, 68, -67, ... -19, 7, 4] + const id = new Int8Array(idJSON).buffer; + + return { + type, + id, + }; + }); + } + + // e.g. `timeout: 60000` + const timeout = Number(getIndexOne(text.match(/timeout"{0,}:\s{0,}(\d+)/))); + + // e.g. `challenge: new Int8Array([87, -95, 18, ... -3, 49, 12, 81]).buffer,` + const challengeArr: string[] = ensureArray( + text.match(/challenge"{0,}:\s{0,}new\s{0,}(Uint|Int)8Array\(([^\)]+)/), + ); + // e.g. `[87, -95, 18, ... -3, 49, 12, 81]` + const challengeJSON = JSON.parse(challengeArr[2]); + // e.g. [87, -95, 18, ... -3, 49, 12, 81] + const challenge = new Int8Array(challengeJSON).buffer; + // e.g. `rpId: \"example.com\"` + const rpId = getIndexOne(text.match(/rpId"{0,}:\s{0,}\\{0,}"([^"\\]*)/)); + + return { + challenge, + timeout, + // only add key-value pairs if the truthy values are provided + ...(allowCredentials && { allowCredentials }), + ...(userVerification && { userVerification }), + ...(rpId && { rpId }), + }; +} + +export { parseWebAuthnAuthenticateText, parseWebAuthnRegisterText }; diff --git a/packages/journey-client/src/lib/journey-webauthn/script-text.mock.data.ts b/packages/journey-client/src/lib/journey-webauthn/script-text.mock.data.ts new file mode 100644 index 0000000000..462779ba7a --- /dev/null +++ b/packages/journey-client/src/lib/journey-webauthn/script-text.mock.data.ts @@ -0,0 +1,463 @@ +/* + * @forgerock/javascript-sdk + * + * script-text.mock.data.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +export const authenticateInputWithRpidAndAllowCredentials = `/* +* Copyright 2018-2020 ForgeRock AS. All Rights Reserved +* +* Use of this code requires a commercial software license with ForgeRock AS. +* or with one of its affiliates. All use shall be exclusively subject +* to such license between the licensee and ForgeRock AS. +*/ + +if (!window.PublicKeyCredential) { + document.getElementById('webAuthnOutcome').value = "unsupported"; + document.getElementById("loginButton_0").click(); +} + +var options = { + rpId: "example.com", + challenge: new Int8Array([14, 126, -110, -74, 64, -66, 20, -56, -40, -28, 116, -61, -128, -20, 72, 24, 42, 79, -105, 94, -84, -12, -17, -97, 105, -31, -30, 92, 55, 67, -83, 65]).buffer, + timeout: 60000, + allowCredentials: [{ type: "public-key", id: new Int8Array([-107, 93, 68, -67, -5, 107, 18, 16, -25, -30, 80, 103, -75, -53, -2, -95, 102, 42, 47, 126, -1, 85, 93, 45, -85, 8, -108, 107, 47, -25, 66, 12, -96, 81, 104, -127, 26, -59, -69, -23, 75, 89, 58, 124, -93, 4, 28, -128, 121, 35, 39, 103, -86, -86, 123, -67, -7, -4, 79, -49, 127, -19, 7, 4]).buffer }] +}; + +navigator.credentials.get({ "publicKey" : options }) + .then(function (assertion) { + var clientData = String.fromCharCode.apply(null, new Uint8Array(assertion.response.clientDataJSON)); + var authenticatorData = new Int8Array(assertion.response.authenticatorData).toString(); + var signature = new Int8Array(assertion.response.signature).toString(); + var rawId = assertion.id; + var userHandle = String.fromCharCode.apply(null, new Uint8Array(assertion.response.userHandle)); + document.getElementById('webAuthnOutcome').value = clientData + "::" + authenticatorData + "::" + signature + "::" + rawId + "::" + userHandle; + document.getElementById("loginButton_0").click(); + }).catch(function (err) { + document.getElementById('webAuthnOutcome').value = "ERROR" + "::" + err; + document.getElementById("loginButton_0").click(); + });`; + +export const authenticateInputWithRpidAllowCredentialsAndQuotes = `/* +* Copyright 2018-2020 ForgeRock AS. All Rights Reserved +* +* Use of this code requires a commercial software license with ForgeRock AS. +* or with one of its affiliates. All use shall be exclusively subject +* to such license between the licensee and ForgeRock AS. +*/ + +if (!window.PublicKeyCredential) { + document.getElementById('webAuthnOutcome').value = "unsupported"; + document.getElementById("loginButton_0").click(); +} + +var options = { + "rpId": "example.com", + "challenge": new Int8Array([14, 126, -110, -74, 64, -66, 20, -56, -40, -28, 116, -61, -128, -20, 72, 24, 42, 79, -105, 94, -84, -12, -17, -97, 105, -31, -30, 92, 55, 67, -83, 65]).buffer, + "timeout": 60000, + "allowCredentials": [{ "type": "public-key", "id": new Int8Array([-107, 93, 68, -67, -5, 107, 18, 16, -25, -30, 80, 103, -75, -53, -2, -95, 102, 42, 47, 126, -1, 85, 93, 45, -85, 8, -108, 107, 47, -25, 66, 12, -96, 81, 104, -127, 26, -59, -69, -23, 75, 89, 58, 124, -93, 4, 28, -128, 121, 35, 39, 103, -86, -86, 123, -67, -7, -4, 79, -49, 127, -19, 7, 4]).buffer }] +}; + +navigator.credentials.get({ "publicKey" : options }) + .then(function (assertion) { + var clientData = String.fromCharCode.apply(null, new Uint8Array(assertion.response.clientDataJSON)); + var authenticatorData = new Int8Array(assertion.response.authenticatorData).toString(); + var signature = new Int8Array(assertion.response.signature).toString(); + var rawId = assertion.id; + var userHandle = String.fromCharCode.apply(null, new Uint8Array(assertion.response.userHandle)); + document.getElementById('webAuthnOutcome').value = clientData + "::" + authenticatorData + "::" + signature + "::" + rawId + "::" + userHandle; + document.getElementById("loginButton_0").click(); + }).catch(function (err) { + document.getElementById('webAuthnOutcome').value = "ERROR" + "::" + err; + document.getElementById("loginButton_0").click(); + });`; + +export const authenticateInputWithoutRpidAndAllowCredentials = `/* +* Copyright 2018-2020 ForgeRock AS. All Rights Reserved +* +* Use of this code requires a commercial software license with ForgeRock AS. +* or with one of its affiliates. All use shall be exclusively subject +* to such license between the licensee and ForgeRock AS. +*/ + +if (!window.PublicKeyCredential) { + document.getElementById('webAuthnOutcome').value = "unsupported"; + document.getElementById("loginButton_0").click(); +} + +var options = { + challenge: new Int8Array([14, 126, -110, -74, 64, -66, 20, -56, -40, -28, 116, -61, -128, -20, 72, 24, 42, 79, -105, 94, -84, -12, -17, -97, 105, -31, -30, 92, 55, 67, -83, 65]).buffer, + timeout: 60000, +}; + +navigator.credentials.get({ "publicKey" : options }) + .then(function (assertion) { + var clientData = String.fromCharCode.apply(null, new Uint8Array(assertion.response.clientDataJSON)); + var authenticatorData = new Int8Array(assertion.response.authenticatorData).toString(); + var signature = new Int8Array(assertion.response.signature).toString(); + var rawId = assertion.id; + var userHandle = String.fromCharCode.apply(null, new Uint8Array(assertion.response.userHandle)); + document.getElementById('webAuthnOutcome').value = clientData + "::" + authenticatorData + "::" + signature + "::" + rawId + "::" + userHandle; + document.getElementById("loginButton_0").click(); + }).catch(function (err) { + document.getElementById('webAuthnOutcome').value = "ERROR" + "::" + err; + document.getElementById("loginButton_0").click(); + });`; + +// AM 6.5.3 variant of JS text string +export const authenticateInputWithAcceptableCredentialsWithoutRpid = `/* +* Copyright 2018-2020 ForgeRock AS. All Rights Reserved +* +* Use of this code requires a commercial software license with ForgeRock AS. +* or with one of its affiliates. All use shall be exclusively subject +* to such license between the licensee and ForgeRock AS. +*/ + +if (!window.PublicKeyCredential) { + document.getElementById('webAuthnOutcome').value = "unsupported"; + document.getElementById("loginButton_0").click(); +} + +var acceptableCredentials = [ + { "type": "public-key", "id": new Int8Array([1, 97, 2, 123, -105, -19, -106, 10, -86, 82, -23, 5, 52, 63, 103, 110, -71, 53, 107, 104, 76, -42, -49, 96, 67, -114, -97, 19, -59, 89, -102, -115, -110, -101, -6, -98, 39, -75, 2, 74, 23, -105, 67, 6, -112, 21, -3, 36, -114, 52, 35, 75, 74, 82, -8, 115, -128, -34, -105, 110, 124, 41, -79, -53, -90, 81, -11, -7, 91, -45, -67, -82, 106, 74, 30, 112, 100, -47, 54, -12, 95, 81, 97, 36, 123, -15, -91, 87, -82, 87, -45, -103, -80, 109, -111, 82, 109, 58, 50, 19, -21, -102, 54, -108, -68, 12, -101, -53, -65, 11, -94, -36, 112, -101, -95, -90, -118, 68, 13, 8, -49, -77, -28, -82, -97, 126, -71, 33, -58, 19, 58, -118, 36, -28, 22, -55, 64, -72, -80, -9, -48, -50, 58, -52, 64, -64, -27, -5, -12, 110, -95, -17]).buffer } +]; + +var options = { + + challenge: new Int8Array([-42, -21, -101, -22, -35, 94, 14, 33, -75, -12, -113, 86, 109, -51, 62, 89, 29, 119, 48, -92, 33, -64, 102, -18, 18, -122, 73, 13, -17, -50, -22, -74]).buffer, + timeout: 60000, + userVerification: "preferred", + allowCredentials: acceptableCredentials +}; + +navigator.credentials.get({ "publicKey" : options }) + .then(function (assertion) { + var clientData = String.fromCharCode.apply(null, new Uint8Array(assertion.response.clientDataJSON)); + var authenticatorData = new Int8Array(assertion.response.authenticatorData).toString(); + var signature = new Int8Array(assertion.response.signature).toString(); + var rawId = assertion.id; + document.getElementById('webAuthnOutcome').value = clientData + "::" + authenticatorData + "::" + signature + "::" + rawId; + document.getElementById("loginButton_0").click(); + }).catch(function (err) { + document.getElementById('webAuthnOutcome').value = "ERROR" + "::" + err; + document.getElementById("loginButton_0").click(); + });`; + +export const registerInputWithRpid = `/* + * Copyright 2018-2020 ForgeRock AS. All Rights Reserved + * + * Use of this code requires a commercial software license with ForgeRock AS. + * or with one of its affiliates. All use shall be exclusively subject + * to such license between the licensee and ForgeRock AS. + */ + +if (!window.PublicKeyCredential) { + document.getElementById('webAuthnOutcome').value = "unsupported"; + document.getElementById("loginButton_0").click(); +} + +var publicKey = { + challenge: new Int8Array([102, -15, -36, -101, -95, 10, -20, 39, 29, 70, 122, 25, 53, 83, 72, -38, 83, -92, 31, -30, 26, -94, 92, -94, -83, 7, 82, -66, -125, -95, -4, -75]).buffer, + // Relying Party: + rp: { + id: "example.com", + name: "ForgeRock" + }, + // User: + user: { + id: Uint8Array.from("NTdhNWI0ZTQtNjk5OS00YjQ1LWJmODYtYTRmMmU1ZDRiNjI5", function (c) { return c.charCodeAt(0) }), + name: "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629", + displayName: "bob_lee-tester@me.co.uk" + }, + // Below pubKeyCredParams format represents AM 6.5 + pubKeyCredParams: [ + { + type: "public-key", + alg: -7 + } + ,{ + type: "public-key", + alg: -257 + } + ], + attestation: "none", + timeout: 60000, + excludeCredentials: [], + authenticatorSelection: { + userVerification: "preferred" + authenticatorAttachment:"cross-platform" + } +}; + +navigator.credentials.create({publicKey: publicKey}) + .then(function (newCredentialInfo) { + var rawId = newCredentialInfo.id; + var clientData = String.fromCharCode.apply(null, new Uint8Array(newCredentialInfo.response.clientDataJSON)); + var keyData = new Int8Array(newCredentialInfo.response.attestationObject).toString(); + document.getElementById('webAuthnOutcome').value = clientData + "::" + keyData + "::" + rawId; + document.getElementById("loginButton_0").click(); + }).catch(function (err) { + document.getElementById('webAuthnOutcome').value = "ERROR" + "::" + err; + document.getElementById("loginButton_0").click(); + });`; + +export const registerInputWithRpidAndQuotes = `/* + * Copyright 2018-2020 ForgeRock AS. All Rights Reserved + * + * Use of this code requires a commercial software license with ForgeRock AS. + * or with one of its affiliates. All use shall be exclusively subject + * to such license between the licensee and ForgeRock AS. + */ + + if (!window.PublicKeyCredential) { + document.getElementById('webAuthnOutcome').value = "unsupported"; + document.getElementById("loginButton_0").click(); + } + + var publicKey = { + "challenge": new Int8Array([102, -15, -36, -101, -95, 10, -20, 39, 29, 70, 122, 25, 53, 83, 72, -38, 83, -92, 31, -30, 26, -94, 92, -94, -83, 7, 82, -66, -125, -95, -4, -75]).buffer, + // Relying Party: + "rp": { + "id": "example.com", + "name": "ForgeRock" + }, + // User: + "user": { + "id": Uint8Array.from("NTdhNWI0ZTQtNjk5OS00YjQ1LWJmODYtYTRmMmU1ZDRiNjI5", function (c) { return c.charCodeAt(0) }), + "name": "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629", + "displayName": "bob_lee-tester@me.co.uk" + }, + "pubKeyCredParams": [ { "type": "public-key", "alg": -257 }, { "type": "public-key", "alg": -7 } ], + "attestation": "none", + "timeout": 60000, + "excludeCredentials": [], + "authenticatorSelection": {"userVerification":"preferred","authenticatorAttachment":"cross-platform"} + }; + + navigator.credentials.create({publicKey: publicKey}) + .then(function (newCredentialInfo) { + var rawId = newCredentialInfo.id; + var clientData = String.fromCharCode.apply(null, new Uint8Array(newCredentialInfo.response.clientDataJSON)); + var keyData = new Int8Array(newCredentialInfo.response.attestationObject).toString(); + document.getElementById('webAuthnOutcome').value = clientData + "::" + keyData + "::" + rawId; + document.getElementById("loginButton_0").click(); + }).catch(function (err) { + document.getElementById('webAuthnOutcome').value = "ERROR" + "::" + err; + document.getElementById("loginButton_0").click(); + });`; + +export const registerOutputWithRpid = { + attestation: 'none', + authenticatorSelection: { + userVerification: 'preferred', + authenticatorAttachment: 'cross-platform', + }, + challenge: [ + /* don't directly test */ + ], + pubKeyCredParams: [ + { type: 'public-key', alg: -257 }, + { type: 'public-key', alg: -7 }, + ], + rp: { id: 'example.com', name: 'ForgeRock' }, + timeout: 60000, + user: { + displayName: 'bob_lee-tester@me.co.uk', + id: [ + /* don't directly test */ + ], + name: '57a5b4e4-6999-4b45-bf86-a4f2e5d4b629', + }, +}; + +export const registerInputWithoutRpid = `/* + * Copyright 2018-2020 ForgeRock AS. All Rights Reserved + * + * Use of this code requires a commercial software license with ForgeRock AS. + * or with one of its affiliates. All use shall be exclusively subject + * to such license between the licensee and ForgeRock AS. + */ + +if (!window.PublicKeyCredential) { + document.getElementById('webAuthnOutcome').value = "unsupported"; + document.getElementById("loginButton_0").click(); +} + +var publicKey = { + challenge: new Int8Array([102, -15, -36, -101, -95, 10, -20, 39, 29, 70, 122, 25, 53, 83, 72, -38, 83, -92, 31, -30, 26, -94, 92, -94, -83, 7, 82, -66, -125, -95, -4, -75]).buffer, + // Relying Party: + rp: { + name: "ForgeRock" + }, + // User: + user: { + id: Uint8Array.from("NTdhNWI0ZTQtNjk5OS00YjQ1LWJmODYtYTRmMmU1ZDRiNjI5", function (c) { return c.charCodeAt(0) }), + name: "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629", + displayName: "Bob Tester" + }, + pubKeyCredParams: [ { "type": "public-key", "alg": -257 }, { "type": "public-key", "alg": -7 } ], + attestation: "none", + timeout: 60000, + excludeCredentials: [], + authenticatorSelection: {"userVerification":"preferred"} +}; + +navigator.credentials.create({publicKey: publicKey}) + .then(function (newCredentialInfo) { + var rawId = newCredentialInfo.id; + var clientData = String.fromCharCode.apply(null, new Uint8Array(newCredentialInfo.response.clientDataJSON)); + var keyData = new Int8Array(newCredentialInfo.response.attestationObject).toString(); + document.getElementById('webAuthnOutcome').value = clientData + "::" + keyData + "::" + rawId; + document.getElementById("loginButton_0").click(); + }).catch(function (err) { + document.getElementById('webAuthnOutcome').value = "ERROR" + "::" + err; + document.getElementById("loginButton_0").click(); + });`; + +export const registerOutputWithoutRpid = { + attestation: 'none', + authenticatorSelection: { userVerification: 'preferred' }, + challenge: [ + /* don't directly test */ + ], + pubKeyCredParams: [ + { type: 'public-key', alg: -257 }, + { type: 'public-key', alg: -7 }, + ], + rp: { name: 'ForgeRock' }, + timeout: 60000, + user: { + displayName: 'Bob Tester', + id: [ + /* don't directly test */ + ], + name: '57a5b4e4-6999-4b45-bf86-a4f2e5d4b629', + }, +}; + +export const registerInputWithExcludeCreds = `/* + * Copyright 2018-2020 ForgeRock AS. All Rights Reserved + * + * Use of this code requires a commercial software license with ForgeRock AS. + * or with one of its affiliates. All use shall be exclusively subject + * to such license between the licensee and ForgeRock AS. + */ + +if (!window.PublicKeyCredential) { + document.getElementById('webAuthnOutcome').value = "unsupported"; + document.getElementById("loginButton_0").click(); +} + +var publicKey = { + challenge: new Int8Array([102, -15, -36, -101, -95, 10, -20, 39, 29, 70, 122, 25, 53, 83, 72, -38, 83, -92, 31, -30, 26, -94, 92, -94, -83, 7, 82, -66, -125, -95, -4, -75]).buffer, + // Relying Party: + rp: { + name: "ForgeRock" + }, + // User: + user: { + id: Uint8Array.from("NTdhNWI0ZTQtNjk5OS00YjQ1LWJmODYtYTRmMmU1ZDRiNjI5", function (c) { return c.charCodeAt(0) }), + name: "57a5b4e4-6999-4b45-bf86-a4f2e5d4b629", + displayName: "Bob Tester" + }, + pubKeyCredParams: [ { "type": "public-key", "alg": -257 }, { "type": "public-key", "alg": -7 } ], + attestation: "none", + timeout: 60000, + excludeCredentials: [{ "type": "public-key", "id": new Int8Array([49, -96, -107, 113, 106, 5, 115, 22, 68, 121, -85, -27, 8, -58, -113, 127, -105, -37, -10, -12, -58, -25, 29, -82, -18, 69, -99, 125, 33, 82, 38, -66, -27, -128, -91, -86, 87, 68, 94, 0, -78, 70, -11, -70, -14, -53, 38, -60, 46, 27, 66, 46, 21, -125, -70, 123, -46, -124, 86, -2, 102, 70, -52, 54]).buffer },{ "type": "public-key", "id": new Int8Array([64, 17, -15, -123, -21, 127, 76, -120, 90, -112, -5, 54, 105, 93, 82, -104, -79, 107, -69, -3, -113, -94, -59, -4, 126, -33, 117, 32, -44, 122, -97, 8, -112, 105, -96, 96, 90, 44, -128, -121, 107, 79, -98, -68, -93, 11, -105, -47, 102, 13, 110, 84, 59, -91, -30, 37, -3, -22, 39, 111, -10, 87, -50, -35]).buffer }], + authenticatorSelection: {"userVerification":"preferred"} +}; + +navigator.credentials.create({publicKey: publicKey}) + .then(function (newCredentialInfo) { + var rawId = newCredentialInfo.id; + var clientData = String.fromCharCode.apply(null, new Uint8Array(newCredentialInfo.response.clientDataJSON)); + var keyData = new Int8Array(newCredentialInfo.response.attestationObject).toString(); + document.getElementById('webAuthnOutcome').value = clientData + "::" + keyData + "::" + rawId; + document.getElementById("loginButton_0").click(); + }).catch(function (err) { + document.getElementById('webAuthnOutcome').value = "ERROR" + "::" + err; + document.getElementById("loginButton_0").click(); + });`; + +export const registerOutputWithExcludeCreds = { + attestation: 'none', + authenticatorSelection: { userVerification: 'preferred' }, + challenge: [ + /* don't directly test */ + ], + excludeCredentials: [ + { + type: 'public-key', + id: 'Do not directly test', + }, + { + type: 'public-key', + id: 'Do not directly test', + }, + ], + pubKeyCredParams: [ + { type: 'public-key', alg: -257 }, + { type: 'public-key', alg: -7 }, + ], + rp: { name: 'ForgeRock' }, + timeout: 60000, + user: { + displayName: 'Bob Tester', + id: [ + /* don't directly test */ + ], + name: '57a5b4e4-6999-4b45-bf86-a4f2e5d4b629', + }, +}; + +export const authenticateInputWithRpidAndAllowCredentialsAndAllowRecoveryCode = `/* + * Copyright 2018-2022 ForgeRock AS. All Rights Reserved + * + * Use of this code requires a commercial software license with ForgeRock AS. + * or with one of its affiliates. All use shall be exclusively subject + * to such license between the licensee and ForgeRock AS. + */ + +if (!window.PublicKeyCredential) { + document.getElementById('webAuthnOutcome').value = "unsupported"; + document.getElementById("loginButton_0").click(); +} + +var options = { + + challenge: new Int8Array([-17, -117, -10, 120, -90, 127, 70, -73, 114, -37, -94, 126, -96, -111, -65, 78, -84, 53, 74, -18, 93, 102, 24, -77, -97, -6, -106, -10, -101, -29, 36, -33]).buffer, + timeout: 60000, + userVerification: "preferred", + allowCredentials: [{ "type": "public-key", "id": new Int8Array([-33, 59, -68, 121, 57, -27, -33, -40, 55, 8, -65, -15, -40, -103, 73, 61, 49, 56, 65, -84, -27, -86, -103, -115, 15, 43, -64, -60, -105, 81, -111, 115, 105, 111, -105, 64, 73, 55, -35, 35, 38, 59, -91, 95, 64, 30, -10, -6, -91, -59, 26, 19, -3, 2, -39, 71, 112, 124, -66, -89, -10, -35, 122, 103]).buffer }] +}; + +navigator.credentials.get({ "publicKey" : options }) + .then(function (assertion) { + var clientData = String.fromCharCode.apply(null, new Uint8Array(assertion.response.clientDataJSON)); + var authenticatorData = new Int8Array(assertion.response.authenticatorData).toString(); + var signature = new Int8Array(assertion.response.signature).toString(); + var rawId = assertion.id; + var userHandle = String.fromCharCode.apply(null, new Uint8Array(assertion.response.userHandle)); + document.getElementById('webAuthnOutcome').value = clientData + "::" + authenticatorData + "::" + signature + "::" + rawId + "::" + userHandle; + document.getElementById("loginButton_0").click(); + }).catch(function (err) { + document.getElementById('webAuthnOutcome').value = "ERROR" + "::" + err; + var allowRecoveryCode = 'true' === "true"; + if (allowRecoveryCode) { + var loginButton = document.getElementById("loginButton_0"); + if (loginButton) { + var prev = loginButton.previousElementSibling; + if (prev && prev.nodeName == "DIV") { + prev.getElementsByTagName("div")[0].innerHTML = " " + + err + ""; + } + } + } else { + document.getElementById("loginButton_0").click(); + } + }); +`; diff --git a/packages/journey-client/src/lib/journey.api.ts b/packages/journey-client/src/lib/journey.api.ts new file mode 100644 index 0000000000..765714f11a --- /dev/null +++ b/packages/journey-client/src/lib/journey.api.ts @@ -0,0 +1,131 @@ +import type { StepOptions } from '@forgerock/sdk-types'; +import type { ServerConfig } from '@forgerock/sdk-types'; +import { type logger as loggerFn } from '@forgerock/sdk-logger'; + +import { initQuery, RequestMiddleware } from '@forgerock/sdk-request-middleware'; +import { type Step } from '@forgerock/sdk-types'; + +import { REQUESTED_WITH, getEndpointPath, stringify, resolve } from '@forgerock/sdk-utilities'; + +import type { JourneyClientConfig } from './config.types.js'; +import type { + BaseQueryApi, + BaseQueryFn, + FetchArgs, + FetchBaseQueryError, + FetchBaseQueryMeta, + QueryReturnValue, +} from '@reduxjs/toolkit/query'; +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; + +// Move these functions to the top, before journeyApi definition +function constructUrl( + serverConfig: ServerConfig, + realmPath?: string, + tree?: string, + query?: Record, +): string { + const treeParams = tree ? { authIndexType: 'service', authIndexValue: tree } : undefined; + const params: Record = { ...query, ...treeParams }; + const queryString = Object.keys(params).length > 0 ? `?${stringify(params)}` : ''; + const path = getEndpointPath({ + endpoint: 'authenticate', + realmPath, + customPaths: serverConfig.paths, + }); + const url = resolve(serverConfig.baseUrl, `${path}${queryString}`); + return url; +} + +function configureRequest(step?: Step): RequestInit { + const init: RequestInit = { + body: step ? JSON.stringify(step) : undefined, + credentials: 'include', + headers: new Headers({}), + method: 'POST', + }; + + return init; +} + +interface Extras { + requestMiddleware: RequestMiddleware[]; + logger: ReturnType; + config: JourneyClientConfig; +} + +export const journeyApi = createApi({ + reducerPath: 'journeyReducer', + baseQuery: fetchBaseQuery({ + baseUrl: '/', + prepareHeaders: (headers: Headers) => { + headers.set('Accept', 'application/json'); + headers.set('Accept-API-Version', 'protocol=1.0,resource=2.1'); + headers.set('Content-Type', 'application/json'); + headers.set('X-Requested-With', REQUESTED_WITH); + + return headers; + }, + }), + endpoints: (builder) => ({ + start: builder.mutation({ + queryFn: async ( + options: StepOptions | void, + api: BaseQueryApi, + _: unknown, + baseQuery: BaseQueryFn, + ) => { + const { config } = api.extra as Extras; + if (!config.serverConfig) { + throw new Error('Server configuration is missing.'); + } + const { realmPath, serverConfig, tree } = config; + + const query = options?.query || {}; + + const url = constructUrl(serverConfig, realmPath, tree, query); + const request = configureRequest(); + + const { requestMiddleware } = api.extra as Extras; + + const response = await initQuery({ ...request, url: url }, 'start') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => { + const result = await baseQuery(req, api, api.extra as Extras); + return result as QueryReturnValue; + }); + + return response as QueryReturnValue; + }, + }), + next: builder.mutation({ + queryFn: async ( + { step, options }: { step: Step; options?: StepOptions }, + api: BaseQueryApi, + _: unknown, + baseQuery: BaseQueryFn, + ) => { + const { config } = api.extra as Extras; + if (!config.serverConfig) { + throw new Error('Server configuration is missing.'); + } + const { realmPath, serverConfig, tree } = config; + const query = options?.query || {}; + + const url = constructUrl(serverConfig, realmPath, tree, query); + const request = configureRequest(step); + + const { requestMiddleware } = api.extra as Extras; + + const response = await initQuery({ ...request, url }, 'next') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => { + const result = await baseQuery(req, api, api.extra as Extras); + return result as QueryReturnValue; + }); + + return response as QueryReturnValue; + }, + }), + }), +}); diff --git a/packages/journey-client/src/lib/journey.slice.ts b/packages/journey-client/src/lib/journey.slice.ts new file mode 100644 index 0000000000..9a680f3bb7 --- /dev/null +++ b/packages/journey-client/src/lib/journey.slice.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { createSlice, PayloadAction, Slice } from '@reduxjs/toolkit'; +import type { Step } from '@forgerock/sdk-types'; +import type { JourneyClientConfig } from './config.types.js'; + +export interface JourneyState { + authId?: string; + step?: Step; + error?: Error; + config?: JourneyClientConfig; +} + +const initialState: JourneyState = {}; + +export const journeySlice: Slice = createSlice({ + name: 'journey', + initialState, + reducers: { + setConfig: (state, action: PayloadAction) => { + state.config = action.payload; + }, + }, +}); + +export const { setConfig } = journeySlice.actions; diff --git a/packages/journey-client/src/lib/journey.store.ts b/packages/journey-client/src/lib/journey.store.ts new file mode 100644 index 0000000000..25410e1581 --- /dev/null +++ b/packages/journey-client/src/lib/journey.store.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { journeyApi } from './journey.api.js'; +import { journeySlice } from './journey.slice.js'; +import { JourneyClientConfig } from './config.types.js'; +import { logger as loggerFn } from '@forgerock/sdk-logger'; + +import { RequestMiddleware } from '@forgerock/sdk-request-middleware'; + +const rootReducer = combineReducers({ + [journeyApi.reducerPath]: journeyApi.reducer, + [journeySlice.name]: journeySlice.reducer, +}); + +export const createJourneyStore = ({ + requestMiddleware, + logger, + config, +}: { + requestMiddleware?: RequestMiddleware[]; + logger?: ReturnType; + config: JourneyClientConfig; +}) => { + return configureStore({ + reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: true, + thunk: { + extraArgument: { + requestMiddleware, + logger, + config, + }, + }, + }).concat(journeyApi.middleware), + }); +}; + +export type RootState = ReturnType; diff --git a/packages/journey-client/src/lib/journey.utils.ts b/packages/journey-client/src/lib/journey.utils.ts new file mode 100644 index 0000000000..43f46645d2 --- /dev/null +++ b/packages/journey-client/src/lib/journey.utils.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { type Step, StepType } from '@forgerock/sdk-types'; +import { createJourneyStep, JourneyStep } from './journey-step.utils.js'; +import { createJourneyLoginSuccess, JourneyLoginSuccess } from './journey-login-success.utils.js'; +import { createJourneyLoginFailure, JourneyLoginFailure } from './journey-login-failure.utils.js'; + +function createJourneyObject( + step: Step, +): JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | undefined { + let type; + if (step.authId) { + type = StepType.Step; + } else if (step.successUrl) { + type = StepType.LoginSuccess; + } else { + type = StepType.LoginFailure; + } + + switch (type) { + case StepType.LoginSuccess: + return createJourneyLoginSuccess(step); + case StepType.LoginFailure: + return createJourneyLoginFailure(step); + case StepType.Step: + return createJourneyStep(step); + default: + return undefined; + } +} + +export { createJourneyObject }; diff --git a/packages/journey-client/src/lib/recovery-codes/index.ts b/packages/journey-client/src/lib/recovery-codes/index.ts new file mode 100644 index 0000000000..dc4cf7da96 --- /dev/null +++ b/packages/journey-client/src/lib/recovery-codes/index.ts @@ -0,0 +1,72 @@ +/* + * @forgerock/javascript-sdk + * + * index.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; +import type TextOutputCallback from '../callbacks/text-output-callback.js'; +import type { JourneyStep } from '../journey-step.utils.js'; +import { parseDeviceNameText, parseDisplayRecoveryCodesText } from './script-parser.js'; + +/** + * Utility for handling recovery code nodes. + * + * Example: + * + * ```js + * // Determine if step is Display Recovery Codes step + * const isDisplayRecoveryCodesStep = JourneyRecoveryCodes.isDisplayStep(step); + * if (isDisplayRecoveryCodesStep) { + * const recoveryCodes = JourneyRecoveryCodes.getCodes(step); + * // Do the UI needful + * } + * ``` + */ +abstract class JourneyRecoveryCodes { + public static getDeviceName(step: JourneyStep): string { + const text = this.getDisplayCallback(step)?.getOutputByName('message', '') ?? ''; + return parseDeviceNameText(text); + } + /** + * Retrieves the recovery codes by parsing the JavaScript message text in callback. + * + * @param step The step to evaluate + * @return Recovery Code values in array + */ + public static getCodes(step: JourneyStep): string[] { + const text = this.getDisplayCallback(step)?.getOutputByName('message', ''); + return parseDisplayRecoveryCodesText(text || ''); + } + + /** + * Determines if the given step is a Display Recovery Codes step. + * + * @param step The step to evaluate + * @return Is this step a Display Recovery Codes step + */ + public static isDisplayStep(step: JourneyStep): boolean { + return !!this.getDisplayCallback(step); + } + + /** + * Gets the recovery codes step. + * + * @param step The step to evaluate + * @return gets the Display Recovery Codes' callback + */ + private static getDisplayCallback(step: JourneyStep): TextOutputCallback | undefined { + return step + .getCallbacksOfType(callbackType.TextOutputCallback) + .find((x) => { + const cb = x.getOutputByName('message', undefined); + return cb && (cb.includes('Recovery Codes') || cb.includes('recovery codes')); + }); + } +} + +export default JourneyRecoveryCodes; diff --git a/packages/journey-client/src/lib/recovery-codes/recovery-codes.test.ts b/packages/journey-client/src/lib/recovery-codes/recovery-codes.test.ts new file mode 100644 index 0000000000..067ba0613a --- /dev/null +++ b/packages/journey-client/src/lib/recovery-codes/recovery-codes.test.ts @@ -0,0 +1,43 @@ +/* + * @forgerock/javascript-sdk + * + * recovery-codes.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { createJourneyStep } from '../journey-step.utils.js'; +import JourneyRecoveryCodes from './index.js'; +import { + displayRecoveryCodesResponse, + expectedDeviceName, + expectedRecoveryCodes, + otherResponse, +} from './script-text.mock.data.js'; + +describe('Class for managing the Display Recovery Codes node', () => { + it('should return true if Display Recovery Codes step', () => { + const step = createJourneyStep(displayRecoveryCodesResponse); + const isDisplayStep = JourneyRecoveryCodes.isDisplayStep(step); + expect(isDisplayStep).toBe(true); + }); + + it('should return false if not Display Recovery Codes step', () => { + const step = createJourneyStep(otherResponse); + const isDisplayStep = JourneyRecoveryCodes.isDisplayStep(step); + expect(isDisplayStep).toBe(false); + }); + + it('should return the Recovery Codes as array of strings', () => { + const step = createJourneyStep(displayRecoveryCodesResponse); + const recoveryCodes = JourneyRecoveryCodes.getCodes(step); + expect(recoveryCodes).toStrictEqual(expectedRecoveryCodes); + }); + it('should return a display name from the getDisplayName method', () => { + const step = createJourneyStep(displayRecoveryCodesResponse); + const displayName = JourneyRecoveryCodes.getDeviceName(step); + expect(displayName).toStrictEqual(expectedDeviceName); + }); +}); diff --git a/packages/journey-client/src/lib/recovery-codes/script-parser.test.ts b/packages/journey-client/src/lib/recovery-codes/script-parser.test.ts new file mode 100644 index 0000000000..95acf799a3 --- /dev/null +++ b/packages/journey-client/src/lib/recovery-codes/script-parser.test.ts @@ -0,0 +1,34 @@ +/* + * @forgerock/javascript-sdk + * + * script-parser.test.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { parseDeviceNameText, parseDisplayRecoveryCodesText } from './script-parser.js'; +import { + displayRecoveryCodes, + expectedRecoveryCodes, + securityKeyCustomNameResponse, + securityKeyResponse, +} from './script-text.mock.data.js'; + +describe('Parsing of the Display Recovery Codes script text', () => { + it('should parse the Display Recovery Codes Text', () => { + const result = parseDisplayRecoveryCodesText(displayRecoveryCodes); + expect(result).toStrictEqual(expectedRecoveryCodes); + }); + it('should parse the display name from recovery codes script', () => { + const text = securityKeyResponse; + const result = parseDeviceNameText(text); + expect(result).toStrictEqual('New Security Key'); + }); + it('should parse a custom name out of the recovery text', () => { + const text = securityKeyCustomNameResponse; + const result = parseDeviceNameText(text); + expect(result).toStrictEqual('My Custom Device Name'); + }); +}); diff --git a/packages/journey-client/src/lib/recovery-codes/script-parser.ts b/packages/journey-client/src/lib/recovery-codes/script-parser.ts new file mode 100644 index 0000000000..bce108da9c --- /dev/null +++ b/packages/journey-client/src/lib/recovery-codes/script-parser.ts @@ -0,0 +1,51 @@ +/* + * @forgerock/javascript-sdk + * + * script-parser.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +function parseDisplayRecoveryCodesText(text: string): string[] { + /** + * e.g. ` ... + * "
\n" + + * "iZmEtxvQ00\n" + + * "
\n" + + * ... ` + */ + + const recoveryCodesMatches = text.match(/\s[\w\W]"([\w]*)\\/g); + const recoveryCodes = + Array.isArray(recoveryCodesMatches) && + recoveryCodesMatches.map((substr: string) => { + // e.g. `"iZmEtxvQ00\` + const arr = substr.match(/"([\w]*)\\/); + return Array.isArray(arr) ? arr[1] : ''; + }); + return recoveryCodes || []; +} + +/** + * + * @param text + * @returns string + */ +function parseDeviceNameText(text: string): string { + /** + * We default the device name to 'New Security Key' + * If the user has a device name, it will be wrapped in tags + * e.g. ` ... My Security Key ... ` + * We want to remove the tags and just return the device name + * e.g. ` ... My Security Key ... ` + */ + const displayName = + text + ?.match(/\s*.*<\/em>/g)?.[0] + ?.replace('', '') + ?.replace('', '') ?? 'New Security Key'; + return displayName; +} +export { parseDeviceNameText, parseDisplayRecoveryCodesText }; diff --git a/packages/journey-client/src/lib/recovery-codes/script-text.mock.data.ts b/packages/journey-client/src/lib/recovery-codes/script-text.mock.data.ts new file mode 100644 index 0000000000..fea2ea410a --- /dev/null +++ b/packages/journey-client/src/lib/recovery-codes/script-text.mock.data.ts @@ -0,0 +1,120 @@ +/* eslint-disable no-useless-escape */ +/* + * @forgerock/javascript-sdk + * + * script-text.mock.data.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { callbackType } from '@forgerock/sdk-types'; + +const displayRecoveryCodes = `/* +* Copyright 2018 ForgeRock AS. All Rights Reserved +* +* Use of this code requires a commercial software license with ForgeRock AS. +* or with one of its affiliates. All use shall be exclusively subject +* to such license between the licensee and ForgeRock AS. +*/\n\nvar newLocation = document.getElementById(\"wrapper\");\nvar oldHtml = newLocation.getElementsByTagName(\"fieldset\")[0].innerHTML;\nnewLocation.getElementsByTagName(\"fieldset\")[0].innerHTML = \"
\\n\" + + \"
\\n\" + + \"

Your Recovery Codes

\\n\" + + \"

You must make a copy of these WebAuthn authenticator recovery codes. They cannot be displayed again.

\\n\" + + \"
\\n\" + + \"
\\n\" + + \"iZmEtxvQ00\\n\" + + \"
\\n\" + + \"
\\n\" + + \"Eqw3GFVamY\\n\" + + \"
\\n\" + + \"
\\n\" + + \"nNPqIEtIpS\\n\" + + \"
\\n\" + + \"
\\n\" + + \"vGhNQpDjP8\\n\" + + \"
\\n\" + + \"
\\n\" + + \"ItA4W3iBaA\\n\" + + \"
\\n\" + + \"
\\n\" + + \"JmLQP6XyIo\\n\" + + \"
\\n\" + + \"
\\n\" + + \"G2e6foNKke\\n\" + + \"
\\n\" + + \"
\\n\" + + \"h2SqAqvT21\\n\" + + \"
\\n\" + + \"
\\n\" + + \"q6VX1ojNbI\\n\" + + \"
\\n\" + + \"
\\n\" + + \"IZKIQXAfY2\\n\" + + \"
\\n\" + + \"
\\n\" + + \"

Use one of these codes to authenticate if you lose your device, which has been named: New Security Key

\\n\" + + \"
\\n\" + + \"
\" + oldHtml;\ndocument.body.appendChild(newLocation);\n\n\n +`; + +const displayRecoveryCodesResponse = { + authId: 'foo', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n */\n\nvar newLocation = document.getElementById("wrapper");\nvar oldHtml = newLocation.getElementsByTagName("fieldset")[0].innerHTML;\nnewLocation.getElementsByTagName("fieldset")[0].innerHTML = "
\\n" +\n "
\\n" +\n "

Your Recovery Codes

\\n" +\n "

You must make a copy of these WebAuthn authenticator recovery codes. They cannot be displayed again.

\\n" +\n "
\\n" +\n "
\\n" +\n "iZmEtxvQ00\\n" +\n "
\\n" +\n "
\\n" +\n "Eqw3GFVamY\\n" +\n "
\\n" +\n "
\\n" +\n "nNPqIEtIpS\\n" +\n "
\\n" +\n "
\\n" +\n "vGhNQpDjP8\\n" +\n "
\\n" +\n "
\\n" +\n "ItA4W3iBaA\\n" +\n "
\\n" +\n "
\\n" +\n "JmLQP6XyIo\\n" +\n "
\\n" +\n "
\\n" +\n "G2e6foNKke\\n" +\n "
\\n" +\n "
\\n" +\n "h2SqAqvT21\\n" +\n "
\\n" +\n "
\\n" +\n "q6VX1ojNbI\\n" +\n "
\\n" +\n "
\\n" +\n "IZKIQXAfY2\\n" +\n "
\\n" +\n "
\\n" +\n "

Use one of these codes to authenticate if you lose your device, which has been named: New Security Key

\\n" +\n "
\\n" +\n "
" + oldHtml;\ndocument.body.appendChild(newLocation);\n\n\n', + }, + { name: 'messageType', value: '4' }, + ], + }, + ], +}; +const expectedDeviceName = 'New Security Key'; + +const expectedRecoveryCodes = [ + 'iZmEtxvQ00', + 'Eqw3GFVamY', + 'nNPqIEtIpS', + 'vGhNQpDjP8', + 'ItA4W3iBaA', + 'JmLQP6XyIo', + 'G2e6foNKke', + 'h2SqAqvT21', + 'q6VX1ojNbI', + 'IZKIQXAfY2', +]; + +const otherResponse = { + authId: 'foo', + callbacks: [ + { + type: callbackType.TextOutputCallback, + output: [ + { + name: 'message', + value: + '/*\n * Copyright 2018 ForgeRock AS. All Rights Reserved\n *\n * Use of this code requires a commercial software license with ForgeRock AS.\n * or with one of its affiliates. All use shall be exclusively subject\n * to such license between the licensee and ForgeRock AS.\n */\n\nvar new Location = foo;', + }, + { name: 'messageType', value: '4' }, + ], + }, + ], +}; + +const securityKeyResponse = `/* * Copyright 2018 ForgeRock AS. All Rights Reserved * * Use of this code requires a commercial software license with ForgeRock AS. * or with one of its affiliates. All use shall be exclusively subject * to such license between the licensee and ForgeRock AS. */ var newLocation = document.getElementById("wrapper"); var oldHtml = newLocation.getElementsByTagName("fieldset")[0].innerHTML; newLocation.getElementsByTagName("fieldset")[0].innerHTML = "
\n" + "
\n" + "

Your Recovery Codes

\n" + "

You must make a copy of these recovery codes. They cannot be displayed again.

\n" + "
\n" + "
\n" + "kw50qtmm32\n" + "
\n" + "
\n" + "Rt2Td8AK2s\n" + "
\n" + "
\n" + "6vZrIRCBJB\n" + "
\n" + "
\n" + "HCmL01Yiyv\n" + "
\n" + "
\n" + "BvDsSpazA2\n" + "
\n" + "
\n" + "T6pbKLyW2l\n" + "
\n" + "
\n" + "gZR0u6XubS\n" + "
\n" + "
\n" + "VZ1bH94IfO\n" + "
\n" + "
\n" + "O9BZ4bLefQ\n" + "
\n" + "
\n" + "NKNrBxlHCt\n" + "
\n" + "
\n" + "

Use one of these codes to authenticate if you lose your device, which has been named: New Security Key

\n" + "
\n" + "
" + oldHtml; document.body.appendChild(newLocation);`; +const securityKeyCustomNameResponse = `/* * Copyright 2018 ForgeRock AS. All Rights Reserved * * Use of this code requires a commercial software license with ForgeRock AS. * or with one of its affiliates. All use shall be exclusively subject * to such license between the licensee and ForgeRock AS. */ var newLocation = document.getElementById("wrapper"); var oldHtml = newLocation.getElementsByTagName("fieldset")[0].innerHTML; newLocation.getElementsByTagName("fieldset")[0].innerHTML = "
\n" + "
\n" + "

Your Recovery Codes

\n" + "

You must make a copy of these recovery codes. They cannot be displayed again.

\n" + "
\n" + "
\n" + "kw50qtmm32\n" + "
\n" + "
\n" + "Rt2Td8AK2s\n" + "
\n" + "
\n" + "6vZrIRCBJB\n" + "
\n" + "
\n" + "HCmL01Yiyv\n" + "
\n" + "
\n" + "BvDsSpazA2\n" + "
\n" + "
\n" + "T6pbKLyW2l\n" + "
\n" + "
\n" + "gZR0u6XubS\n" + "
\n" + "
\n" + "VZ1bH94IfO\n" + "
\n" + "
\n" + "O9BZ4bLefQ\n" + "
\n" + "
\n" + "NKNrBxlHCt\n" + "
\n" + "
\n" + "

Use one of these codes to authenticate if you lose your device, which has been named: My Custom Device Name

\n" + "
\n" + "
" + oldHtml; document.body.appendChild(newLocation);`; + +export { + displayRecoveryCodes, + displayRecoveryCodesResponse, + expectedDeviceName, + expectedRecoveryCodes, + otherResponse, + securityKeyCustomNameResponse, + securityKeyResponse, +}; diff --git a/packages/journey-client/tsconfig.json b/packages/journey-client/tsconfig.json new file mode 100644 index 0000000000..341e0b5e7f --- /dev/null +++ b/packages/journey-client/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "../sdk-effects/storage" + }, + { + "path": "../sdk-utilities" + }, + { + "path": "../sdk-types" + }, + { + "path": "../sdk-effects/sdk-request-middleware" + }, + { + "path": "../sdk-effects/logger" + }, + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/journey-client/tsconfig.lib.json b/packages/journey-client/tsconfig.lib.json new file mode 100644 index 0000000000..280db2ff6c --- /dev/null +++ b/packages/journey-client/tsconfig.lib.json @@ -0,0 +1,37 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "module": "nodenext", + "moduleResolution": "nodenext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"], + "references": [ + { + "path": "../sdk-effects/storage/tsconfig.lib.json" + }, + { + "path": "../sdk-effects/sdk-request-middleware/tsconfig.lib.json" + }, + { + "path": "../sdk-utilities/tsconfig.lib.json" + }, + { + "path": "../sdk-types/tsconfig.lib.json" + }, + { + "path": "../sdk-effects/logger/tsconfig.lib.json" + } + ] +} diff --git a/packages/journey-client/tsconfig.spec.json b/packages/journey-client/tsconfig.spec.json new file mode 100644 index 0000000000..5f35a633f1 --- /dev/null +++ b/packages/journey-client/tsconfig.spec.json @@ -0,0 +1,42 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "module": "nodenext", + "moduleResolution": "nodenext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "declaration": true + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/journey-client/vite.config.ts b/packages/journey-client/vite.config.ts new file mode 100644 index 0000000000..8ff01c6adf Binary files /dev/null and b/packages/journey-client/vite.config.ts differ diff --git a/packages/journey-client/vitest.setup.ts b/packages/journey-client/vitest.setup.ts new file mode 100644 index 0000000000..644736e02a --- /dev/null +++ b/packages/journey-client/vitest.setup.ts @@ -0,0 +1 @@ +import 'vitest-canvas-mock'; diff --git a/packages/oidc-client/tsconfig.json b/packages/oidc-client/tsconfig.json index f9c5bdebb9..9aae8f1878 100644 --- a/packages/oidc-client/tsconfig.json +++ b/packages/oidc-client/tsconfig.json @@ -21,18 +21,6 @@ { "path": "../sdk-effects/iframe-manager" }, - { - "path": "../sdk-effects/sdk-request-middleware" - }, - { - "path": "../sdk-effects/oidc" - }, - { - "path": "../sdk-effects/logger" - }, - { - "path": "../sdk-effects/iframe-manager" - }, { "path": "./tsconfig.lib.json" }, diff --git a/packages/oidc-client/vite.config.ts b/packages/oidc-client/vite.config.ts index 43fb553213..eb8fbd01ca 100644 Binary files a/packages/oidc-client/vite.config.ts and b/packages/oidc-client/vite.config.ts differ diff --git a/packages/protect/vite.config.ts b/packages/protect/vite.config.ts index e3e8b96b14..a5d8fef124 100644 Binary files a/packages/protect/vite.config.ts and b/packages/protect/vite.config.ts differ diff --git a/packages/sdk-effects/iframe-manager/eslint.config.mjs b/packages/sdk-effects/iframe-manager/eslint.config.mjs index 8f5baa2d3a..cc5eaa196c 100644 --- a/packages/sdk-effects/iframe-manager/eslint.config.mjs +++ b/packages/sdk-effects/iframe-manager/eslint.config.mjs @@ -16,7 +16,7 @@ export default [ ], }, languageOptions: { - parser: await import('jsonc-eslint-parser'), + parser: (await import('jsonc-eslint-parser')).default, }, }, ]; diff --git a/packages/sdk-effects/iframe-manager/vite.config.ts b/packages/sdk-effects/iframe-manager/vite.config.ts index 6ec07333ef..d66e3b2f06 100644 Binary files a/packages/sdk-effects/iframe-manager/vite.config.ts and b/packages/sdk-effects/iframe-manager/vite.config.ts differ diff --git a/packages/sdk-effects/logger/eslint.config.mjs b/packages/sdk-effects/logger/eslint.config.mjs index 8f5baa2d3a..cc5eaa196c 100644 --- a/packages/sdk-effects/logger/eslint.config.mjs +++ b/packages/sdk-effects/logger/eslint.config.mjs @@ -16,7 +16,7 @@ export default [ ], }, languageOptions: { - parser: await import('jsonc-eslint-parser'), + parser: (await import('jsonc-eslint-parser')).default, }, }, ]; diff --git a/packages/sdk-effects/logger/vite.config.ts b/packages/sdk-effects/logger/vite.config.ts index bba16a4c4e..d08c944cff 100644 Binary files a/packages/sdk-effects/logger/vite.config.ts and b/packages/sdk-effects/logger/vite.config.ts differ diff --git a/packages/sdk-effects/oidc/vite.config.ts b/packages/sdk-effects/oidc/vite.config.ts index 294c9a0c79..99123060f4 100644 Binary files a/packages/sdk-effects/oidc/vite.config.ts and b/packages/sdk-effects/oidc/vite.config.ts differ diff --git a/packages/sdk-effects/sdk-request-middleware/vite.config.ts b/packages/sdk-effects/sdk-request-middleware/vite.config.ts index 239527fba3..196bf47172 100644 Binary files a/packages/sdk-effects/sdk-request-middleware/vite.config.ts and b/packages/sdk-effects/sdk-request-middleware/vite.config.ts differ diff --git a/packages/sdk-effects/storage/eslint.config.mjs b/packages/sdk-effects/storage/eslint.config.mjs index 1c878bcd48..4f6da4f811 100644 --- a/packages/sdk-effects/storage/eslint.config.mjs +++ b/packages/sdk-effects/storage/eslint.config.mjs @@ -22,7 +22,7 @@ export default [ ], }, languageOptions: { - parser: await import('jsonc-eslint-parser'), + parser: (await import('jsonc-eslint-parser')).default, }, }, ]; diff --git a/packages/sdk-effects/storage/vite.config.ts b/packages/sdk-effects/storage/vite.config.ts index 170c4d97c4..aa02b02d79 100644 Binary files a/packages/sdk-effects/storage/vite.config.ts and b/packages/sdk-effects/storage/vite.config.ts differ diff --git a/packages/sdk-types/eslint.config.mjs b/packages/sdk-types/eslint.config.mjs index 2dcaf60cbc..0f7a87237c 100644 --- a/packages/sdk-types/eslint.config.mjs +++ b/packages/sdk-types/eslint.config.mjs @@ -16,7 +16,7 @@ export default [ ], }, languageOptions: { - parser: await import('jsonc-eslint-parser'), + parser: (await import('jsonc-eslint-parser')).default, }, }, ]; diff --git a/packages/sdk-types/src/index.ts b/packages/sdk-types/src/index.ts index 7402cbf6e6..7d5ffabb36 100644 --- a/packages/sdk-types/src/index.ts +++ b/packages/sdk-types/src/index.ts @@ -6,6 +6,7 @@ */ export * from './lib/am-callback.types.js'; +export * from './lib/enums.js'; export * from './lib/error.types.js'; export * from './lib/legacy-config.types.js'; export * from './lib/legacy-mware.types.js'; @@ -13,3 +14,4 @@ export * from './lib/branded.types.js'; export * from './lib/tokens.types.js'; export * from './lib/config.types.js'; export * from './lib/authorize.types.js'; +export * from './lib/policy.types.js'; diff --git a/packages/sdk-types/src/lib/am-callback.types.ts b/packages/sdk-types/src/lib/am-callback.types.ts index a35acb18e6..8ee87effdd 100644 --- a/packages/sdk-types/src/lib/am-callback.types.ts +++ b/packages/sdk-types/src/lib/am-callback.types.ts @@ -5,8 +5,9 @@ * of the MIT license. See the LICENSE file for details. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const callbackType = { +import type { StepType } from './enums.js'; + +export const callbackType = { BooleanAttributeInputCallback: 'BooleanAttributeInputCallback', ChoiceCallback: 'ChoiceCallback', ConfirmationCallback: 'ConfirmationCallback', @@ -48,3 +49,87 @@ export interface Callback { output: NameValue[]; type: CallbackType; } + +/** + * Base interface for all types of authentication step responses. + */ +export interface AuthResponse { + type: StepType; +} + +/** + * Represents details of a failure in an authentication step. + */ +export interface FailureDetail { + failureUrl?: string; +} + +/** + * Represents the authentication tree API payload schema. + */ +export interface Step { + authId?: string; + callbacks?: Callback[]; + code?: number; + description?: string; + detail?: StepDetail; + header?: string; + message?: string; + ok?: string; + realm?: string; + reason?: string; + stage?: string; + status?: number; + successUrl?: string; + tokenId?: string; +} + +/** + * Represents details of a failure in an authentication step. + */ +export interface StepDetail { + failedPolicyRequirements?: FailedPolicyRequirement[]; + failureUrl?: string; + result?: boolean; +} + +/** + * Represents failed policies for a matching property. + */ +export interface FailedPolicyRequirement { + policyRequirements: PolicyRequirement[]; + property: string; +} + +/** + * Represents a failed policy policy and failed policy params. + */ +export interface PolicyRequirement { + params?: Partial; + policyRequirement: string; +} + +export interface PolicyParams { + [key: string]: unknown; + disallowedFields: string; + duplicateValue: string; + forbiddenChars: string; + maxLength: number; + minLength: number; + numCaps: number; + numNums: number; +} + +export type ConfigurablePaths = keyof CustomPathConfig; +/** + * Optional configuration for custom paths for actions + */ +export interface CustomPathConfig { + authenticate?: string; + authorize?: string; + accessToken?: string; + endSession?: string; + userInfo?: string; + revoke?: string; + sessions?: string; +} diff --git a/packages/sdk-types/src/lib/enums.ts b/packages/sdk-types/src/lib/enums.ts new file mode 100644 index 0000000000..35b5b2f64a --- /dev/null +++ b/packages/sdk-types/src/lib/enums.ts @@ -0,0 +1,20 @@ +/* + * @forgerock/javascript-sdk + * + * enums.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +/** + * Types of steps returned by the authentication tree. + */ +enum StepType { + LoginFailure = 'LoginFailure', + LoginSuccess = 'LoginSuccess', + Step = 'Step', +} + +export { StepType }; diff --git a/packages/sdk-types/src/lib/legacy-config.types.ts b/packages/sdk-types/src/lib/legacy-config.types.ts index d5591a0e60..a6230ae33a 100644 --- a/packages/sdk-types/src/lib/legacy-config.types.ts +++ b/packages/sdk-types/src/lib/legacy-config.types.ts @@ -9,22 +9,10 @@ * Legacy configuration options for the SDK * **************************************************************/ -import type { Callback } from './am-callback.types.js'; +import type { Callback, CustomPathConfig } from './am-callback.types.js'; import type { LegacyRequestMiddleware } from './legacy-mware.types.js'; import { CustomStorageObject } from './tokens.types.js'; -/** - * Optional configuration for custom paths for actions - */ -export interface CustomPathConfig { - authenticate?: string; - authorize?: string; - accessToken?: string; - endSession?: string; - userInfo?: string; - revoke?: string; - sessions?: string; -} /** * Configuration settings for connecting to a server. */ @@ -47,8 +35,6 @@ export interface AsyncLegacyConfigOptions extends Omit any; +type JourneyCallbackFactory = (callback: Callback) => any; export interface LegacyConfigOptions { - callbackFactory?: FRCallbackFactory; + callbackFactory?: JourneyCallbackFactory; clientId?: string; middleware?: LegacyRequestMiddleware[]; realmPath?: string; diff --git a/packages/sdk-types/src/lib/policy.types.ts b/packages/sdk-types/src/lib/policy.types.ts new file mode 100644 index 0000000000..66f517ba79 --- /dev/null +++ b/packages/sdk-types/src/lib/policy.types.ts @@ -0,0 +1,35 @@ +/* + * @forgerock/javascript-sdk + * + * policy.types.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +enum PolicyKey { + CannotContainCharacters = 'CANNOT_CONTAIN_CHARACTERS', + CannotContainDuplicates = 'CANNOT_CONTAIN_DUPLICATES', + CannotContainOthers = 'CANNOT_CONTAIN_OTHERS', + LeastCapitalLetters = 'AT_LEAST_X_CAPITAL_LETTERS', + LeastNumbers = 'AT_LEAST_X_NUMBERS', + MatchRegexp = 'MATCH_REGEXP', + MaximumLength = 'MAX_LENGTH', + MaximumNumber = 'MAXIMUM_NUMBER_VALUE', + MinimumLength = 'MIN_LENGTH', + MinimumNumber = 'MINIMUM_NUMBER_VALUE', + Required = 'REQUIRED', + Unique = 'UNIQUE', + UnknownPolicy = 'UNKNOWN_POLICY', + ValidArrayItems = 'VALID_ARRAY_ITEMS', + ValidDate = 'VALID_DATE', + ValidEmailAddress = 'VALID_EMAIL_ADDRESS_FORMAT', + ValidNameFormat = 'VALID_NAME_FORMAT', + ValidNumber = 'VALID_NUMBER', + ValidPhoneFormat = 'VALID_PHONE_FORMAT', + ValidQueryFilter = 'VALID_QUERY_FILTER', + ValidType = 'VALID_TYPE', +} + +export { PolicyKey }; diff --git a/packages/sdk-types/vite.config.ts b/packages/sdk-types/vite.config.ts index cad91dfcc5..85fbf37348 100644 Binary files a/packages/sdk-types/vite.config.ts and b/packages/sdk-types/vite.config.ts differ diff --git a/packages/sdk-utilities/eslint.config.mjs b/packages/sdk-utilities/eslint.config.mjs index d86da7ddff..24c9b92c96 100644 --- a/packages/sdk-utilities/eslint.config.mjs +++ b/packages/sdk-utilities/eslint.config.mjs @@ -34,7 +34,7 @@ export default [ ], }, languageOptions: { - parser: await import('jsonc-eslint-parser'), + parser: (await import('jsonc-eslint-parser')).default, }, }, { diff --git a/packages/sdk-utilities/package.json b/packages/sdk-utilities/package.json index dab48be36c..a3c3b3134c 100644 --- a/packages/sdk-utilities/package.json +++ b/packages/sdk-utilities/package.json @@ -15,8 +15,17 @@ "import": "./dist/src/index.js", "default": "./dist/src/index.js" }, + "./constants": { + "types": "./dist/src/lib/constants/index.d.ts", + "import": "./dist/src/lib/constants/index.js", + "default": "./dist/src/lib/constants/index.js" + }, "./package.json": "./package.json", - "./types": "./dist/src/types.js" + "./types": { + "types": "./dist/src/types.d.ts", + "import": "./dist/src/types.js", + "default": "./dist/src/types.js" + } }, "main": "./dist/src/index.js", "module": "./dist/src/index.js", diff --git a/packages/sdk-utilities/src/index.ts b/packages/sdk-utilities/src/index.ts index ed9b87ecaa..2657995089 100644 --- a/packages/sdk-utilities/src/index.ts +++ b/packages/sdk-utilities/src/index.ts @@ -8,4 +8,7 @@ */ export * from './lib/oidc/index.js'; +export * from './lib/strings/index.js'; export * from './lib/url/index.js'; +export * from './lib/object.utils.js'; +export * from './lib/constants/index.js'; diff --git a/packages/sdk-utilities/src/lib/constants/index.ts b/packages/sdk-utilities/src/lib/constants/index.ts new file mode 100644 index 0000000000..1263f0523f --- /dev/null +++ b/packages/sdk-utilities/src/lib/constants/index.ts @@ -0,0 +1,2 @@ +export const REQUESTED_WITH = 'forgerock-sdk' as const; +export const X_REQUESTED_PLATFORM = 'javascript' as const; diff --git a/packages/sdk-utilities/src/lib/object.utils.ts b/packages/sdk-utilities/src/lib/object.utils.ts new file mode 100644 index 0000000000..90fe02d375 --- /dev/null +++ b/packages/sdk-utilities/src/lib/object.utils.ts @@ -0,0 +1,56 @@ +/* + * @forgerock/javascript-sdk + * + * object.utils.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +function getProp(obj: { [key: string]: unknown } | undefined, prop: string, defaultValue: T): T { + if (!obj || obj[prop] === undefined) { + return defaultValue; + } + return obj[prop] as T; +} + +/** + * @method reduceToObject - goes one to two levels into source to collect attribute + * @param props - array of strings; can use dot notation for two level lookup + * @param src - source of attributes to check + */ +function reduceToObject( + props: string[], + src: Record, +): Record { + return props.reduce( + (prev, curr) => { + if (curr.includes('.')) { + const propArr = curr.split('.'); + const prop1 = propArr[0]; + const prop2 = propArr[1]; + const prop = (src[prop1] as Record)?.[prop2]; + prev[prop2] = prop != undefined ? (prop as string | number | null) : ''; + } else { + prev[curr] = src[curr] != undefined ? (src[curr] as string | number | null) : null; + } + return prev; + }, + {} as Record, + ); +} + +/** + * @method reduceToString - goes one level into source to collect attribute + * @param props - array of strings + * @param src - source of attributes to check + */ +function reduceToString(props: string[], src: Record): string { + return props.reduce((prev, curr) => { + prev = `${prev}${src[curr].filename};`; + return prev; + }, ''); +} + +export { getProp, reduceToObject, reduceToString }; diff --git a/packages/sdk-utilities/src/lib/strings/index.ts b/packages/sdk-utilities/src/lib/strings/index.ts new file mode 100644 index 0000000000..6b4af62026 --- /dev/null +++ b/packages/sdk-utilities/src/lib/strings/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +export * from './strings.utils.js'; diff --git a/packages/sdk-utilities/src/lib/strings/strings.utils.ts b/packages/sdk-utilities/src/lib/strings/strings.utils.ts new file mode 100644 index 0000000000..92c0fed974 --- /dev/null +++ b/packages/sdk-utilities/src/lib/strings/strings.utils.ts @@ -0,0 +1,21 @@ +/* + * @forgerock/javascript-sdk + * + * strings.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +/** + * @module + * @ignore + * These are private utility functions + */ +export function plural(n: number, singularText: string, pluralText?: string): string { + if (n === 1) { + return singularText; + } + return pluralText !== undefined ? pluralText : singularText + 's'; +} diff --git a/packages/sdk-utilities/src/lib/url/index.ts b/packages/sdk-utilities/src/lib/url/index.ts index d41b9fc1fd..d4caedeef2 100644 --- a/packages/sdk-utilities/src/lib/url/index.ts +++ b/packages/sdk-utilities/src/lib/url/index.ts @@ -6,3 +6,4 @@ */ export * from './am-url.utils.js'; +export * from './url.utils.js'; diff --git a/packages/sdk-utilities/src/lib/url/url.utils.ts b/packages/sdk-utilities/src/lib/url/url.utils.ts new file mode 100644 index 0000000000..16bd8b829b --- /dev/null +++ b/packages/sdk-utilities/src/lib/url/url.utils.ts @@ -0,0 +1,62 @@ +/* + * @forgerock/javascript-sdk + * + * url.ts + * + * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +/** + * Returns the base URL including protocol, hostname and any non-standard port. + * The returned URL does not include a trailing slash. + */ +function getBaseUrl(url: URL): string { + const isNonStandardPort = + (url.protocol === 'http:' && ['', '80'].indexOf(url.port) === -1) || + (url.protocol === 'https:' && ['', '443'].indexOf(url.port) === -1); + const port = isNonStandardPort ? `:${url.port}` : ''; + + let hostname = url.hostname; + // Detect IPv6 hostnames and wrap them in square brackets + if (hostname.includes(':') && !hostname.startsWith('[') && !hostname.endsWith(']')) { + hostname = `[${hostname}]`; + } + + const baseUrl = `${url.protocol}//${hostname}${port}`; + return baseUrl; +} + +function resolve(baseUrl: string, path: string): string { + const url = new URL(baseUrl); + + if (path.startsWith('/')) { + return `${getBaseUrl(url)}${path}`; + } + + const basePath = url.pathname.split('/'); + const destPath = path.split('/').filter((x) => !!x); + const newPath = [...basePath.slice(0, -1), ...destPath].join('/'); + + return `${getBaseUrl(url)}${newPath}`; +} + +function parseQuery(fullUrl: string): Record { + const url = new URL(fullUrl); + const query: Record = {}; + url.searchParams.forEach((v, k) => (query[k] = v)); + return query; +} + +function stringify(data: Record): string { + const pairs: string[] = []; + for (const k in data) { + if (data[k]) { + pairs.push(k + '=' + encodeURIComponent(data[k] as string)); + } + } + return pairs.join('&'); +} + +export { getBaseUrl, parseQuery, resolve, stringify }; diff --git a/packages/sdk-utilities/tsconfig.lib.json b/packages/sdk-utilities/tsconfig.lib.json index 4f61993c1a..4919a8c11a 100644 --- a/packages/sdk-utilities/tsconfig.lib.json +++ b/packages/sdk-utilities/tsconfig.lib.json @@ -17,18 +17,7 @@ "sourceMap": true, "lib": ["es2022", "dom", "dom.iterable"] }, - "include": ["src/**/*.ts", "src/**/*.*.ts"], - "exclude": [ - "src/**/*.*.test-d.ts", - "src/**/*.test-d.ts", - "vite.config.ts", - "src/**/*.spec.ts", - "src/**/*.test.ts", - "src/**/*.*test.ts", - "src/**/*.test-d.ts", - "src/**/*.types.test-d.ts", - "src/**/*.utils.test-d.ts", - "src/lib/mock-data/*" - ], + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/lib/mock-data/*"], "references": [] } diff --git a/packages/sdk-utilities/vite.config.ts b/packages/sdk-utilities/vite.config.ts index ed4a20837a..1add05b310 100644 Binary files a/packages/sdk-utilities/vite.config.ts and b/packages/sdk-utilities/vite.config.ts differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dcee8beca..ca353af4da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,7 +184,7 @@ importers: version: 2.4.2 jsdom: specifier: 26.1.0 - version: 26.1.0 + version: 26.1.0(canvas@3.2.0) jsonc-eslint-parser: specifier: ^2.1.0 version: 2.4.0 @@ -238,7 +238,7 @@ importers: version: 6.3.6(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + version: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) vitest-canvas-mock: specifier: ^0.3.3 version: 0.3.3(vitest@3.2.4) @@ -314,7 +314,7 @@ importers: devDependencies: '@effect/vitest': specifier: catalog:effect - version: 0.23.13(effect@3.17.7)(vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1)) + version: 0.23.13(effect@3.17.7)(vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1)) e2e/oidc-app: dependencies: @@ -367,7 +367,7 @@ importers: devDependencies: vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + version: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) packages/device-client: dependencies: @@ -382,6 +382,43 @@ importers: specifier: 'catalog:' version: 2.10.4(@types/node@22.14.1)(typescript@5.8.3) + packages/journey-client: + dependencies: + '@forgerock/sdk-logger': + specifier: workspace:* + version: link:../sdk-effects/logger + '@forgerock/sdk-request-middleware': + specifier: workspace:* + version: link:../sdk-effects/sdk-request-middleware + '@forgerock/sdk-types': + specifier: workspace:* + version: link:../sdk-types + '@forgerock/sdk-utilities': + specifier: workspace:* + version: link:../sdk-utilities + '@forgerock/storage': + specifier: workspace:* + version: link:../sdk-effects/storage + '@reduxjs/toolkit': + specifier: 'catalog:' + version: 2.8.2 + tslib: + specifier: ^2.3.0 + version: 2.8.1 + vite: + specifier: 6.3.4 + version: 6.3.4(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + vitest-canvas-mock: + specifier: ^0.3.3 + version: 0.3.3(vitest@1.6.1(@types/node@22.14.1)(@vitest/ui@3.0.4)(jsdom@26.1.0(canvas@3.2.0))(terser@5.44.0)) + devDependencies: + '@vitest/coverage-v8': + specifier: ^1.2.0 + version: 1.6.1(vitest@1.6.1(@types/node@22.14.1)(@vitest/ui@3.0.4)(jsdom@26.1.0(canvas@3.2.0))(terser@5.44.0)) + vitest: + specifier: ^1.2.0 + version: 1.6.1(@types/node@22.14.1)(@vitest/ui@3.0.4)(jsdom@26.1.0(canvas@3.2.0))(terser@5.44.0) + packages/oidc-client: dependencies: '@forgerock/iframe-manager': @@ -411,7 +448,7 @@ importers: devDependencies: '@effect/vitest': specifier: catalog:effect - version: 0.23.13(effect@3.17.7)(vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1)) + version: 0.23.13(effect@3.17.7)(vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1)) msw: specifier: 'catalog:' version: 2.10.4(@types/node@22.14.1)(typescript@5.8.3) @@ -449,9 +486,9 @@ importers: scratchpad: dependencies: - '@forgerock/pingone-scripts': + '@forgerock/journey-client': specifier: workspace:* - version: link:../tools/user-scripts + version: link:../packages/journey-client devDependencies: dotenv: specifier: 17.2.0 @@ -485,14 +522,14 @@ importers: version: 3.17.7 vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + version: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) devDependencies: '@effect/language-service': specifier: catalog:effect version: 0.35.2 '@effect/vitest': specifier: catalog:effect - version: 0.23.13(effect@3.17.7)(vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1)) + version: 0.23.13(effect@3.17.7)(vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1)) packages: @@ -1476,6 +1513,12 @@ packages: '@emnapi/wasi-threads@1.0.4': resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.23.1': resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} engines: {node: '>=18'} @@ -1488,6 +1531,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.23.1': resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} engines: {node: '>=18'} @@ -1500,6 +1549,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.23.1': resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} engines: {node: '>=18'} @@ -1512,6 +1567,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.23.1': resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} engines: {node: '>=18'} @@ -1524,6 +1585,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.23.1': resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} engines: {node: '>=18'} @@ -1536,6 +1603,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.23.1': resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} engines: {node: '>=18'} @@ -1548,6 +1621,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.23.1': resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} engines: {node: '>=18'} @@ -1560,6 +1639,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.23.1': resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} engines: {node: '>=18'} @@ -1572,6 +1657,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.23.1': resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} engines: {node: '>=18'} @@ -1584,6 +1675,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.23.1': resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} engines: {node: '>=18'} @@ -1596,6 +1693,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.23.1': resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} engines: {node: '>=18'} @@ -1608,6 +1711,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.23.1': resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} engines: {node: '>=18'} @@ -1620,6 +1729,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.23.1': resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} engines: {node: '>=18'} @@ -1632,6 +1747,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.23.1': resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} engines: {node: '>=18'} @@ -1644,6 +1765,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.23.1': resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} engines: {node: '>=18'} @@ -1656,6 +1783,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.23.1': resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} engines: {node: '>=18'} @@ -1668,6 +1801,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.23.1': resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} engines: {node: '>=18'} @@ -1686,6 +1825,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.23.1': resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} engines: {node: '>=18'} @@ -1710,6 +1855,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.23.1': resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} engines: {node: '>=18'} @@ -1728,6 +1879,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.23.1': resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} engines: {node: '>=18'} @@ -1740,6 +1897,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.23.1': resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} engines: {node: '>=18'} @@ -1752,6 +1915,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.23.1': resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} engines: {node: '>=18'} @@ -1764,6 +1933,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.23.1': resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} engines: {node: '>=18'} @@ -3142,6 +3317,11 @@ packages: resolution: {integrity: sha512-efg/bunOUMVXV+MlljJCrpuT+OQRrQS4wJyGL92B3epUGlgZ8DXs+nxN5v59v1a6AocAdSKwHgZS0g9txmBhOg==} engines: {node: '>=18'} + '@vitest/coverage-v8@1.6.1': + resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} + peerDependencies: + vitest: 1.6.1 + '@vitest/coverage-v8@3.2.4': resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: @@ -3151,6 +3331,9 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -3171,12 +3354,21 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} @@ -3185,6 +3377,9 @@ packages: peerDependencies: vitest: 3.0.4 + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@vitest/utils@3.0.4': resolution: {integrity: sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ==} @@ -3474,6 +3669,9 @@ packages: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3708,9 +3906,17 @@ packages: caniuse-lite@1.0.30001733: resolution: {integrity: sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q==} + canvas@3.2.0: + resolution: {integrity: sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==} + engines: {node: ^18.12.0 || >= 20.9.0} + caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + chai@5.2.1: resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} engines: {node: '>=18'} @@ -3737,10 +3943,16 @@ packages: chardet@2.1.0: resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -3869,6 +4081,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + confusing-browser-globals@1.0.11: resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} @@ -4072,6 +4287,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -4364,6 +4583,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.23.1: resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} engines: {node: '>=18'} @@ -4574,6 +4798,10 @@ packages: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expand-tilde@2.0.2: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} @@ -4835,6 +5063,9 @@ packages: resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} engines: {node: '>=18'} + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -4880,6 +5111,9 @@ packages: engines: {node: '>=16'} hasBin: true + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5676,6 +5910,10 @@ packages: resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} engines: {node: '>=6.11.5'} + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -5757,6 +5995,9 @@ packages: resolution: {integrity: sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==} engines: {node: '>=0.10.0'} + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.2.0: resolution: {integrity: sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==} @@ -5923,11 +6164,17 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} hasBin: true + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + module-definition@6.0.1: resolution: {integrity: sha512-FeVc50FTfVVQnolk/WQT8MX+2WVcDnTGiq6Wo+/+lJ2ET1bRVi3HG3YlJUfqagNMc/kUlFSoR96AJkxGpKz13g==} engines: {node: '>=18'} @@ -5999,6 +6246,9 @@ packages: engines: {node: ^18 || >=20} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -6016,6 +6266,10 @@ packages: nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + node-abi@3.77.0: + resolution: {integrity: sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==} + engines: {node: '>=10'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -6209,6 +6463,10 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -6307,9 +6565,15 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.1: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} @@ -6371,6 +6635,9 @@ packages: piscina@4.9.2: resolution: {integrity: sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkginfo@0.4.1: resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==} engines: {node: '>= 0.4.0'} @@ -6407,6 +6674,11 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + precinct@12.2.0: resolution: {integrity: sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w==} engines: {node: '>=18'} @@ -6839,6 +7111,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sirv@3.0.1: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} @@ -7031,6 +7309,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} @@ -7080,6 +7361,9 @@ packages: resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} engines: {node: '>=6'} + tar-fs@2.1.3: + resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -7149,6 +7433,10 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -7157,6 +7445,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.3: resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} engines: {node: '>=14.0.0'} @@ -7281,6 +7573,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -7346,6 +7642,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -7493,11 +7792,47 @@ packages: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite@5.4.20: + resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.3.4: resolution: {integrity: sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -7583,6 +7918,31 @@ packages: peerDependencies: vitest: '*' + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -9103,10 +9463,10 @@ snapshots: dependencies: effect: 3.17.7 - '@effect/vitest@0.23.13(effect@3.17.7)(vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1))': + '@effect/vitest@0.23.13(effect@3.17.7)(vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1))': dependencies: effect: 3.17.7 - vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) '@effect/workflow@0.5.1(@effect/platform@0.90.0(effect@3.17.7))(@effect/rpc@0.65.2(@effect/platform@0.90.0(effect@3.17.7))(effect@3.17.7))(effect@3.17.7)': dependencies: @@ -9127,102 +9487,153 @@ snapshots: dependencies: tslib: 2.8.1 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.23.1': optional: true '@esbuild/aix-ppc64@0.25.8': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.23.1': optional: true '@esbuild/android-arm64@0.25.8': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.23.1': optional: true '@esbuild/android-arm@0.25.8': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.23.1': optional: true '@esbuild/android-x64@0.25.8': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.23.1': optional: true '@esbuild/darwin-arm64@0.25.8': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.23.1': optional: true '@esbuild/darwin-x64@0.25.8': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.23.1': optional: true '@esbuild/freebsd-arm64@0.25.8': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.23.1': optional: true '@esbuild/freebsd-x64@0.25.8': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.23.1': optional: true '@esbuild/linux-arm64@0.25.8': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.23.1': optional: true '@esbuild/linux-arm@0.25.8': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.23.1': optional: true '@esbuild/linux-ia32@0.25.8': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.23.1': optional: true '@esbuild/linux-loong64@0.25.8': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.23.1': optional: true '@esbuild/linux-mips64el@0.25.8': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.23.1': optional: true '@esbuild/linux-ppc64@0.25.8': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.23.1': optional: true '@esbuild/linux-riscv64@0.25.8': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.23.1': optional: true '@esbuild/linux-s390x@0.25.8': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.23.1': optional: true @@ -9232,6 +9643,9 @@ snapshots: '@esbuild/netbsd-arm64@0.25.8': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.23.1': optional: true @@ -9244,6 +9658,9 @@ snapshots: '@esbuild/openbsd-arm64@0.25.8': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.23.1': optional: true @@ -9253,24 +9670,36 @@ snapshots: '@esbuild/openharmony-arm64@0.25.8': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.23.1': optional: true '@esbuild/sunos-x64@0.25.8': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.23.1': optional: true '@esbuild/win32-arm64@0.25.8': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.23.1': optional: true '@esbuild/win32-ia32@0.25.8': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.23.1': optional: true @@ -9933,7 +10362,7 @@ snapshots: semver: 7.7.2 tsconfig-paths: 4.2.0 vite: 6.3.6(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) - vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -10915,6 +11344,25 @@ snapshots: minimatch: 7.4.6 semver: 7.7.1 + '@vitest/coverage-v8@1.6.1(vitest@1.6.1(@types/node@22.14.1)(@vitest/ui@3.0.4)(jsdom@26.1.0(canvas@3.2.0))(terser@5.44.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + picocolors: 1.1.1 + std-env: 3.9.0 + strip-literal: 2.1.1 + test-exclude: 6.0.0 + vitest: 1.6.1(@types/node@22.14.1)(@vitest/ui@3.0.4)(jsdom@26.1.0(canvas@3.2.0))(terser@5.44.0) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 @@ -10930,10 +11378,16 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -10942,14 +11396,14 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(vite@6.3.4(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(vite@6.3.6(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: msw: 2.10.4(@types/node@22.14.1)(typescript@5.8.3) - vite: 6.3.4(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) '@vitest/pretty-format@3.0.4': dependencies: @@ -10959,18 +11413,34 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.0.0 + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.17 + pathe: 1.1.2 + pretty-format: 29.7.0 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.17 pathe: 2.0.3 + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.3 @@ -10984,7 +11454,14 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 '@vitest/utils@3.0.4': dependencies: @@ -11236,7 +11713,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.0 + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -11374,6 +11851,8 @@ snapshots: assert-plus@1.0.0: {} + assertion-error@1.1.0: {} + assertion-error@2.0.1: {} ast-module-types@6.0.1: {} @@ -11661,8 +12140,24 @@ snapshots: caniuse-lite@1.0.30001733: {} + canvas@3.2.0: + dependencies: + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + optional: true + caseless@0.12.0: {} + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + chai@5.2.1: dependencies: assertion-error: 2.0.1 @@ -11690,8 +12185,15 @@ snapshots: chardet@2.1.0: {} + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + check-error@2.1.1: {} + chownr@1.1.4: + optional: true + chrome-trace-event@1.0.4: {} ci-info@3.9.0: {} @@ -11815,6 +12317,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + confusing-browser-globals@1.0.11: {} content-disposition@0.5.4: @@ -11997,6 +12501,10 @@ snapshots: optionalDependencies: babel-plugin-macros: 3.1.0 + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + deep-eql@5.0.2: {} deep-extend@0.6.0: {} @@ -12313,6 +12821,32 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.23.1: optionalDependencies: '@esbuild/aix-ppc64': 0.23.1 @@ -12611,6 +13145,9 @@ snapshots: exit@0.1.2: {} + expand-template@2.0.3: + optional: true + expand-tilde@2.0.2: dependencies: homedir-polyfill: 1.0.3 @@ -12914,6 +13451,8 @@ snapshots: get-east-asian-width@1.3.0: {} + get-func-name@2.0.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -12966,6 +13505,9 @@ snapshots: meow: 12.1.1 split2: 4.2.0 + github-from-package@0.0.0: + optional: true + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -13194,7 +13736,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.0 + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -13839,7 +14381,7 @@ snapshots: jsbn@0.1.1: {} - jsdom@26.1.0: + jsdom@26.1.0(canvas@3.2.0): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 @@ -13861,6 +14403,8 @@ snapshots: whatwg-url: 14.2.0 ws: 8.18.3 xml-name-validator: 5.0.0 + optionalDependencies: + canvas: 3.2.0 transitivePeerDependencies: - bufferutil - supports-color @@ -13991,6 +14535,11 @@ snapshots: loader-runner@4.3.0: {} + local-pkg@0.5.1: + dependencies: + mlly: 1.8.0 + pkg-types: 1.3.1 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -14058,6 +14607,10 @@ snapshots: longest@2.0.1: {} + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + loupe@3.2.0: {} lowdb@1.0.0: @@ -14201,8 +14754,18 @@ snapshots: minipass@7.1.2: {} + mkdirp-classic@0.5.3: + optional: true + mkdirp@1.0.4: {} + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + module-definition@6.0.1: dependencies: ast-module-types: 6.0.1 @@ -14282,6 +14845,9 @@ snapshots: nanoid@5.1.5: {} + napi-build-utils@2.0.0: + optional: true + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -14292,6 +14858,11 @@ snapshots: nice-try@1.0.5: {} + node-abi@3.77.0: + dependencies: + semver: 7.7.2 + optional: true + node-addon-api@7.1.1: {} node-fetch@2.6.7: @@ -14542,6 +15113,10 @@ snapshots: dependencies: yocto-queue: 1.2.1 + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.1 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -14616,8 +15191,12 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@1.1.1: {} + pathval@2.0.1: {} peek-stream@1.1.3: @@ -14675,6 +15254,12 @@ snapshots: optionalDependencies: '@napi-rs/nice': 1.0.4 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + pkginfo@0.4.1: {} playwright-core@1.55.0: {} @@ -14709,6 +15294,22 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.0.4 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.77.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.3 + tunnel-agent: 0.6.0 + optional: true + precinct@12.2.0: dependencies: '@dependents/detective-less': 5.0.1 @@ -15194,6 +15795,16 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: + optional: true + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + sirv@3.0.1: dependencies: '@polka/url': 1.0.0-next.29 @@ -15407,6 +16018,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + strip-literal@3.0.0: dependencies: js-tokens: 9.0.1 @@ -15449,6 +16064,14 @@ snapshots: tapable@2.2.3: {} + tar-fs@2.1.3: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + optional: true + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -15523,10 +16146,14 @@ snapshots: fdir: 6.4.6(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@0.8.4: {} + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} + tinyspy@2.2.1: {} + tinyspy@4.0.3: {} tldts-core@6.1.86: {} @@ -15655,6 +16282,8 @@ snapshots: type-detect@4.0.8: {} + type-detect@4.1.0: {} + type-fest@0.20.2: {} type-fest@0.21.3: {} @@ -15732,6 +16361,8 @@ snapshots: uc.micro@2.1.0: {} + ufo@1.6.1: {} + uglify-js@3.19.3: optional: true @@ -15901,6 +16532,24 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 + vite-node@1.6.1(@types/node@22.14.1)(terser@5.44.0): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.20(@types/node@22.14.1)(terser@5.44.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@3.2.4(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1): dependencies: cac: 6.7.14 @@ -15922,6 +16571,16 @@ snapshots: - tsx - yaml + vite@5.4.20(@types/node@22.14.1)(terser@5.44.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.46.2 + optionalDependencies: + '@types/node': 22.14.1 + fsevents: 2.3.3 + terser: 5.44.0 + vite@6.3.4(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1): dependencies: esbuild: 0.25.8 @@ -15954,16 +16613,57 @@ snapshots: tsx: 4.17.0 yaml: 2.8.1 + vitest-canvas-mock@0.3.3(vitest@1.6.1(@types/node@22.14.1)(@vitest/ui@3.0.4)(jsdom@26.1.0(canvas@3.2.0))(terser@5.44.0)): + dependencies: + jest-canvas-mock: 2.5.2 + vitest: 1.6.1(@types/node@22.14.1)(@vitest/ui@3.0.4)(jsdom@26.1.0(canvas@3.2.0))(terser@5.44.0) + vitest-canvas-mock@0.3.3(vitest@3.2.4): dependencies: jest-canvas-mock: 2.5.2 - vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + + vitest@1.6.1(@types/node@22.14.1)(@vitest/ui@3.0.4)(jsdom@26.1.0(canvas@3.2.0))(terser@5.44.0): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.4 + chai: 4.5.0 + debug: 4.4.1 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.17 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.9.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.20(@types/node@22.14.1)(terser@5.44.0) + vite-node: 1.6.1(@types/node@22.14.1)(terser@5.44.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.14.1 + '@vitest/ui': 3.0.4(vitest@3.2.4) + jsdom: 26.1.0(canvas@3.2.0) + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser - vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1): + vitest@3.2.4(@types/node@22.14.1)(@vitest/ui@3.0.4)(jiti@2.4.2)(jsdom@26.1.0(canvas@3.2.0))(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(vite@6.3.4(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.10.4(@types/node@22.14.1)(typescript@5.8.3))(vite@6.3.6(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -15981,13 +16681,13 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.4(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) vite-node: 3.2.4(@types/node@22.14.1)(jiti@2.4.2)(terser@5.44.0)(tsx@4.17.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.14.1 '@vitest/ui': 3.0.4(vitest@3.2.4) - jsdom: 26.1.0 + jsdom: 26.1.0(canvas@3.2.0) transitivePeerDependencies: - jiti - less diff --git a/scratchpad/package.json b/scratchpad/package.json index 2b61c3fcf4..910ce2ebcc 100644 --- a/scratchpad/package.json +++ b/scratchpad/package.json @@ -15,7 +15,7 @@ "serve": "tsx ./src/main.ts" }, "dependencies": { - "@forgerock/pingone-scripts": "workspace:*" + "@forgerock/journey-client": "workspace:*" }, "devDependencies": { "dotenv": "17.2.0", diff --git a/scratchpad/src/main.ts b/scratchpad/src/main.ts index a24a6e00cc..6d8b26c45d 100644 --- a/scratchpad/src/main.ts +++ b/scratchpad/src/main.ts @@ -1,4 +1,50 @@ -import { getUsersAndDelete } from '@forgerock/pingone-scripts'; -import 'dotenv/config'; +import { journey } from '@forgerock/journey-client'; +import { callbackType } from '@forgerock/sdk-types'; +import type { NameCallback, PasswordCallback } from '@forgerock/journey-client'; -await getUsersAndDelete('email', 'ipt8tglj@autogenerated.com'); +async function authenticateUser() { + const client = await journey({ + config: { + serverConfig: { baseUrl: 'https://your-am-instance.com' }, + realmPath: 'root', + tree: 'Login', + }, + }); + + try { + // Start the journey + let step = await client.start(); + + // Handle NameCallback + if (step.getCallbacksOfType(callbackType.NameCallback).length > 0) { + const nameCallback = step.getCallbackOfType(callbackType.NameCallback); + console.log('Prompt for username:', nameCallback.getPrompt()); + nameCallback.setName('demo'); // Set the username + step = await client.next({ step: step.payload }); // Submit the step + } + + // Handle PasswordCallback + if (step.getCallbacksOfType(callbackType.PasswordCallback).length > 0) { + const passwordCallback = step.getCallbackOfType( + callbackType.PasswordCallback, + ); + console.log('Prompt for password:', passwordCallback.getPrompt()); + passwordCallback.setPassword('password'); // Set the password + step = await client.next({ step: step.payload }); // Submit the step + } + + // Check for success or failure + if (step.type === 'LoginSuccess') { + console.log('Login successful!', step.getSessionToken()); + } else if (step.type === 'LoginFailure') { + console.error('Login failed:', step.getMessage()); + } else { + console.log('Next step requires further interaction:', step); + // Further logic to handle other callback types + } + } catch (error) { + console.error('An error occurred during the authentication journey:', error); + } +} + +authenticateUser(); diff --git a/scratchpad/tsconfig.json b/scratchpad/tsconfig.json index bda6314554..5c38b209af 100644 --- a/scratchpad/tsconfig.json +++ b/scratchpad/tsconfig.json @@ -21,7 +21,10 @@ }, "references": [ { - "path": "../tools/user-scripts" + "path": "../packages/sdk-types" + }, + { + "path": "../packages/journey-client" } ] } diff --git a/tsconfig.json b/tsconfig.json index 7dbd2aeb74..b5d57bda62 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -66,6 +66,9 @@ }, { "path": "./tools/user-scripts" + }, + { + "path": "./packages/journey-client" } ] }