1
0
mirror of https://github.com/bitwarden/directory-connector synced 2026-02-28 10:13:15 +00:00

Compare commits

...

12 Commits

Author SHA1 Message Date
Brandon
1f0bb5a71e update callers to vNext state service 2026-02-27 13:13:06 -05:00
Brandon
ac96470627 merge state service re-write 2026-02-27 13:12:54 -05:00
Brandon
ded20e9321 clean up 2026-02-27 13:10:21 -05:00
Brandon
43eba4cb92 Migrate all remaining cjs files to esm 2026-02-27 13:02:26 -05:00
Brandon
537b7489a9 migrate configuration files to ESM 2026-02-27 13:00:28 -05:00
Brandon
65f37446b9 add skill for migrating CJS to ESM + example 2026-02-27 12:58:35 -05:00
Brandon
c23fbfbad1 flatten account structure using claude 2026-02-27 12:58:17 -05:00
Brandon
4e8cccdb30 add tech debt context for DC Modernization 2026-02-27 12:55:00 -05:00
Brandon
19d6078f74 fix integration test 2026-02-27 12:54:49 -05:00
Brandon
23acdf63bf fix type issues 2026-02-27 12:54:41 -05:00
Brandon
293f673f5e add tests 2026-02-27 12:54:27 -05:00
Brandon
99f6af8dc8 scaffold new state service, add migration, initial commit 2026-02-27 12:54:18 -05:00
47 changed files with 2437 additions and 272 deletions

View File

@@ -1,3 +1,7 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Bitwarden Directory Connector
## Project Overview
@@ -20,6 +24,392 @@ Directory Connector is a TypeScript application that synchronizes users and grou
- Node
- Jest for testing
### Current Project Status
**Mission Critical but Deprioritized:** Directory Connector is used to sync customer directory services with their Bitwarden organization. While SCIM is the more modern cloud-hosted solution, not all directory services support SCIM, and SCIM is only available on Enterprise plans. Therefore, DC remains mission-critical infrastructure for many paying customers, but it's deprioritized in the codebase due to infrequent changes.
**Isolated Repository:** Unlike other Bitwarden client applications that live in a monorepo with shared core libraries, Directory Connector was kept separate when other TypeScript clients moved to the monorepo. It got its own copy of the jslib repo to avoid unnecessary regressions from apparently unrelated code changes in other clients. This severed it from the rest of the codebase, causing:
- Outdated dependencies that can't be updated (ES modules vs CommonJS conflicts)
- File/folder structure that doesn't match modern Bitwarden client patterns
- Accumulated technical debt requiring significant investment to pay down
- jslib contains unused code from all clients, but cannot be deleted due to monolithic/tightly coupled architecture
**Critical Issues (Current Status):**
-~~Electron, Node, and Angular are on unmaintained versions~~ **RESOLVED** - All updated (Electron 39, Node 20, Angular 21, TypeScript 5.9)
-`keytar` is archived (Dec 2022) and incompatible with Node v22, **blocking Node upgrades beyond v20** - **PRIMARY BLOCKER**
- ❌ No ESM support blocks dependency upgrades: googleapis, lowdb, chalk, inquirer, node-fetch, electron-store
- ⚠️ 70 dev dependencies + 31 runtime dependencies = excessive maintenance burden (count increased with Angular 21 tooling)
- ❌ StateService is a large pre-StateProvider monolith containing every getter/setter for all clients (PM-31159 In Progress)
-~~Angular CLI not used~~ **RESOLVED** - Angular CLI 21.1.2 now integrated with angular.json configuration
**Development Approach:** When working on this codebase, prioritize sustainability and maintainability over adding new features. Consider how changes will affect long-term maintenance burden.
## Tech Debt Roadmap
### Progress Summary
**Completed:**
- ✅ Phase 0 (Immediate Priority): All major dependencies upgraded (Node 20, Angular 21, TypeScript 5.9, Electron 39)
- ✅ Phase 6: Angular CLI integration complete
**In Progress:**
- 🔄 Phase 1: StateService rewrite (PM-31159)
**Blocked/Todo:**
- ❌ Phase 2: Remove remaining jslib code (blocked by Phase 1)
- ❌ Phase 3: Repository restructure (should be done before Phase 5)
- ⚠️ Phase 4: Replace Keytar **[CRITICAL BLOCKER]** - blocking Node v22+ upgrades
- ❌ Phase 5: ESM Support (blocked by Phase 3, needed for googleapis, lowdb, chalk, inquirer, etc.)
**Primary Blocker:** Keytar removal (Phase 4) is the most critical task as it blocks Node upgrades beyond v20.
---
### ✅ Immediate Priority: Unsupported Dependencies (COMPLETED)
**Upgrade Path (July 2025 release) - STATUS: COMPLETE**
All major version upgrades have been completed and exceeded targets:
1. ✅ Node 18.20.8 → 20.18 → **COMPLETE** (engines: `~20`, .nvmrc: `v20`)
2. ✅ Angular 17 → 18.2.x → **EXCEEDED** (now at **21.1.1**)
3. ✅ TypeScript 5.4.5 → 5.6.0 → **EXCEEDED** (now at **5.9.3**)
4. ✅ Electron 34 → 36 → **EXCEEDED** (now at **39.2.1**)
5. ✅ Angular matches clients monorepo version (21.x)
**Current Versions:**
- Node: v20 (project target), blocked from v22+ by keytar
- TypeScript: 5.9.3
- Angular: 21.1.1 (all packages)
- Electron: 39.2.1 (well beyond EOL target of 36)
- @yao-pkg/pkg: 5.16.1 (community fork replacing archived pkg)
**Note:** Further Node upgrades to v22+ are **blocked by keytar** (see Phase 4). Electron 36 was EOL October 2028, but we're already on 39.2.1.
### Phase 1: StateService Rewrite (PM-31159, In Progress)
**Problem:** StateService is a post-account-switching, pre-StateProvider monolith containing every getter/setter for all clients. This prevents deletion of unused data models and code. Never very stable, and more complex than DC needs (DC doesn't need account switching).
**Current Status:** 🔄 **Active PR** - [#990](https://github.com/bitwarden/directory-connector/pull/990) (Open, Author: @BTreston)
- PR created: Feb 2, 2026
- Last updated: Feb 5, 2026
- Files changed: 17 files (+1,512, -41 lines)
- Commits: 4 (scaffold, add tests, fix type issues, fix integration test)
**Implementation Details:**
**New Architecture:**
- Created `StateServiceVNext` interface (`src/abstractions/state-vNext.service.ts`)
- New implementation: `StateServiceVNextImplementation` (`src/services/state-service/state-vNext.service.ts`)
- New state model with flat key-value structure (`src/models/state.model.ts`)
- Comprehensive test suite: `state-vNext.service.spec.ts` (488 lines of tests)
**Storage Key Structure:**
```typescript
// vNext Storage Keys (Flat key-value structure)
StorageKeysVNext = {
stateVersion: "stateVersion",
directoryType: "directoryType",
organizationId: "organizationId",
directory_ldap: "directory_ldap",
directory_gsuite: "directory_gsuite",
directory_entra: "directory_entra",
directory_okta: "directory_okta",
directory_onelogin: "directory_onelogin",
sync: "sync",
syncingDir: "syncingDir",
};
// Secure storage keys for sensitive data
SecureStorageKeysVNext = {
ldap: "secret_ldap",
gsuite: "secret_gsuite",
azure: "secret_azure", // Backwards compatible with old name
entra: "secret_entra",
okta: "secret_okta",
oneLogin: "secret_oneLogin",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
};
```
**Migration Strategy:**
- State version bumped to `StateVersion.Five` (`jslib/common/src/enums/stateVersion.ts`)
- Enhanced `StateMigrationService` to handle migration from old account-based structure to new flat structure
- Migration keys defined for backwards compatibility (`MigrationKeys`, `SecureStorageKeysMigration`)
- Temporary keys used during migration (`TempKeys`) to preserve data during transition
**File Organization:**
- State-related files moved to `src/services/state-service/` subdirectory:
- `state-vNext.service.ts` (new implementation)
- `state-vNext.service.spec.ts` (488 lines of tests)
- `state.service.ts` (legacy, moved from `src/services/`)
- `stateMigration.service.ts` (enhanced for v5 migration)
- New abstraction: `src/abstractions/state-vNext.service.ts`
- New model: `src/models/state.model.ts` (defines all storage keys)
**Integration:**
- Both old `StateService` and new `StateServiceVNext` injected in parallel during migration phase
- `DirectoryFactoryService` updated to accept both services
- Services module provides both implementations
- CLI (`bwdc.ts`) and GUI (`main.ts`) both instantiate new service alongside old one
**Chosen Approach Benefits:**
- Clean break with old StateService - high degree of certainty
- Simple and focused on DC's needs (no account switching, no rxjs)
- Flat key-value structure easier to maintain
- Versioning and migration capabilities included
- Keeps existing data.json around during transition
- All getters/setters in one place (acceptable for small application)
**Rejected Approaches:**
- Copy StateProvider from clients: Too complex (supports account switching, rxjs, syncing background/foreground contexts)
- Rewrite simplified StateService keeping current data structure: Commits us to previous decisions, keeps monolithic account objects
**Next Steps:**
- Complete PR review and merge
- Monitor for regressions during initial rollout
- After several releases, can remove old StateService and migration code
- Begin Phase 2: Remove remaining jslib code that was only needed by old StateService
### Phase 2: Remove Remaining jslib Code
After StateService is removed, review and delete old models and remaining services that referenced each other. jslib contains unused code from all clients that DC doesn't need.
### Phase 3: Restructure Repository (PM-31852, To Do)
**Current Structure:**
```
src/ # Both Electron and CLI app code
src-cli/ # package.json entry point for CLI only, no code
jslib/
├── common/ # Shared common code
├── node/ # Node specific code used in CLI
└── electron/ # Electron specific code used in GUI
```
**Target Structure:**
```
src-gui/ # Electron specific code only (combining src (partial) + jslib/electron)
src-cli/ # Node and CLI specific code only (combining src (partial) + jslib/node)
libs/ # Shared app-independent DC code, e.g. sync services (combining src (partial) + jslib/common)
```
**Why:** Makes subsequent changes (code reorganizing, ESM support) much easier. This should be done early in the modernization process.
### Phase 4: Replace Keytar (PM-12436, To Do) ⚠️ **CRITICAL BLOCKER**
**Problem:** `keytar` (OS secure storage for secrets) was archived December 2022 and is incompatible with Node v22, **actively blocking Node upgrades beyond v20**.
**Current Status:**
- `keytar`: **7.9.0** (still present in dependencies)
- **This is the #1 blocker preventing Node v22+ upgrades**
- All "Immediate Priority" dependencies have been upgraded, but further progress requires removing keytar
**Solution:** Migrate to Bitwarden's Rust implementation in `desktop_native` (same as clients monorepo did)
1. Implement Rust <-> NAPI integration (like `desktop_native/napi`) from Electron app to Rust code
2. Copy, rename, and expose necessary functions
3. Point to `desktop_native` crate using git link from DC repo (no need for SDK yet):
```rust
desktop_core = { git = "https://github.com/bitwarden/clients", rev = "00cf24972d944638bbd1adc00a0ae3eeabb6eb9a" }
```
**Important:** `keytar` uses wrong encoding on Windows (UTF-8 instead of UTF-16). Bitwarden uses UTF-16. Code should contain a migration - ensure old values are migrated correctly during testing.
**Priority:** This should be prioritized as it's blocking the Node upgrade path and has been archived for over 2 years.
### Phase 5: Add ESM Support (PM-31850, To Do)
**Problem:** No ESM module support prevents upgrading key dependencies.
**Blocked Dependencies (Current Status):**
- ❌ `googleapis`: **149.0.0** → current (major dependency, disabled in renovate.json5)
- ❌ `lowdb`: **1.0.0** → v7
- ❌ `@types/lowdb`: **1.0.15** (can be deleted once inquirer is upgraded)
- ❌ `@electron/notarize`: **2.5.0** → v3.0.1
- ❌ `chalk`: **4.1.2** → v5.3.0
- ❌ `inquirer`: **8.2.6** → v12.1.0
- ❌ `@types/inquirer`: **8.2.10** (should be deleted when inquirer upgraded)
- ❌ `node-fetch`: **2.7.0** → v3.3.2 (should use native Node fetch API when on Node >=21)
- ❌ `electron-store`: **8.2.0** → v10.1.0
**Status:** These dependencies remain blocked as expected. They will stay on old versions until:
1. Phase 3 (Repository Restructure) is complete
2. ESM support is implemented
3. Note: These ESM dependencies are primarily used in CLI build, so restructuring first (Phase 3) will limit the impact of ESM migration.
**Implementation:**
1. Update tsconfig.json and package.json configurations
2. Update import/export syntax to no longer use `require` statements
3. Upgrade dependencies to move away from CommonJS (ESM can import CommonJS, but not vice versa)
4. Trial and error
**Reference:** [Pure ESM package guide](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)
### Phase 6: Add Angular CLI (PM-31849, In Progress / Possibly Complete?)
**Problem:** Angular CLI provides great DX and makes it easier to manage Angular changes (e.g. auto-migrations). DC didn't use it.
**Current Status:**
- ✅ `@angular/cli`: **21.1.2** is now present in **runtime dependencies**
- ✅ `@angular/build`: **21.1.2** is present in dev dependencies
- ✅ All Angular tooling has been updated to v21.x
**Status:** ✅ **COMPLETE** - Angular CLI has been successfully integrated:
- `angular.json` configuration file exists
- `.angular/` cache directory present
- `@angular/cli` 21.1.2 in runtime dependencies
- `@angular/build` 21.1.2 in dev dependencies
- All Angular packages updated to v21.x
This migration provides improved DX and access to Angular's auto-migration tools for future updates.
### Additional Considerations
**Reduce Dependency Count:** Current state is 70 dev dependencies + 31 runtime dependencies (101 total). The dev dependency count increased from the original 66 due to Angular 21 upgrade adding additional tooling. After removing old code, review dependency list:
- Can we remove some after code cleanup?
- Could we reintegrate with monorepo to leverage Component Library and shared platform dependencies?
- **Risk:** Becomes tightly coupled with monorepo code → regression risk, move slower due to coupling
**GitHub Workflows:** Need review and modernization:
- PM-20478: Add check-run workflow for CI on community PRs
- PM-18290: Add linting workflow
- PM-18289: Update build workflow
- `pkg` and `pkg-fetch` for packaging Node runtime in CLI release are archived (fork exists but untrusted; clients vets all changes manually)
- Options: Make our own fork, or use Node's single executable binary support (investigate)
## Common Development Commands
### Desktop App (Electron + Angular)
**Initial Setup:**
```bash
npm install # Install dependencies (runs git submodule init automatically)
npm run rebuild # Rebuild native modules for Electron
```
**Development:**
```bash
npm run electron # Build and run desktop app with hot reload and debugging
npm run electron:ignore # Same as above but ignores certificate errors
```
**Building:**
```bash
npm run build # Build both main and renderer processes
npm run build:main # Build Electron main process only
npm run build:renderer # Build Angular renderer process only
npm run build:renderer:watch # Build renderer with file watching
```
**Distribution:**
```bash
npm run dist:mac # Create macOS distributable
npm run dist:win # Create Windows distributable
npm run dist:lin # Create Linux distributable
```
### CLI (bwdc)
**Development:**
```bash
npm run build:cli:watch # Build CLI with file watching
node ./build-cli/bwdc.js --help # Run the CLI from build output
```
**Production Build:**
```bash
npm run build:cli:prod # Build CLI for production
npm run dist:cli # Create platform-specific CLI executables (all platforms)
npm run dist:cli:mac # Create macOS CLI executable only
npm run dist:cli:win # Create Windows CLI executable only
npm run dist:cli:lin # Create Linux CLI executable only
```
### Testing
**Unit Tests:**
```bash
npm test # Run unit tests (excludes integration tests)
npm run test:watch # Run unit tests in watch mode
npm run test:watch:all # Run unit tests in watch mode (all files)
npm run test:types # Run TypeScript type checking without emitting files
```
**Integration Tests:**
```bash
npm run test:integration:setup # Set up Docker containers for LDAP testing
npm run test:integration # Run integration tests
npm run test:integration:watch # Run integration tests in watch mode
```
Integration tests require Docker and test against live directory services. The setup command creates OpenLDAP containers using docker-compose.yml.
### Linting & Formatting
```bash
npm run lint # Run ESLint and Prettier checks
npm run lint:fix # Auto-fix ESLint issues
npm run prettier # Format all files with Prettier
```
### Submodule Management
The `jslib` folder is a git submodule containing shared Bitwarden libraries:
```bash
npm run sub:update # Update submodule to latest remote version
npm run sub:pull # Pull latest changes in submodule
npm run sub:commit # Pull and commit submodule update
```
### Utility Commands
```bash
npm run reset # Remove keytar modules and reinstall (use when switching between CLI/desktop)
npm run clean:dist # Clean desktop distribution files
npm run clean:dist:cli # Clean CLI distribution files
```
**Important:** When switching between developing the desktop app and CLI, run `npm run reset` to avoid native module conflicts.
## Code Architecture & Structure
### Directory Organization
@@ -45,6 +435,32 @@ jslib/ # Legacy folder structure (mix of deprecated/unused and c
3. **Directory Service Pattern**: Each directory provider implements `IDirectoryService` interface
4. **Separation of Concerns**: GUI (Angular app) and CLI (commands) share the same service layer
### Core Synchronization Flow
The sync process follows this pattern:
1. **DirectoryFactoryService** (`src/services/directory-factory.service.ts`) - Creates the appropriate directory service based on DirectoryType configuration
2. **IDirectoryService** implementation (`src/services/directory-services/*.service.ts`) - Each provider (LDAP, Entra ID, Google, Okta, OneLogin) implements:
- `getEntries(force, test)` - Returns `[GroupEntry[], UserEntry[]]`
- Provider-specific authentication and API calls
3. **SyncService** (`src/services/sync.service.ts`) - Orchestrates the sync:
- Calls directory service to get entries
- Filters and deduplicates users/groups
- Uses BatchRequestBuilder or SingleRequestBuilder to format API requests
- Generates hash to detect changes and avoid redundant syncs
- Sends data to Bitwarden API via ApiService
4. **Request Builders** (`src/services/*-request-builder.ts`) - Transform directory entries into Bitwarden API format
### Shared Library (jslib)
The `jslib` folder is a git submodule containing shared Bitwarden code:
- Common services (API, Crypto, Storage, Auth)
- Platform utilities
- Shared models and abstractions
**Important:** This is legacy structure - do not add new code to jslib. New code should go in `src/`.
## Development Conventions
### Code Organization

239
.claude/plan.md Normal file
View File

@@ -0,0 +1,239 @@
# Phase 2 PR #1: Flatten Account Model - IMPLEMENTATION COMPLETE
## Status: ✅ COMPLETED
**Implementation Date:** February 13, 2026
**All tests passing:** 120/120 ✅
**TypeScript compilation:** Success ✅
---
## Summary
Successfully implemented Phase 2 PR #1: Flatten Account Model. The Account model has been simplified from 177 lines (51 + 126 inherited) to 51 lines, removing the BaseAccount inheritance and flattening nested structures into direct properties.
## Changes Implemented
### Files Modified (7 files)
1. **`jslib/common/src/enums/stateVersion.ts`**
- Added `StateVersion.Five` for the flattened Account structure
- Updated `StateVersion.Latest = Five`
2. **`src/models/account.ts`**
- Removed `extends BaseAccount` inheritance
- Removed `ClientKeys` class (redundant)
- Flattened 6 authentication fields to top level:
- `userId`, `entityId`, `apiKeyClientId`
- `accessToken`, `refreshToken`, `apiKeyClientSecret`
- Kept `DirectoryConfigurations` and `DirectorySettings` unchanged
- Added compatibility fields with FIXME comment for jslib infrastructure:
- `data?`, `keys?`, `profile?`, `settings?`, `tokens?` (optional, unused)
- Simplified constructor without Object.assign
3. **`src/services/stateMigration.service.ts`**
- Added `migrateStateFrom3To4()` placeholder migration
- Added `migrateStateFrom4To5()` to flatten nested → flat Account structure
- Updated `migrate()` method with new case statements for v3→v4 and v4→v5
- Updated `migrateStateFrom1To2()` to use flattened structure (removed `account.profile`, `account.clientKeys`)
4. **`src/services/auth.service.ts`**
- Removed imports: `AccountKeys`, `AccountProfile`, `AccountTokens`
- Simplified account creation from 26 lines to 10 lines (62% reduction)
- Direct property assignment instead of nested objects with spread operators
5. **`src/services/state.service.ts`**
- Changed `account.profile.userId``account.userId`
- Removed `account.settings` from `scaffoldNewAccountDiskStorage`
- Added `settings` back to `resetAccount` for base class compatibility (unused but required)
6. **`src/services/authService.spec.ts`**
- Removed imports: `AccountKeys`, `AccountProfile`, `AccountTokens`
- Updated test expectations to match new flat Account structure
### Files Created (1 file)
7. **`src/services/stateMigration.service.spec.ts`**
- Comprehensive migration test suite (5 tests, 210 lines)
- Tests flattening nested account structure
- Tests handling missing nested objects gracefully
- Tests empty account list
- Tests preservation of directory configurations and settings
- Tests state version update
## Code Reduction Achieved
- **Account model:** 177 lines (51 + 126 inherited) → 51 lines (71% reduction)
- **AuthService account creation:** 26 lines → 10 lines (62% reduction)
- **Import statements removed:** 5 jslib imports across multiple files
## Migration Logic
### State Version v4 → v5 Migration
The `migrateStateFrom4To5()` method handles conversion from nested to flat structure:
```typescript
// OLD (nested structure):
{
profile: {
userId: "CLIENT_ID",
entityId: "CLIENT_ID",
apiKeyClientId: "organization.CLIENT_ID"
},
tokens: {
accessToken: "token",
refreshToken: "refresh"
},
keys: {
apiKeyClientSecret: "secret"
}
}
// NEW (flat structure):
{
userId: "CLIENT_ID",
entityId: "CLIENT_ID",
apiKeyClientId: "organization.CLIENT_ID",
accessToken: "token",
refreshToken: "refresh",
apiKeyClientSecret: "secret"
}
```
**Migration Safety:**
- Null-safe property access with `??` operator
- Preserves all directory configurations and settings
- Falls back to userId if profile.userId doesn't exist
- Handles empty account lists gracefully
## Test Results
### Unit Tests: ✅ PASS
```
Test Suites: 14 passed, 14 total
Tests: 120 passed, 120 total
```
New tests added:
- `should flatten nested account structure`
- `should handle missing nested objects gracefully`
- `should handle empty account list`
- `should preserve directory configurations and settings`
- `should update state version after successful migration`
### TypeScript Compilation: ✅ PASS
```
npm run test:types
```
All type checks pass with zero errors.
## Technical Notes
### Compatibility Fields
Added optional compatibility fields to Account model to satisfy jslib infrastructure type constraints:
```typescript
// FIXME: Remove these compatibility fields after StateServiceVNext migration (PR #990) is merged
// These fields are unused but required for type compatibility with jslib's StateService infrastructure
data?: any;
keys?: any;
profile?: any;
settings?: any;
tokens?: any;
```
These will be removed after PR #990 (StateServiceVNext) merges and old StateService is deleted.
### Key Architectural Decision
Chose to add compatibility fields rather than refactor entire jslib infrastructure because:
1. PR #990 (StateServiceVNext) will eventually replace this infrastructure
2. Minimizes changes needed in this PR
3. Avoids conflicts with PR #990
4. Can be cleaned up later
## What This Enables
### Immediate Benefits
- ✅ Simplified Account model (71% code reduction)
- ✅ Clearer authentication field structure
- ✅ Easier debugging (no nested property access)
- ✅ Self-documenting code (obvious what DC needs)
### Enables Future Work
- **Phase 2 PR #2:** Remove StateFactory infrastructure
- **Phase 2 PR #3:** Delete ~90 unused jslib files including:
- EncString (only used by old nested Account)
- SymmetricCryptoKey (only used by old nested Account)
- OrganizationData (completely unused)
- ProviderData (completely unused)
- AccountKeys, AccountProfile, AccountTokens, AccountData, AccountSettings
## Merge Strategy
**Conflict Management:**
- This PR targets current codebase (with old StateService)
- Will conflict with PR #990 (StateServiceVNext) when it merges
- Plan: Rebase this PR after #990 merges
- Expected conflicts: StateService files, Account model structure
- Resolution: Keep StateServiceVNext changes, apply Account flattening to new structure
## Next Steps
1. **Review & Test:** Thorough code review and manual testing
2. **Create PR:** Open PR with comprehensive description and test results
3. **Manual Testing Scenarios:**
- Fresh installation → authentication flow
- Existing installation → migration runs successfully
- All directory types → configuration persists correctly
- CLI authentication → flat structure works
4. **After Merge:**
- Begin Phase 2 PR #2: Remove StateFactory Infrastructure
- Monitor for any migration issues in production
## Related Work
- **Depends On:** None (can merge independently)
- **Blocks:** Phase 2 PR #2 (Remove StateFactory), Phase 2 PR #3 (Delete Unused jslib Files)
- **Conflicts With:** PR #990 (StateServiceVNext) - plan to rebase after #990 merges
- **Part Of:** Phase 2 tech debt cleanup (see CLAUDE.md)
---
## Original Implementation Plan
[The original detailed step-by-step plan from the conversation has been preserved below for reference]
### Context
Directory Connector's Account model currently extends jslib's BaseAccount, inheriting 126 lines of complex nested structures designed for multi-account password manager features that DC doesn't use. This inheritance creates unnecessary coupling and blocks cleanup of unused jslib dependencies.
**Current State:**
- Account extends BaseAccount with nested objects: `profile.userId`, `tokens.accessToken`, `keys.apiKeyClientSecret`
- Only 6 fields from BaseAccount are actually used by DC
- 120+ lines of inherited code (AccountData, AccountKeys, AccountProfile, AccountSettings, AccountTokens) are unused
- Creates dependencies on EncString, SymmetricCryptoKey, OrganizationData, ProviderData that DC never uses
**Problem:**
- Unnecessary complexity for a single-account application
- Blocks deletion of unused jslib models (Phase 2 goal)
- Verbose account creation code (26 lines to set 6 fields)
- Difficult to understand what DC actually needs
**Goal:**
Flatten Account model to contain only the 8 fields DC uses, removing BaseAccount inheritance. This enables Phase 2 PR #2 and PR #3 to delete ~90 unused jslib files.
[Rest of original plan preserved in conversation transcript]

View File

@@ -0,0 +1,130 @@
---
userInvocable: true
---
# CommonJS to ESM Conversion
Convert a file (or files) from CommonJS module syntax to ECMAScript Modules (ESM).
## Usage
```
/commonjs-to-esm <file-path> [additional-file-paths...]
```
## Parameters
- `file-path` - Path to the file(s) to convert from CommonJS to ESM
## Examples
```
/commonjs-to-esm src/services/auth.service.ts
/commonjs-to-esm src/utils/helper.ts src/utils/parser.ts
```
## Process
This skill performs a comprehensive analysis and planning process:
### 1. Analyze Target File(s)
For each file to convert:
- Read the file contents
- Identify its purpose and functionality
- Catalog all CommonJS patterns used:
- `require()` statements
- `module.exports` assignments
- `exports.x = ...` assignments
- Dynamic requires
- `__dirname` and `__filename` usage
### 2. Find Dependents
- Search for all files that import/require the target file(s)
- Identify the import patterns used by dependents
- Map the dependency tree to understand impact scope
### 3. Analyze Dependencies
- List all modules the target file(s) depend on
- Determine if dependencies support ESM
- Identify potential blocking dependencies (CommonJS-only packages)
- Check for dynamic imports that may need special handling
### 4. Identify Conversion Challenges
Common issues to flag:
- `__dirname` and `__filename` (need `import.meta.url` conversion)
- Dynamic `require()` calls (need `import()` conversion)
- Conditional requires (need refactoring)
- JSON imports (need `assert { type: 'json' }`)
- CommonJS-only dependencies (may block conversion)
- Circular dependencies (may need restructuring)
### 5. Generate Conversion Plan
Create a step-by-step plan that includes:
**Target File Changes:**
- Convert `require()` to `import` statements
- Convert `module.exports` to `export` statements
- Update `__dirname`/`__filename` to use `import.meta.url`
- Handle dynamic imports appropriately
- Update file extensions if needed (e.g., `.js` to `.mjs`)
**Dependent File Changes:**
- Update all import statements in dependent files
- Ensure consistent naming (default vs named exports)
- Update path references if extensions change
**Configuration Changes:**
- `package.json`: Add `"type": "module"` or use `.mjs` extension
- `tsconfig.json`: Update `module` and `moduleResolution` settings
- Build tools: Update bundler/compiler configurations
**Testing Strategy:**
- Run unit tests after conversion
- Verify no runtime errors from import changes
- Check that all exports are accessible
- Test dynamic import scenarios
### 6. Risk Assessment
Evaluate:
- Number of files affected
- Complexity of CommonJS patterns used
- Presence of blocking dependencies
- Potential for breaking changes
### 7. Present Plan
Output a structured plan with:
- Summary of changes needed
- Ordered steps for execution
- List of files to modify
- Configuration changes required
- Testing checkpoints
- Risk factors and mitigation strategies
- Estimated scope (small/medium/large change)
## Notes
- ESM is **not** compatible with CommonJS in all cases - ESM can import CommonJS, but CommonJS **cannot** require ESM
- This means conversions should generally proceed from leaf dependencies upward
- Some packages remain CommonJS-only and may block full conversion
- The skill generates a plan but does NOT automatically execute the conversion - review and approve first
## References
- [Pure ESM package guide](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)
- [Node.js ESM documentation](https://nodejs.org/api/esm.html)
- [TypeScript ESM support](https://www.typescriptlang.org/docs/handbook/esm-node.html)

View File

@@ -10,7 +10,7 @@
"output": "dist",
"app": "build"
},
"afterSign": "scripts/notarize.js",
"afterSign": "scripts/notarize.mjs",
"mac": {
"artifactName": "Bitwarden-Connector-${version}-mac.${ext}",
"category": "public.app-category.productivity",
@@ -22,7 +22,7 @@
},
"win": {
"target": ["portable", "nsis"],
"sign": "scripts/sign.js"
"sign": "scripts/sign.mjs"
},
"linux": {
"category": "Utility",

View File

@@ -1,14 +1,14 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("./tsconfig");
import { pathsToModuleNameMapper } from "ts-jest";
import tsconfig from "./tsconfig.json" with { type: "json" };
const tsPreset = require("ts-jest/jest-preset");
const angularPreset = require("jest-preset-angular/jest-preset");
const { defaultTransformerOptions } = require("jest-preset-angular/presets");
import angularPresetsModule from "jest-preset-angular/presets/index.js";
const { defaultTransformerOptions } = angularPresetsModule;
const { compilerOptions } = tsconfig;
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
// ...tsPreset,
// ...angularPreset,
export default {
preset: "jest-preset-angular",
reporters: ["default", "jest-junit"],

View File

@@ -3,5 +3,6 @@ export enum StateVersion {
Two = 2, // Move to a typed State object
Three = 3, // Fix migration of users' premium status
Four = 4, // Fix 'Never Lock' option by removing stale data
Latest = Four,
Five = 5, // New state service implementation
Latest = Five,
}

View File

@@ -1,13 +1,11 @@
import * as fs from "fs";
import { ipcMain } from "electron";
import Store from "electron-store";
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
import { NodeUtils } from "@/jslib/common/src/misc/nodeUtils";
// eslint-disable-next-line
const Store = require("electron-store");
export class ElectronStorageService implements StorageService {
private store: any;

View File

@@ -31,14 +31,14 @@
"lint": "eslint . && prettier --check .",
"lint:fix": "eslint . --fix",
"build": "concurrently -n Main,Rend -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "webpack --config webpack.main.cjs",
"build:renderer": "webpack --config webpack.renderer.cjs",
"build:renderer:watch": "webpack --config webpack.renderer.cjs --watch",
"build:main": "webpack --config webpack.main.mjs",
"build:renderer": "webpack --config webpack.renderer.mjs",
"build:renderer:watch": "webpack --config webpack.renderer.mjs --watch",
"build:dist": "npm run reset && npm run rebuild && npm run build",
"build:cli": "webpack --config webpack.cli.cjs",
"build:cli:watch": "webpack --config webpack.cli.cjs --watch",
"build:cli:prod": "cross-env NODE_ENV=production webpack --config webpack.cli.cjs",
"build:cli:prod:watch": "cross-env NODE_ENV=production webpack --config webpack.cli.cjs --watch",
"build:cli": "webpack --config webpack.cli.mjs",
"build:cli:watch": "webpack --config webpack.cli.mjs --watch",
"build:cli:prod": "cross-env NODE_ENV=production webpack --config webpack.cli.mjs",
"build:cli:prod:watch": "cross-env NODE_ENV=production webpack --config webpack.cli.mjs --watch",
"electron": "npm run build:main && concurrently -k -n Main,Rend -c yellow,cyan \"electron --inspect=5858 ./build --watch\" \"npm run build:renderer:watch\"",
"electron:ignore": "npm run build:main && concurrently -k -n Main,Rend -c yellow,cyan \"electron --inspect=5858 --ignore-certificate-errors ./build --watch\" \"npm run build:renderer:watch\"",
"clean:dist": "rimraf --glob ./dist/*",

View File

@@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/no-var-requires */
require("dotenv").config();
const { notarize } = require("@electron/notarize");
import "dotenv/config";
import notarizeModule from "@electron/notarize";
exports.default = async function notarizing(context) {
const { notarize } = notarizeModule;
export default async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== "darwin") {
return;
@@ -33,4 +34,4 @@ exports.default = async function notarizing(context) {
appleIdPassword: appleIdPassword,
});
}
};
}

View File

@@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/no-var-requires, no-console */
exports.default = async function (configuration) {
import { execSync } from "child_process";
export default async function (configuration) {
if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && configuration.path.slice(-4) == ".exe") {
console.log(`[*] Signing file: ${configuration.path}`);
require("child_process").execSync(
execSync(
`azuresigntool sign ` +
`-kvu ${process.env.SIGNING_VAULT_URL} ` +
`-kvi ${process.env.SIGNING_CLIENT_ID} ` +
@@ -18,4 +19,4 @@ exports.default = async function (configuration) {
},
);
}
};
}

View File

@@ -0,0 +1,60 @@
import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions";
import { DirectoryType } from "@/src/enums/directoryType";
import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration";
import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
export abstract class StateServiceVNext {
getDirectory: <IConfiguration>(type: DirectoryType) => Promise<IConfiguration>;
setDirectory: (
type: DirectoryType,
config:
| LdapConfiguration
| GSuiteConfiguration
| EntraIdConfiguration
| OktaConfiguration
| OneLoginConfiguration,
) => Promise<any>;
getLdapConfiguration: (options?: StorageOptions) => Promise<LdapConfiguration>;
setLdapConfiguration: (value: LdapConfiguration, options?: StorageOptions) => Promise<void>;
getGsuiteConfiguration: (options?: StorageOptions) => Promise<GSuiteConfiguration>;
setGsuiteConfiguration: (value: GSuiteConfiguration, options?: StorageOptions) => Promise<void>;
getEntraConfiguration: (options?: StorageOptions) => Promise<EntraIdConfiguration>;
setEntraConfiguration: (value: EntraIdConfiguration, options?: StorageOptions) => Promise<void>;
getOktaConfiguration: (options?: StorageOptions) => Promise<OktaConfiguration>;
setOktaConfiguration: (value: OktaConfiguration, options?: StorageOptions) => Promise<void>;
getOneLoginConfiguration: (options?: StorageOptions) => Promise<OneLoginConfiguration>;
setOneLoginConfiguration: (
value: OneLoginConfiguration,
options?: StorageOptions,
) => Promise<void>;
getOrganizationId: (options?: StorageOptions) => Promise<string>;
setOrganizationId: (value: string, options?: StorageOptions) => Promise<void>;
getSync: (options?: StorageOptions) => Promise<SyncConfiguration>;
setSync: (value: SyncConfiguration, options?: StorageOptions) => Promise<void>;
getDirectoryType: (options?: StorageOptions) => Promise<DirectoryType>;
setDirectoryType: (value: DirectoryType, options?: StorageOptions) => Promise<void>;
getUserDelta: (options?: StorageOptions) => Promise<string>;
setUserDelta: (value: string, options?: StorageOptions) => Promise<void>;
getLastUserSync: (options?: StorageOptions) => Promise<Date>;
setLastUserSync: (value: Date, options?: StorageOptions) => Promise<void>;
getLastGroupSync: (options?: StorageOptions) => Promise<Date>;
setLastGroupSync: (value: Date, options?: StorageOptions) => Promise<void>;
getGroupDelta: (options?: StorageOptions) => Promise<string>;
setGroupDelta: (value: string, options?: StorageOptions) => Promise<void>;
getLastSyncHash: (options?: StorageOptions) => Promise<string>;
setLastSyncHash: (value: string, options?: StorageOptions) => Promise<void>;
getSyncingDir: (options?: StorageOptions) => Promise<boolean>;
setSyncingDir: (value: boolean, options?: StorageOptions) => Promise<void>;
clearSyncSettings: (syncHashToo: boolean) => Promise<void>;
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
getEntityId: (options?: StorageOptions) => Promise<string>;
init: () => Promise<void>;
clean: () => Promise<void>;
getInstalledVersion: (options?: StorageOptions) => Promise<string>;
setInstalledVersion: (value: string, options?: StorageOptions) => Promise<void>;
}

View File

@@ -9,7 +9,7 @@ import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUt
import { Utils } from "@/jslib/common/src/misc/utils";
import { AuthService } from "../../abstractions/auth.service";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { EnvironmentComponent } from "./environment.component";
@@ -41,7 +41,7 @@ export class ApiKeyComponent {
private platformUtilsService: PlatformUtilsService,
private modalService: ModalService,
private logService: LogService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {}
async submit() {

View File

@@ -18,7 +18,7 @@ import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUt
import { TokenService } from "@/jslib/common/src/abstractions/token.service";
import { AuthService } from "../abstractions/auth.service";
import { StateService } from "../abstractions/state.service";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
import { SyncService } from "../services/sync.service";
const BroadcasterSubscriptionId = "AppComponent";
@@ -45,7 +45,7 @@ export class AppComponent implements OnInit {
private platformUtilsService: PlatformUtilsService,
private messagingService: MessagingService,
private syncService: SyncService,
private stateService: StateService,
private stateService: StateServiceVNext,
private logService: LogService,
) {}

View File

@@ -2,12 +2,12 @@ import { Injectable } from "@angular/core";
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
@Injectable()
export class AuthGuardService {
constructor(
private stateService: StateService,
private stateService: StateServiceVNext,
private messagingService: MessagingService,
) {}

View File

@@ -1,12 +1,12 @@
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
@Injectable()
export class LaunchGuardService {
constructor(
private stateService: StateService,
private stateService: StateServiceVNext,
private router: Router,
) {}

View File

@@ -31,12 +31,14 @@ import { DefaultDirectoryFactoryService } from "@/src/services/directory-factory
import { SingleRequestBuilder } from "@/src/services/single-request-builder";
import { AuthService as AuthServiceAbstraction } from "../../abstractions/auth.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService as StateServiceAbstraction } from "../../abstractions/state.service";
import { Account } from "../../models/account";
import { AuthService } from "../../services/auth.service";
import { I18nService } from "../../services/i18n.service";
import { StateService } from "../../services/state.service";
import { StateMigrationService } from "../../services/stateMigration.service";
import { StateServiceVNextImplementation } from "../../services/state-service/state-vNext.service";
import { StateService } from "../../services/state-service/state.service";
import { StateMigrationService } from "../../services/state-service/stateMigration.service";
import { SyncService } from "../../services/sync.service";
import { AuthGuardService } from "./auth-guard.service";
@@ -48,7 +50,7 @@ export function initFactory(
environmentService: EnvironmentServiceAbstraction,
i18nService: I18nServiceAbstraction,
platformUtilsService: PlatformUtilsServiceAbstraction,
stateService: StateServiceAbstraction,
stateService: StateServiceVNext,
cryptoService: CryptoServiceAbstraction,
): () => Promise<void> {
return async () => {
@@ -89,7 +91,7 @@ export function initFactory(
EnvironmentServiceAbstraction,
I18nServiceAbstraction,
PlatformUtilsServiceAbstraction,
StateServiceAbstraction,
StateServiceVNext,
CryptoServiceAbstraction,
],
multi: true,
@@ -166,7 +168,7 @@ export function initFactory(
AppIdServiceAbstraction,
PlatformUtilsServiceAbstraction,
MessagingServiceAbstraction,
StateServiceAbstraction,
StateServiceVNext,
],
}),
safeProvider({
@@ -178,7 +180,7 @@ export function initFactory(
MessagingServiceAbstraction,
I18nServiceAbstraction,
EnvironmentServiceAbstraction,
StateServiceAbstraction,
StateServiceVNext,
BatchRequestBuilder,
SingleRequestBuilder,
DirectoryFactoryService,
@@ -222,6 +224,29 @@ export function initFactory(
StateMigrationServiceAbstraction,
],
}),
// Use new StateServiceVNext with flat key-value structure (new interface)
safeProvider({
provide: StateServiceVNext,
useFactory: (
storageService: StorageServiceAbstraction,
secureStorageService: StorageServiceAbstraction,
logService: LogServiceAbstraction,
stateMigrationService: StateMigrationServiceAbstraction,
) =>
new StateServiceVNextImplementation(
storageService,
secureStorageService,
logService,
stateMigrationService,
true,
),
deps: [
StorageServiceAbstraction,
SECURE_STORAGE,
LogServiceAbstraction,
StateMigrationServiceAbstraction,
],
}),
safeProvider({
provide: SingleRequestBuilder,
deps: [],
@@ -233,7 +258,7 @@ export function initFactory(
safeProvider({
provide: DirectoryFactoryService,
useClass: DefaultDirectoryFactoryService,
deps: [LogServiceAbstraction, I18nServiceAbstraction, StateServiceAbstraction],
deps: [LogServiceAbstraction, I18nServiceAbstraction, StateServiceVNext],
}),
] satisfies SafeProvider[],
})

View File

@@ -5,7 +5,7 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { GroupEntry } from "../../models/groupEntry";
import { SimResult } from "../../models/simResult";
import { UserEntry } from "../../models/userEntry";
@@ -41,7 +41,7 @@ export class DashboardComponent implements OnInit, OnDestroy {
private messagingService: MessagingService,
private platformUtilsService: PlatformUtilsService,
private changeDetectorRef: ChangeDetectorRef,
private stateService: StateService,
private stateService: StateServiceVNext,
) {}
async ngOnInit() {

View File

@@ -5,7 +5,7 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
const BroadcasterSubscriptionId = "MoreComponent";
@@ -26,7 +26,7 @@ export class MoreComponent implements OnInit {
private broadcasterService: BroadcasterService,
private ngZone: NgZone,
private changeDetectorRef: ChangeDetectorRef,
private stateService: StateService,
private stateService: StateServiceVNext,
) {}
async ngOnInit() {

View File

@@ -4,7 +4,7 @@ import { webUtils } from "electron";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { DirectoryType } from "../../enums/directoryType";
import { EntraIdConfiguration } from "../../models/entraIdConfiguration";
import { GSuiteConfiguration } from "../../models/gsuiteConfiguration";
@@ -39,7 +39,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
private ngZone: NgZone,
private logService: LogService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {
this.directoryOptions = [
{ name: this.i18nService.t("select"), value: null },

View File

@@ -1,4 +1,6 @@
import * as fs from "fs";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import * as path from "path";
import { StorageService as StorageServiceAbstraction } from "@/jslib/common/src/abstractions/storage.service";
@@ -17,7 +19,10 @@ import { ConsoleLogService } from "@/jslib/node/src/cli/services/consoleLog.serv
import { NodeApiService } from "@/jslib/node/src/services/nodeApi.service";
import { NodeCryptoFunctionService } from "@/jslib/node/src/services/nodeCryptoFunction.service";
import packageJson from "../package.json";
import { DirectoryFactoryService } from "./abstractions/directory-factory.service";
import { StateServiceVNext } from "./abstractions/state-vNext.service";
import { Account } from "./models/account";
import { Program } from "./program";
import { AuthService } from "./services/auth.service";
@@ -27,12 +32,15 @@ import { I18nService } from "./services/i18n.service";
import { KeytarSecureStorageService } from "./services/keytarSecureStorage.service";
import { LowdbStorageService } from "./services/lowdbStorage.service";
import { SingleRequestBuilder } from "./services/single-request-builder";
import { StateService } from "./services/state.service";
import { StateMigrationService } from "./services/stateMigration.service";
import { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service";
import { StateService } from "./services/state-service/state.service";
import { StateMigrationService } from "./services/state-service/stateMigration.service";
import { SyncService } from "./services/sync.service";
// eslint-disable-next-line
const packageJson = require("../package.json");
// ESM __dirname polyfill for Node 20
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export class Main {
dataFilePath: string;
@@ -53,6 +61,7 @@ export class Main {
cryptoFunctionService: NodeCryptoFunctionService;
authService: AuthService;
syncService: SyncService;
stateServiceVNext: StateServiceVNext;
stateService: StateService;
stateMigrationService: StateMigrationService;
directoryFactoryService: DirectoryFactoryService;
@@ -116,6 +125,14 @@ export class Main {
process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true",
new StateFactory(GlobalState, Account),
);
// Use new StateServiceVNext with flat key-value structure
this.stateServiceVNext = new StateServiceVNextImplementation(
this.storageService,
this.secureStorageService,
this.logService,
this.stateMigrationService,
process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true",
);
this.cryptoService = new CryptoService(
this.cryptoFunctionService,
@@ -150,13 +167,13 @@ export class Main {
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.stateService,
this.stateServiceVNext,
);
this.directoryFactoryService = new DefaultDirectoryFactoryService(
this.logService,
this.i18nService,
this.stateService,
this.stateServiceVNext,
);
this.batchRequestBuilder = new BatchRequestBuilder();
@@ -168,7 +185,7 @@ export class Main {
this.messagingService,
this.i18nService,
this.environmentService,
this.stateService,
this.stateServiceVNext,
this.batchRequestBuilder,
this.singleRequestBuilder,
this.directoryFactoryService,

View File

@@ -4,12 +4,12 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { Response } from "@/jslib/node/src/cli/models/response";
import { MessageResponse } from "@/jslib/node/src/cli/models/response/messageResponse";
import { StateService } from "../abstractions/state.service";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
export class ClearCacheCommand {
constructor(
private i18nService: I18nService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {}
async run(cmd: program.OptionValues): Promise<Response> {

View File

@@ -6,7 +6,7 @@ import { NodeUtils } from "@/jslib/common/src/misc/nodeUtils";
import { Response } from "@/jslib/node/src/cli/models/response";
import { MessageResponse } from "@/jslib/node/src/cli/models/response/messageResponse";
import { StateService } from "../abstractions/state.service";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
import { DirectoryType } from "../enums/directoryType";
import { EntraIdConfiguration } from "../models/entraIdConfiguration";
import { GSuiteConfiguration } from "../models/gsuiteConfiguration";
@@ -28,7 +28,7 @@ export class ConfigCommand {
constructor(
private environmentService: EnvironmentService,
private i18nService: I18nService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {}
async run(setting: string, value: string, options: program.OptionValues): Promise<Response> {

View File

@@ -1,10 +1,10 @@
import { Response } from "@/jslib/node/src/cli/models/response";
import { StringResponse } from "@/jslib/node/src/cli/models/response/stringResponse";
import { StateService } from "../abstractions/state.service";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
export class LastSyncCommand {
constructor(private stateService: StateService) {}
constructor(private stateService: StateServiceVNext) {}
async run(object: string): Promise<Response> {
try {

View File

@@ -1,6 +1,9 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import * as path from "path";
import { app } from "electron";
import electronReload from "electron-reload";
import { StateFactory } from "@/jslib/common/src/factories/stateFactory";
import { GlobalState } from "@/jslib/common/src/models/domain/globalState";
@@ -11,12 +14,21 @@ import { TrayMain } from "@/jslib/electron/src/tray.main";
import { UpdaterMain } from "@/jslib/electron/src/updater.main";
import { WindowMain } from "@/jslib/electron/src/window.main";
import { StateServiceVNext } from "./abstractions/state-vNext.service";
import { DCCredentialStorageListener } from "./main/credential-storage-listener";
import { MenuMain } from "./main/menu.main";
import { MessagingMain } from "./main/messaging.main";
import { Account } from "./models/account";
import { I18nService } from "./services/i18n.service";
import { StateService } from "./services/state.service";
import { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service";
import { StateService } from "./services/state-service/state.service";
// ESM __dirname polyfill for Node 20
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Import electron-reload for dev mode hot reload
export class Main {
logService: ElectronLogService;
@@ -24,6 +36,7 @@ export class Main {
storageService: ElectronStorageService;
messagingService: ElectronMainMessagingService;
credentialStorageListener: DCCredentialStorageListener;
stateServiceVNext: StateServiceVNext;
stateService: StateService;
windowMain: WindowMain;
@@ -50,8 +63,7 @@ export class Main {
const watch = args.some((val) => val === "--watch");
if (watch) {
// eslint-disable-next-line
require("electron-reload")(__dirname, {});
electronReload(__dirname, {});
}
this.logService = new ElectronLogService(null, app.getPath("userData"));
@@ -66,6 +78,14 @@ export class Main {
true,
new StateFactory(GlobalState, Account),
);
// Use new StateServiceVNext with flat key-value structure
this.stateServiceVNext = new StateServiceVNextImplementation(
this.storageService,
null,
this.logService,
null,
true,
);
this.windowMain = new WindowMain(
this.stateService,

View File

@@ -1,5 +1,3 @@
import { Account as BaseAccount } from "@/jslib/common/src/models/domain/account";
import { DirectoryType } from "@/src/enums/directoryType";
import { EntraIdConfiguration } from "./entraIdConfiguration";
@@ -9,23 +7,39 @@ import { OktaConfiguration } from "./oktaConfiguration";
import { OneLoginConfiguration } from "./oneLoginConfiguration";
import { SyncConfiguration } from "./syncConfiguration";
export class Account extends BaseAccount {
directoryConfigurations?: DirectoryConfigurations = new DirectoryConfigurations();
export class Account {
// Authentication fields (flattened from nested profile/tokens/keys structure)
userId: string;
entityId: string;
apiKeyClientId: string;
accessToken: string;
refreshToken: string;
apiKeyClientSecret: string;
// Directory Connector specific fields
directoryConfigurations: DirectoryConfigurations = new DirectoryConfigurations();
directorySettings: DirectorySettings = new DirectorySettings();
clientKeys: ClientKeys = new ClientKeys();
// FIXME: Remove these compatibility fields after StateServiceVNext migration (PR #990) is merged
// These fields are unused but required for type compatibility with jslib's StateService infrastructure
data?: any;
keys?: any;
profile?: any;
settings?: any;
tokens?: any;
constructor(init: Partial<Account>) {
super(init);
this.userId = init?.userId;
this.entityId = init?.entityId;
this.apiKeyClientId = init?.apiKeyClientId;
this.accessToken = init?.accessToken;
this.refreshToken = init?.refreshToken;
this.apiKeyClientSecret = init?.apiKeyClientSecret;
this.directoryConfigurations = init?.directoryConfigurations ?? new DirectoryConfigurations();
this.directorySettings = init?.directorySettings ?? new DirectorySettings();
}
}
export class ClientKeys {
clientId: string;
clientSecret: string;
}
export class DirectoryConfigurations {
ldap: LdapConfiguration;
gsuite: GSuiteConfiguration;

108
src/models/state.model.ts Normal file
View File

@@ -0,0 +1,108 @@
// ===================================================================
// vNext Storage Keys (Flat key-value structure)
// ===================================================================
export const StorageKeysVNext = {
stateVersion: "stateVersion",
directoryType: "directoryType",
organizationId: "organizationId",
directory_ldap: "directory_ldap",
directory_gsuite: "directory_gsuite",
directory_entra: "directory_entra",
directory_okta: "directory_okta",
directory_onelogin: "directory_onelogin",
sync: "sync",
syncingDir: "syncingDir",
};
export const SecureStorageKeysVNext: { [key: string]: any } = {
ldap: "secret_ldap",
gsuite: "secret_gsuite",
// Azure Active Directory was renamed to Entra ID, but we've kept the old property name
// to be backwards compatible with existing configurations.
azure: "secret_azure",
entra: "secret_entra",
okta: "secret_okta",
oneLogin: "secret_oneLogin",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
};
// ===================================================================
// Legacy Storage Keys (Account-based hierarchy)
// ===================================================================
export const SecureStorageKeysLegacy = {
ldap: "ldapPassword",
gsuite: "gsuitePrivateKey",
// Azure Active Directory was renamed to Entra ID, but we've kept the old property name
// to be backwards compatible with existing configurations.
azure: "azureKey",
entra: "entraKey",
okta: "oktaToken",
oneLogin: "oneLoginClientSecret",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
};
export const TempKeys = {
tempAccountSettings: "tempAccountSettings",
tempDirectoryConfigs: "tempDirectoryConfigs",
tempDirectorySettings: "tempDirectorySettings",
};
// ===================================================================
// Migration Storage Keys
// ===================================================================
export const SecureStorageKeysMigration: { [key: string]: any } = {
ldap: "ldapPassword",
gsuite: "gsuitePrivateKey",
azure: "azureKey",
entra: "entraIdKey",
okta: "oktaToken",
oneLogin: "oneLoginClientSecret",
directoryConfigPrefix: "directoryConfig_",
sync: "syncConfig",
directoryType: "directoryType",
organizationId: "organizationId",
};
export const MigrationKeys: { [key: string]: any } = {
entityId: "entityId",
directoryType: "directoryType",
organizationId: "organizationId",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
syncingDir: "syncingDir",
syncConfig: "syncConfig",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
tempDirectoryConfigs: "tempDirectoryConfigs",
tempDirectorySettings: "tempDirectorySettings",
};
export const MigrationStateKeys = {
global: "global",
authenticatedAccounts: "authenticatedAccounts",
};
export const MigrationClientKeys: { [key: string]: any } = {
clientIdOld: "clientId",
clientId: "apikey_clientId",
clientSecretOld: "clientSecret",
clientSecret: "apikey_clientSecret",
};
// ===================================================================
// Shared Constants
// ===================================================================
export const StoredSecurely = "[STORED SECURELY]";

View File

@@ -2,18 +2,12 @@ import { ApiService } from "@/jslib/common/src/abstractions/api.service";
import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
import {
AccountKeys,
AccountProfile,
AccountTokens,
} from "@/jslib/common/src/models/domain/account";
import { DeviceRequest } from "@/jslib/common/src/models/request/deviceRequest";
import { ApiTokenRequest } from "@/jslib/common/src/models/request/identityToken/apiTokenRequest";
import { TokenRequestTwoFactor } from "@/jslib/common/src/models/request/identityToken/tokenRequestTwoFactor";
import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identityTokenResponse";
import { StateService } from "../abstractions/state.service";
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
export class AuthService {
constructor(
@@ -21,7 +15,7 @@ export class AuthService {
private appIdService: AppIdService,
private platformUtilsService: PlatformUtilsService,
private messagingService: MessagingService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {}
async logIn(credentials: { clientId: string; clientSecret: string }) {
@@ -58,34 +52,9 @@ export class AuthService {
) {
const clientId = tokenRequest.clientId;
const entityId = clientId.split("organization.")[1];
const clientSecret = tokenRequest.clientSecret;
await this.stateService.addAccount(
new Account({
profile: {
...new AccountProfile(),
...{
userId: entityId,
apiKeyClientId: clientId,
entityId: entityId,
},
},
tokens: {
...new AccountTokens(),
...{
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
},
},
keys: {
...new AccountKeys(),
...{
apiKeyClientSecret: clientSecret,
},
},
directorySettings: new DirectorySettings(),
directoryConfigurations: new DirectoryConfigurations(),
}),
);
// DC is single-organization, so we only need to set the organization ID
// TokenService handles token storage via its own StateService instance
await this.stateService.setOrganizationId(entityId);
}
}

View File

@@ -5,17 +5,11 @@ import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
import { Utils } from "@/jslib/common/src/misc/utils";
import {
AccountKeys,
AccountProfile,
AccountTokens,
} from "@/jslib/common/src/models/domain/account";
import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identityTokenResponse";
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
import { AuthService } from "./auth.service";
import { StateService } from "./state.service";
const clientId = "organization.CLIENT_ID";
const clientSecret = "CLIENT_SECRET";
@@ -39,7 +33,7 @@ describe("AuthService", () => {
let appIdService: jest.Mocked<AppIdService>;
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
let messagingService: jest.Mocked<MessagingService>;
let stateService: jest.Mocked<StateService>;
let stateService: jest.Mocked<StateServiceVNext>;
let authService: AuthService;
@@ -47,7 +41,7 @@ describe("AuthService", () => {
apiService = mock<ApiService>();
appIdService = mock<AppIdService>();
platformUtilsService = mock<PlatformUtilsService>();
stateService = mock<StateService>();
stateService = mock<StateServiceVNext>();
messagingService = mock<MessagingService>();
appIdService.getAppId.mockResolvedValue(deviceId);
@@ -61,38 +55,12 @@ describe("AuthService", () => {
);
});
it("sets the local environment after a successful login", async () => {
it("sets the organization ID after a successful login", async () => {
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
await authService.logIn({ clientId, clientSecret });
expect(stateService.addAccount).toHaveBeenCalledTimes(1);
expect(stateService.addAccount).toHaveBeenCalledWith(
new Account({
profile: {
...new AccountProfile(),
...{
userId: "CLIENT_ID",
apiKeyClientId: clientId, // with the "organization." prefix
entityId: "CLIENT_ID",
},
},
tokens: {
...new AccountTokens(),
...{
accessToken: accessToken,
refreshToken: refreshToken,
},
},
keys: {
...new AccountKeys(),
...{
apiKeyClientSecret: clientSecret,
},
},
directorySettings: new DirectorySettings(),
directoryConfigurations: new DirectoryConfigurations(),
}),
);
expect(stateService.setOrganizationId).toHaveBeenCalledTimes(1);
expect(stateService.setOrganizationId).toHaveBeenCalledWith("CLIENT_ID");
});
});

View File

@@ -2,7 +2,7 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
import { StateService } from "../abstractions/state.service";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
import { DirectoryType } from "../enums/directoryType";
import { EntraIdDirectoryService } from "./directory-services/entra-id-directory.service";
@@ -15,7 +15,7 @@ export class DefaultDirectoryFactoryService implements DirectoryFactoryService {
constructor(
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {}
createService(directoryType: DirectoryType) {

View File

@@ -7,7 +7,7 @@ import * as graphType from "@microsoft/microsoft-graph-types";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { DirectoryType } from "../../enums/directoryType";
import { EntraIdConfiguration } from "../../models/entraIdConfiguration";
import { GroupEntry } from "../../models/groupEntry";
@@ -44,7 +44,7 @@ export class EntraIdDirectoryService extends BaseDirectoryService implements IDi
constructor(
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {
super();
this.init();

View File

@@ -1,6 +1,8 @@
import { config as dotenvConfig } from "dotenv";
import { mock, MockProxy } from "jest-mock-extended";
import { StateServiceVNext } from "@/src/abstractions/state-vNext.service";
import { I18nService } from "../../../jslib/common/src/abstractions/i18n.service";
import { LogService } from "../../../jslib/common/src/abstractions/log.service";
import {
@@ -10,7 +12,6 @@ import {
import { groupFixtures } from "../../../utils/google-workspace/group-fixtures";
import { userFixtures } from "../../../utils/google-workspace/user-fixtures";
import { DirectoryType } from "../../enums/directoryType";
import { StateService } from "../state.service";
import { GSuiteDirectoryService } from "./gsuite-directory.service";
@@ -34,7 +35,7 @@ jest.setTimeout(15000);
describe("gsuiteDirectoryService", () => {
let logService: MockProxy<LogService>;
let i18nService: MockProxy<I18nService>;
let stateService: MockProxy<StateService>;
let stateService: MockProxy<StateServiceVNext>;
let directoryService: GSuiteDirectoryService;

View File

@@ -4,7 +4,8 @@ import { admin_directory_v1, google } from "googleapis";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "@/src/abstractions/state-vNext.service";
import { DirectoryType } from "../../enums/directoryType";
import { GroupEntry } from "../../models/groupEntry";
import { GSuiteConfiguration } from "../../models/gsuiteConfiguration";
@@ -24,7 +25,7 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
constructor(
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {
super();
this.service = google.admin("directory_v1");

View File

@@ -8,8 +8,8 @@ import {
} from "../../../utils/openldap/config-fixtures";
import { groupFixtures } from "../../../utils/openldap/group-fixtures";
import { userFixtures } from "../../../utils/openldap/user-fixtures";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { DirectoryType } from "../../enums/directoryType";
import { StateService } from "../state.service";
import { LdapDirectoryService } from "./ldap-directory.service";
@@ -22,7 +22,7 @@ import { LdapDirectoryService } from "./ldap-directory.service";
describe("ldapDirectoryService", () => {
let logService: MockProxy<LogService>;
let i18nService: MockProxy<I18nService>;
let stateService: MockProxy<StateService>;
let stateService: MockProxy<StateServiceVNext>;
let directoryService: LdapDirectoryService;

View File

@@ -7,7 +7,7 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { Utils } from "@/jslib/common/src/misc/utils";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { DirectoryType } from "../../enums/directoryType";
import { GroupEntry } from "../../models/groupEntry";
import { LdapConfiguration } from "../../models/ldapConfiguration";
@@ -31,7 +31,7 @@ export class LdapDirectoryService implements IDirectoryService {
constructor(
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {}
async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {

View File

@@ -3,7 +3,7 @@ import * as https from "https";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { DirectoryType } from "../../enums/directoryType";
import { GroupEntry } from "../../models/groupEntry";
import { OktaConfiguration } from "../../models/oktaConfiguration";
@@ -23,7 +23,7 @@ export class OktaDirectoryService extends BaseDirectoryService implements IDirec
constructor(
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {
super();
}

View File

@@ -1,7 +1,7 @@
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateService } from "../../abstractions/state.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { DirectoryType } from "../../enums/directoryType";
import { GroupEntry } from "../../models/groupEntry";
import { OneLoginConfiguration } from "../../models/oneLoginConfiguration";
@@ -23,7 +23,7 @@ export class OneLoginDirectoryService extends BaseDirectoryService implements ID
constructor(
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
private stateService: StateServiceVNext,
) {
super();
}

View File

@@ -0,0 +1,488 @@
import { mock, MockProxy } from "jest-mock-extended";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service";
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
import { DirectoryType } from "@/src/enums/directoryType";
import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration";
import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import { StorageKeysVNext as StorageKeys, StoredSecurely } from "@/src/models/state.model";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
import { StateServiceVNextImplementation } from "./state-vNext.service";
describe("StateServiceVNextImplementation", () => {
let storageService: MockProxy<StorageService>;
let secureStorageService: MockProxy<StorageService>;
let logService: MockProxy<LogService>;
let stateMigrationService: MockProxy<StateMigrationService>;
let stateService: StateServiceVNextImplementation;
beforeEach(() => {
storageService = mock<StorageService>();
secureStorageService = mock<StorageService>();
logService = mock<LogService>();
stateMigrationService = mock<StateMigrationService>();
stateService = new StateServiceVNextImplementation(
storageService,
secureStorageService,
logService,
stateMigrationService,
true, // useSecureStorageForSecrets
);
});
describe("init", () => {
it("should run migration if needed", async () => {
stateMigrationService.needsMigration.mockResolvedValue(true);
await stateService.init();
expect(stateMigrationService.needsMigration).toHaveBeenCalled();
expect(stateMigrationService.migrate).toHaveBeenCalled();
});
it("should not run migration if not needed", async () => {
stateMigrationService.needsMigration.mockResolvedValue(false);
await stateService.init();
expect(stateMigrationService.needsMigration).toHaveBeenCalled();
expect(stateMigrationService.migrate).not.toHaveBeenCalled();
});
});
describe("clean", () => {
it("should clear all directory settings and configurations", async () => {
await stateService.clean();
// Verify all directory types are cleared
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.directoryType, null);
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, null);
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, null);
});
});
describe("Directory Type", () => {
it("should store and retrieve directory type", async () => {
storageService.get.mockResolvedValue(DirectoryType.Ldap);
await stateService.setDirectoryType(DirectoryType.Ldap);
const result = await stateService.getDirectoryType();
expect(storageService.save).toHaveBeenCalledWith(
StorageKeys.directoryType,
DirectoryType.Ldap,
);
expect(result).toBe(DirectoryType.Ldap);
});
it("should return null when directory type is not set", async () => {
storageService.get.mockResolvedValue(null);
const result = await stateService.getDirectoryType();
expect(result).toBeNull();
});
});
describe("Organization Id", () => {
it("should store and retrieve organization ID", async () => {
const orgId = "test-org-123";
storageService.get.mockResolvedValue(orgId);
await stateService.setOrganizationId(orgId);
const result = await stateService.getOrganizationId();
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, orgId);
expect(result).toBe(orgId);
});
});
describe("LDAP Configuration", () => {
it("should store and retrieve LDAP configuration with secrets in secure storage", async () => {
const config: LdapConfiguration = {
ssl: true,
startTls: false,
tlsCaPath: null,
sslAllowUnauthorized: false,
sslCertPath: null,
sslKeyPath: null,
sslCaPath: null,
hostname: "ldap.example.com",
port: 636,
domain: null,
rootPath: null,
ad: true,
username: "admin",
password: "secret-password",
currentUser: false,
pagedSearch: true,
};
secureStorageService.get.mockResolvedValue("secret-password");
storageService.get.mockResolvedValue({
...config,
password: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.Ldap, config);
const result = await stateService.getDirectory<LdapConfiguration>(DirectoryType.Ldap);
// Verify password is stored in secure storage
expect(secureStorageService.save).toHaveBeenCalled();
// Verify configuration is stored
expect(storageService.save).toHaveBeenCalled();
// Verify retrieved config has real password from secure storage
expect(result?.password).toBe("secret-password");
});
it("should return null when LDAP configuration is not set", async () => {
storageService.get.mockResolvedValue(null);
const result = await stateService.getLdapConfiguration();
expect(result).toBeNull();
});
it("should handle null password in LDAP configuration", async () => {
const config: LdapConfiguration = {
ssl: true,
startTls: false,
tlsCaPath: null,
sslAllowUnauthorized: false,
sslCertPath: null,
sslKeyPath: null,
sslCaPath: null,
hostname: "ldap.example.com",
port: 636,
domain: null,
rootPath: null,
ad: true,
username: "admin",
password: null,
currentUser: false,
pagedSearch: true,
};
await stateService.setDirectory(DirectoryType.Ldap, config);
// Null passwords should call remove on the secure storage secret key
expect(secureStorageService.remove).toHaveBeenCalled();
});
});
describe("GSuite Configuration", () => {
it("should store and retrieve GSuite configuration with privateKey in secure storage", async () => {
const config: GSuiteConfiguration = {
domain: "example.com",
clientEmail: "service@example.com",
adminUser: "admin@example.com",
privateKey: "private-key-content",
customer: "customer-id",
};
secureStorageService.get.mockResolvedValue("private-key-content");
storageService.get.mockResolvedValue({
...config,
privateKey: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.GSuite, config);
const result = await stateService.getDirectory<GSuiteConfiguration>(DirectoryType.GSuite);
expect(secureStorageService.save).toHaveBeenCalled();
expect(result?.privateKey).toBe("private-key-content");
});
it("should handle null privateKey in GSuite configuration", async () => {
const config: GSuiteConfiguration = {
domain: "example.com",
clientEmail: "service@example.com",
adminUser: "admin@example.com",
privateKey: null,
customer: "customer-id",
};
await stateService.setDirectory(DirectoryType.GSuite, config);
// Null privateKey should call remove on the secure storage secret key
expect(secureStorageService.remove).toHaveBeenCalled();
});
});
describe("Entra ID Configuration", () => {
it("should store and retrieve Entra ID configuration with key in secure storage", async () => {
const config: EntraIdConfiguration = {
identityAuthority: "https://login.microsoftonline.com",
tenant: "tenant-id",
applicationId: "app-id",
key: "secret-key",
};
secureStorageService.get.mockResolvedValue("secret-key");
storageService.get.mockResolvedValue({
...config,
key: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.EntraID, config);
const result = await stateService.getDirectory<EntraIdConfiguration>(DirectoryType.EntraID);
expect(secureStorageService.save).toHaveBeenCalled();
expect(result?.key).toBe("secret-key");
});
it("should maintain backwards compatibility with Azure key storage", async () => {
const config: EntraIdConfiguration = {
identityAuthority: "https://login.microsoftonline.com",
tenant: "tenant-id",
applicationId: "app-id",
key: StoredSecurely,
};
storageService.get.mockResolvedValue(config);
secureStorageService.get.mockResolvedValueOnce(null); // entra key not found
secureStorageService.get.mockResolvedValueOnce("azure-secret-key"); // fallback to azure key
const result = await stateService.getDirectory<EntraIdConfiguration>(DirectoryType.EntraID);
expect(secureStorageService.get).toHaveBeenCalled();
expect(result?.key).toBe("azure-secret-key");
});
});
describe("Okta Configuration", () => {
it("should store and retrieve Okta configuration with token in secure storage", async () => {
const config: OktaConfiguration = {
orgUrl: "https://example.okta.com",
token: "okta-token",
};
secureStorageService.get.mockResolvedValue("okta-token");
storageService.get.mockResolvedValue({
...config,
token: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.Okta, config);
const result = await stateService.getDirectory<OktaConfiguration>(DirectoryType.Okta);
expect(secureStorageService.save).toHaveBeenCalled();
expect(result?.token).toBe("okta-token");
});
});
describe("OneLogin Configuration", () => {
it("should store and retrieve OneLogin configuration with clientSecret in secure storage", async () => {
const config: OneLoginConfiguration = {
region: "us",
clientId: "client-id",
clientSecret: "client-secret",
};
secureStorageService.get.mockResolvedValue("client-secret");
storageService.get.mockResolvedValue({
...config,
clientSecret: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.OneLogin, config);
const result = await stateService.getDirectory<OneLoginConfiguration>(DirectoryType.OneLogin);
expect(secureStorageService.save).toHaveBeenCalled();
expect(result?.clientSecret).toBe("client-secret");
});
});
describe("Sync Configuration", () => {
it("should store and retrieve sync configuration", async () => {
const syncConfig: SyncConfiguration = {
users: true,
groups: true,
interval: 5,
userFilter: null,
groupFilter: null,
removeDisabled: true,
overwriteExisting: false,
largeImport: false,
groupObjectClass: null,
userObjectClass: null,
groupPath: null,
userPath: null,
groupNameAttribute: null,
userEmailAttribute: null,
memberAttribute: "member",
creationDateAttribute: "whenCreated",
revisionDateAttribute: "whenChanged",
useEmailPrefixSuffix: false,
emailPrefixAttribute: null,
emailSuffix: null,
};
storageService.get.mockResolvedValue(syncConfig);
await stateService.setSync(syncConfig);
const result = await stateService.getSync();
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, syncConfig);
expect(result).toEqual(syncConfig);
});
});
describe("Sync Settings", () => {
it("should clear sync settings when clearSyncSettings is called", async () => {
await stateService.clearSyncSettings(false);
// Should set delta and sync values to null
expect(storageService.save).toHaveBeenCalled();
});
it("should clear lastSyncHash when hashToo is true", async () => {
await stateService.clearSyncSettings(true);
// Should set all values including lastSyncHash to null
expect(storageService.save).toHaveBeenCalled();
});
it("should not clear lastSyncHash when hashToo is false", async () => {
await stateService.clearSyncSettings(false);
// Should set delta and sync values but not lastSyncHash
expect(storageService.save).toHaveBeenCalled();
});
});
describe("Last Sync Hash", () => {
it("should store and retrieve last sync hash", async () => {
const hash = "hash";
storageService.get.mockResolvedValue(hash);
await stateService.setLastSyncHash(hash);
const result = await stateService.getLastSyncHash();
expect(storageService.save).toHaveBeenCalled();
expect(result).toBe(hash);
});
});
describe("Delta Tokens", () => {
it("should store and retrieve user delta token", async () => {
const token = "user-delta-token";
storageService.get.mockResolvedValue(token);
await stateService.setUserDelta(token);
const result = await stateService.getUserDelta();
expect(storageService.save).toHaveBeenCalled();
expect(result).toBe(token);
});
it("should store and retrieve group delta token", async () => {
const token = "group-delta-token";
storageService.get.mockResolvedValue(token);
await stateService.setGroupDelta(token);
const result = await stateService.getGroupDelta();
expect(storageService.save).toHaveBeenCalled();
expect(result).toBe(token);
});
});
describe("Last Sync Timestamps", () => {
it("should store and retrieve last user sync timestamp", async () => {
const timestamp = new Date("2024-01-01T00:00:00Z");
storageService.get.mockResolvedValue(timestamp.toISOString());
await stateService.setLastUserSync(timestamp);
const result = await stateService.getLastUserSync();
expect(storageService.save).toHaveBeenCalled();
expect(result?.toISOString()).toBe(timestamp.toISOString());
});
it("should store and retrieve last group sync timestamp", async () => {
const timestamp = new Date("2024-01-01T00:00:00Z");
storageService.get.mockResolvedValue(timestamp.toISOString());
await stateService.setLastGroupSync(timestamp);
const result = await stateService.getLastGroupSync();
expect(storageService.save).toHaveBeenCalled();
expect(result?.toISOString()).toBe(timestamp.toISOString());
});
it("should return null when last user sync timestamp is not set", async () => {
storageService.get.mockResolvedValue(null);
const result = await stateService.getLastUserSync();
expect(result).toBeNull();
});
it("should return null when last group sync timestamp is not set", async () => {
storageService.get.mockResolvedValue(null);
const result = await stateService.getLastGroupSync();
expect(result).toBeNull();
});
});
describe("Secure Storage Flag", () => {
it("should not separate secrets when useSecureStorageForSecrets is false", async () => {
const insecureStateService = new StateServiceVNextImplementation(
storageService,
secureStorageService,
logService,
stateMigrationService,
false, // useSecureStorageForSecrets = false
);
const config: LdapConfiguration = {
ssl: true,
startTls: false,
tlsCaPath: null,
sslAllowUnauthorized: false,
sslCertPath: null,
sslKeyPath: null,
sslCaPath: null,
hostname: "ldap.example.com",
port: 636,
domain: null,
rootPath: null,
ad: true,
username: "admin",
password: "secret-password",
currentUser: false,
pagedSearch: true,
};
storageService.get.mockResolvedValue(config);
// When useSecureStorageForSecrets is false, setDirectory doesn't process secrets
await insecureStateService.setDirectory(DirectoryType.Ldap, config);
// Retrieve config - should return password as-is from storage (not from secure storage)
const result = await insecureStateService.getDirectory<LdapConfiguration>(DirectoryType.Ldap);
// Password should be retrieved directly from storage, not secure storage
expect(result?.password).toBe("secret-password");
expect(secureStorageService.get).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,420 @@
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service";
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
import { EnvironmentUrls } from "@/jslib/common/src/models/domain/environmentUrls";
import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions";
import { StateServiceVNext as StateServiceVNextAbstraction } from "@/src/abstractions/state-vNext.service";
import { DirectoryType } from "@/src/enums/directoryType";
import { IConfiguration } from "@/src/models/IConfiguration";
import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration";
import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import {
SecureStorageKeysVNext as SecureStorageKeys,
StorageKeysVNext as StorageKeys,
StoredSecurely,
} from "@/src/models/state.model";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
export class StateServiceVNextImplementation implements StateServiceVNextAbstraction {
constructor(
protected storageService: StorageService,
protected secureStorageService: StorageService,
protected logService: LogService,
protected stateMigrationService: StateMigrationService,
private useSecureStorageForSecrets = true,
) {}
async init(): Promise<void> {
if (await this.stateMigrationService.needsMigration()) {
await this.stateMigrationService.migrate();
}
}
async clean(options?: StorageOptions): Promise<void> {
// Clear all directory settings and configurations
// but preserve version and environment settings
await this.setDirectoryType(null);
await this.setOrganizationId(null);
await this.setSync(null);
await this.setLdapConfiguration(null);
await this.setGsuiteConfiguration(null);
await this.setEntraConfiguration(null);
await this.setOktaConfiguration(null);
await this.setOneLoginConfiguration(null);
await this.clearSyncSettings(true);
}
async getIsAuthenticated(options?: StorageOptions): Promise<boolean> {
// DC is authenticated if there's an organization ID
const orgId = await this.getOrganizationId(options);
return orgId != null;
}
async getEntityId(options?: StorageOptions): Promise<string> {
// In DC, entity ID is the same as organization ID
return await this.getOrganizationId(options);
}
// ===================================================================
// Directory Configuration Methods
// ===================================================================
async getDirectory<T extends IConfiguration>(type: DirectoryType): Promise<T> {
const config = await this.getConfiguration(type);
if (config == null) {
return config as T;
}
if (this.useSecureStorageForSecrets) {
// Create a copy to avoid modifying the cached config
const configWithSecrets = Object.assign({}, config);
switch (type) {
case DirectoryType.Ldap:
(configWithSecrets as any).password = await this.getLdapSecret();
break;
case DirectoryType.EntraID:
(configWithSecrets as any).key = await this.getEntraSecret();
break;
case DirectoryType.Okta:
(configWithSecrets as any).token = await this.getOktaSecret();
break;
case DirectoryType.GSuite:
(configWithSecrets as any).privateKey = await this.getGsuiteSecret();
break;
case DirectoryType.OneLogin:
(configWithSecrets as any).clientSecret = await this.getOneLoginSecret();
break;
}
return configWithSecrets as T;
}
return config as T;
}
async setDirectory(
type: DirectoryType,
config:
| LdapConfiguration
| GSuiteConfiguration
| EntraIdConfiguration
| OktaConfiguration
| OneLoginConfiguration,
): Promise<any> {
if (this.useSecureStorageForSecrets) {
switch (type) {
case DirectoryType.Ldap: {
const ldapConfig = config as LdapConfiguration;
await this.setLdapSecret(ldapConfig.password);
ldapConfig.password = StoredSecurely;
await this.setLdapConfiguration(ldapConfig);
break;
}
case DirectoryType.EntraID: {
const entraConfig = config as EntraIdConfiguration;
await this.setEntraSecret(entraConfig.key);
entraConfig.key = StoredSecurely;
await this.setEntraConfiguration(entraConfig);
break;
}
case DirectoryType.Okta: {
const oktaConfig = config as OktaConfiguration;
await this.setOktaSecret(oktaConfig.token);
oktaConfig.token = StoredSecurely;
await this.setOktaConfiguration(oktaConfig);
break;
}
case DirectoryType.GSuite: {
const gsuiteConfig = config as GSuiteConfiguration;
if (gsuiteConfig.privateKey == null) {
await this.setGsuiteSecret(null);
} else {
const normalizedPrivateKey = gsuiteConfig.privateKey.replace(/\\n/g, "\n");
await this.setGsuiteSecret(normalizedPrivateKey);
gsuiteConfig.privateKey = StoredSecurely;
}
await this.setGsuiteConfiguration(gsuiteConfig);
break;
}
case DirectoryType.OneLogin: {
const oneLoginConfig = config as OneLoginConfiguration;
await this.setOneLoginSecret(oneLoginConfig.clientSecret);
oneLoginConfig.clientSecret = StoredSecurely;
await this.setOneLoginConfiguration(oneLoginConfig);
break;
}
}
}
}
async getConfiguration(type: DirectoryType): Promise<IConfiguration> {
switch (type) {
case DirectoryType.Ldap:
return await this.getLdapConfiguration();
case DirectoryType.GSuite:
return await this.getGsuiteConfiguration();
case DirectoryType.EntraID:
return await this.getEntraConfiguration();
case DirectoryType.Okta:
return await this.getOktaConfiguration();
case DirectoryType.OneLogin:
return await this.getOneLoginConfiguration();
}
}
// ===================================================================
// Secret Storage Methods (Secure Storage)
// ===================================================================
private async getLdapSecret(): Promise<string> {
return await this.secureStorageService.get<string>(SecureStorageKeys.ldap);
}
private async setLdapSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.ldap);
} else {
await this.secureStorageService.save(SecureStorageKeys.ldap, value);
}
}
private async getGsuiteSecret(): Promise<string> {
return await this.secureStorageService.get<string>(SecureStorageKeys.gsuite);
}
private async setGsuiteSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.gsuite);
} else {
await this.secureStorageService.save(SecureStorageKeys.gsuite, value);
}
}
private async getEntraSecret(): Promise<string> {
// Try new key first, fall back to old azure key for backwards compatibility
const entraKey = await this.secureStorageService.get<string>(SecureStorageKeys.entra);
if (entraKey != null) {
return entraKey;
}
return await this.secureStorageService.get<string>(SecureStorageKeys.azure);
}
private async setEntraSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.entra);
await this.secureStorageService.remove(SecureStorageKeys.azure);
} else {
await this.secureStorageService.save(SecureStorageKeys.entra, value);
}
}
private async getOktaSecret(): Promise<string> {
return await this.secureStorageService.get<string>(SecureStorageKeys.okta);
}
private async setOktaSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.okta);
} else {
await this.secureStorageService.save(SecureStorageKeys.okta, value);
}
}
private async getOneLoginSecret(): Promise<string> {
return await this.secureStorageService.get<string>(SecureStorageKeys.oneLogin);
}
private async setOneLoginSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.oneLogin);
} else {
await this.secureStorageService.save(SecureStorageKeys.oneLogin, value);
}
}
// ===================================================================
// Directory-Specific Configuration Methods
// ===================================================================
async getLdapConfiguration(options?: StorageOptions): Promise<LdapConfiguration> {
return await this.storageService.get<LdapConfiguration>(StorageKeys.directory_ldap);
}
async setLdapConfiguration(value: LdapConfiguration, options?: StorageOptions): Promise<void> {
await this.storageService.save(StorageKeys.directory_ldap, value);
}
async getGsuiteConfiguration(options?: StorageOptions): Promise<GSuiteConfiguration> {
return await this.storageService.get<GSuiteConfiguration>(StorageKeys.directory_gsuite);
}
async setGsuiteConfiguration(
value: GSuiteConfiguration,
options?: StorageOptions,
): Promise<void> {
await this.storageService.save(StorageKeys.directory_gsuite, value);
}
async getEntraConfiguration(options?: StorageOptions): Promise<EntraIdConfiguration> {
return await this.storageService.get<EntraIdConfiguration>(StorageKeys.directory_entra);
}
async setEntraConfiguration(
value: EntraIdConfiguration,
options?: StorageOptions,
): Promise<void> {
await this.storageService.save(StorageKeys.directory_entra, value);
}
async getOktaConfiguration(options?: StorageOptions): Promise<OktaConfiguration> {
return await this.storageService.get<OktaConfiguration>(StorageKeys.directory_okta);
}
async setOktaConfiguration(value: OktaConfiguration, options?: StorageOptions): Promise<void> {
await this.storageService.save(StorageKeys.directory_okta, value);
}
async getOneLoginConfiguration(options?: StorageOptions): Promise<OneLoginConfiguration> {
return await this.storageService.get<OneLoginConfiguration>(StorageKeys.directory_onelogin);
}
async setOneLoginConfiguration(
value: OneLoginConfiguration,
options?: StorageOptions,
): Promise<void> {
await this.storageService.save(StorageKeys.directory_onelogin, value);
}
// ===================================================================
// Directory Settings Methods
// ===================================================================
async getOrganizationId(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>(StorageKeys.organizationId);
}
async setOrganizationId(value: string, options?: StorageOptions): Promise<void> {
const currentId = await this.getOrganizationId();
if (currentId !== value) {
await this.clearSyncSettings();
}
await this.storageService.save(StorageKeys.organizationId, value);
}
async getSync(options?: StorageOptions): Promise<SyncConfiguration> {
return await this.storageService.get<SyncConfiguration>(StorageKeys.sync);
}
async setSync(value: SyncConfiguration, options?: StorageOptions): Promise<void> {
await this.storageService.save(StorageKeys.sync, value);
}
async getDirectoryType(options?: StorageOptions): Promise<DirectoryType> {
return await this.storageService.get<DirectoryType>(StorageKeys.directoryType);
}
async setDirectoryType(value: DirectoryType, options?: StorageOptions): Promise<void> {
const currentType = await this.getDirectoryType();
if (value !== currentType) {
await this.clearSyncSettings();
}
await this.storageService.save(StorageKeys.directoryType, value);
}
async getLastUserSync(options?: StorageOptions): Promise<Date> {
const dateString = await this.storageService.get<string>(SecureStorageKeys.lastUserSync);
return dateString ? new Date(dateString) : null;
}
async setLastUserSync(value: Date, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.lastUserSync, value);
}
async getLastGroupSync(options?: StorageOptions): Promise<Date> {
const dateString = await this.storageService.get<string>(SecureStorageKeys.lastGroupSync);
return dateString ? new Date(dateString) : null;
}
async setLastGroupSync(value: Date, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.lastGroupSync, value);
}
async getLastSyncHash(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>(SecureStorageKeys.lastSyncHash);
}
async setLastSyncHash(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.lastSyncHash, value);
}
async getSyncingDir(options?: StorageOptions): Promise<boolean> {
return await this.storageService.get<boolean>(StorageKeys.syncingDir);
}
async setSyncingDir(value: boolean, options?: StorageOptions): Promise<void> {
await this.storageService.save(StorageKeys.syncingDir, value);
}
async getUserDelta(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>(SecureStorageKeys.userDelta);
}
async setUserDelta(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.userDelta, value);
}
async getGroupDelta(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>(SecureStorageKeys.groupDelta);
}
async setGroupDelta(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.groupDelta, value);
}
async clearSyncSettings(hashToo = false): Promise<void> {
await this.setUserDelta(null);
await this.setGroupDelta(null);
await this.setLastGroupSync(null);
await this.setLastUserSync(null);
if (hashToo) {
await this.setLastSyncHash(null);
}
}
// ===================================================================
// Environment URLs (inherited from base, simplified implementation)
// ===================================================================
async getEnvironmentUrls(options?: StorageOptions): Promise<EnvironmentUrls> {
return await this.storageService.get<EnvironmentUrls>("environmentUrls");
}
async setEnvironmentUrls(value: EnvironmentUrls): Promise<void> {
await this.storageService.save("environmentUrls", value);
}
// ===================================================================
// Additional State Methods
// ===================================================================
async getLocale(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>("locale");
}
async setLocale(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save("locale", value);
}
async getInstalledVersion(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>("installedVersion");
}
async setInstalledVersion(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save("installedVersion", value);
}
}

View File

@@ -16,32 +16,13 @@ import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import {
SecureStorageKeysLegacy as SecureStorageKeys,
StoredSecurely,
TempKeys as keys,
} from "@/src/models/state.model";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
const SecureStorageKeys = {
ldap: "ldapPassword",
gsuite: "gsuitePrivateKey",
// Azure Active Directory was renamed to Entra ID, but we've kept the old property name
// to be backwards compatible with existing configurations.
azure: "azureKey",
entra: "entraKey",
okta: "oktaToken",
oneLogin: "oneLoginClientSecret",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
};
const keys = {
tempAccountSettings: "tempAccountSettings",
tempDirectoryConfigs: "tempDirectoryConfigs",
tempDirectorySettings: "tempDirectorySettings",
};
const StoredSecurely = "[STORED SECURELY]";
export class StateService
extends BaseStateService<GlobalState, Account>
implements StateServiceAbstraction
@@ -558,18 +539,16 @@ export class StateService
protected async scaffoldNewAccountDiskStorage(account: Account): Promise<void> {
const storageOptions = this.reconcileOptions(
{ userId: account.profile.userId },
{ userId: account.userId },
await this.defaultOnDiskLocalOptions(),
);
const storedAccount = await this.getAccount(storageOptions);
if (storedAccount != null) {
account.settings = storedAccount.settings;
account.directorySettings = storedAccount.directorySettings;
account.directoryConfigurations = storedAccount.directoryConfigurations;
} else if (await this.hasTemporaryStorage()) {
// If migrating to state V2 with an no actively authed account we store temporary data to be copied on auth - this will only be run once.
account.settings = await this.storageService.get<any>(keys.tempAccountSettings);
account.directorySettings = await this.storageService.get<any>(keys.tempDirectorySettings);
account.directoryConfigurations = await this.storageService.get<any>(
keys.tempDirectoryConfigs,
@@ -600,7 +579,7 @@ export class StateService
protected resetAccount(account: Account) {
const persistentAccountInformation = {
settings: account.settings,
settings: account.settings, // Required by base class (unused by DC)
directorySettings: account.directorySettings,
directoryConfigurations: account.directoryConfigurations,
};

View File

@@ -8,48 +8,14 @@ import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import {
MigrationClientKeys as ClientKeys,
MigrationKeys as Keys,
MigrationStateKeys as StateKeys,
SecureStorageKeysMigration as SecureStorageKeys,
} from "@/src/models/state.model";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
const SecureStorageKeys: { [key: string]: any } = {
ldap: "ldapPassword",
gsuite: "gsuitePrivateKey",
azure: "azureKey",
entra: "entraIdKey",
okta: "oktaToken",
oneLogin: "oneLoginClientSecret",
directoryConfigPrefix: "directoryConfig_",
sync: "syncConfig",
directoryType: "directoryType",
organizationId: "organizationId",
};
const Keys: { [key: string]: any } = {
entityId: "entityId",
directoryType: "directoryType",
organizationId: "organizationId",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
syncingDir: "syncingDir",
syncConfig: "syncConfig",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
tempDirectoryConfigs: "tempDirectoryConfigs",
tempDirectorySettings: "tempDirectorySettings",
};
const StateKeys = {
global: "global",
authenticatedAccounts: "authenticatedAccounts",
};
const ClientKeys: { [key: string]: any } = {
clientIdOld: "clientId",
clientId: "apikey_clientId",
clientSecretOld: "clientSecret",
clientSecret: "apikey_clientSecret",
};
export class StateMigrationService extends BaseStateMigrationService {
async migrate(): Promise<void> {
let currentStateVersion = await this.getCurrentStateVersion();
@@ -61,6 +27,13 @@ export class StateMigrationService extends BaseStateMigrationService {
break;
case StateVersion.Two:
await this.migrateStateFrom2To3();
break;
case StateVersion.Three:
await this.migrateStateFrom3To4();
break;
case StateVersion.Four:
await this.migrateStateFrom4To5();
break;
}
currentStateVersion += 1;
}
@@ -143,15 +116,10 @@ export class StateMigrationService extends BaseStateMigrationService {
const account = await this.get<Account>(userId);
account.directoryConfigurations = directoryConfigs;
account.directorySettings = directorySettings;
account.profile = {
userId: userId,
entityId: userId,
apiKeyClientId: clientId,
};
account.clientKeys = {
clientId: clientId,
clientSecret: clientSecret,
};
account.userId = userId;
account.entityId = userId;
account.apiKeyClientId = clientId;
account.apiKeyClientSecret = clientSecret;
await this.set(userId, account);
await clearDirectoryConnectorV1Keys();
@@ -198,4 +166,131 @@ export class StateMigrationService extends BaseStateMigrationService {
globals.stateVersion = StateVersion.Three;
await this.set(StateKeys.global, globals);
}
protected async migrateStateFrom3To4(): Promise<void> {
// Placeholder migration for v3→v4 (no changes needed for DC)
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Four;
await this.set(StateKeys.global, globals);
}
/**
* Migrate from State v4 (Account-based hierarchy) to v5 (flat key-value structure)
*
* This is a clean break from the Account-based structure. Data is extracted from
* the account and saved into flat keys for simpler access.
*
* Old structure: authenticatedAccounts -> userId -> account.directorySettings/directoryConfigurations
* New structure: flat keys like "directoryType", "organizationId", "directory_ldap", etc.
*
* Secrets migrate from: {userId}_{secretKey} -> secret_{secretKey}
*/
protected async migrateStateFrom4To5(useSecureStorageForSecrets = true): Promise<void> {
// Get the authenticated user IDs from v3 structure
const authenticatedUserIds = await this.get<string[]>(StateKeys.authenticatedAccounts);
if (
!authenticatedUserIds ||
!Array.isArray(authenticatedUserIds) ||
authenticatedUserIds.length === 0
) {
// No accounts to migrate, just update version
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Five;
await this.set(StateKeys.global, globals);
return;
}
// DC is single-user, so we take the first (and likely only) account
const userId = authenticatedUserIds[0];
const account = await this.get<Account>(userId);
if (!account) {
// No account data found, just update version
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Five;
await this.set(StateKeys.global, globals);
return;
}
// Migrate directory configurations to flat structure
if (account.directoryConfigurations) {
if (account.directoryConfigurations.ldap) {
await this.set("directory_ldap", account.directoryConfigurations.ldap);
}
if (account.directoryConfigurations.gsuite) {
await this.set("directory_gsuite", account.directoryConfigurations.gsuite);
}
if (account.directoryConfigurations.entra) {
await this.set("directory_entra", account.directoryConfigurations.entra);
} else if (account.directoryConfigurations.azure) {
// Backwards compatibility: migrate azure to entra
await this.set("directory_entra", account.directoryConfigurations.azure);
}
if (account.directoryConfigurations.okta) {
await this.set("directory_okta", account.directoryConfigurations.okta);
}
if (account.directoryConfigurations.oneLogin) {
await this.set("directory_onelogin", account.directoryConfigurations.oneLogin);
}
}
// Migrate directory settings to flat structure
if (account.directorySettings) {
if (account.directorySettings.organizationId) {
await this.set("organizationId", account.directorySettings.organizationId);
}
if (account.directorySettings.directoryType != null) {
await this.set("directoryType", account.directorySettings.directoryType);
}
if (account.directorySettings.sync) {
await this.set("sync", account.directorySettings.sync);
}
if (account.directorySettings.lastUserSync) {
await this.set("lastUserSync", account.directorySettings.lastUserSync);
}
if (account.directorySettings.lastGroupSync) {
await this.set("lastGroupSync", account.directorySettings.lastGroupSync);
}
if (account.directorySettings.lastSyncHash) {
await this.set("lastSyncHash", account.directorySettings.lastSyncHash);
}
if (account.directorySettings.userDelta) {
await this.set("userDelta", account.directorySettings.userDelta);
}
if (account.directorySettings.groupDelta) {
await this.set("groupDelta", account.directorySettings.groupDelta);
}
if (account.directorySettings.syncingDir != null) {
await this.set("syncingDir", account.directorySettings.syncingDir);
}
}
// Migrate secrets from {userId}_* to secret_* pattern
if (useSecureStorageForSecrets) {
const oldSecretKeys = [
{ old: `${userId}_${SecureStorageKeys.ldap}`, new: "secret_ldap" },
{ old: `${userId}_${SecureStorageKeys.gsuite}`, new: "secret_gsuite" },
{ old: `${userId}_${SecureStorageKeys.azure}`, new: "secret_azure" },
{ old: `${userId}_${SecureStorageKeys.entra}`, new: "secret_entra" },
{ old: `${userId}_${SecureStorageKeys.okta}`, new: "secret_okta" },
{ old: `${userId}_${SecureStorageKeys.oneLogin}`, new: "secret_onelogin" },
];
for (const { old: oldKey, new: newKey } of oldSecretKeys) {
if (await this.secureStorageService.has(oldKey)) {
const value = await this.secureStorageService.get(oldKey);
if (value) {
await this.secureStorageService.save(newKey, value);
}
// @TODO Keep old key for now - will remove in future release
// await this.secureStorageService.remove(oldKey);
}
}
}
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Five;
await this.set(StateKeys.global, globals);
}
}

View File

@@ -0,0 +1,196 @@
import { mock } from "jest-mock-extended";
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
import { StateVersion } from "@/jslib/common/src/enums/stateVersion";
import { StateFactory } from "@/jslib/common/src/factories/stateFactory";
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
import { StateMigrationService } from "./state-service/stateMigration.service";
describe("StateMigrationService - v4 to v5 migration", () => {
let storageService: jest.Mocked<StorageService>;
let secureStorageService: jest.Mocked<StorageService>;
let stateFactory: jest.Mocked<StateFactory<any, Account>>;
let migrationService: StateMigrationService;
beforeEach(() => {
storageService = mock<StorageService>();
secureStorageService = mock<StorageService>();
stateFactory = mock<StateFactory<any, Account>>();
migrationService = new StateMigrationService(
storageService,
secureStorageService,
stateFactory,
);
});
it("should flatten nested account structure", async () => {
const userId = "test-user-id";
const oldAccount = {
profile: {
userId: userId,
entityId: userId,
apiKeyClientId: "organization.CLIENT_ID",
},
tokens: {
accessToken: "test-access-token",
refreshToken: "test-refresh-token",
},
keys: {
apiKeyClientSecret: "test-secret",
},
directoryConfigurations: new DirectoryConfigurations(),
directorySettings: new DirectorySettings(),
};
storageService.get.mockImplementation((key: string) => {
if (key === "authenticatedAccounts") {
return Promise.resolve([userId]);
}
if (key === userId) {
return Promise.resolve(oldAccount);
}
if (key === "global") {
return Promise.resolve({ stateVersion: StateVersion.Four });
}
return Promise.resolve(null);
});
await migrationService["migrateStateFrom4To5"]();
expect(storageService.save).toHaveBeenCalledWith(
"global",
expect.objectContaining({ stateVersion: StateVersion.Five }),
expect.anything(),
);
});
it("should handle missing nested objects gracefully", async () => {
const userId = "test-user-id";
const partialAccount = {
directoryConfigurations: new DirectoryConfigurations(),
directorySettings: new DirectorySettings(),
};
storageService.get.mockImplementation((key: string) => {
if (key === "authenticatedAccounts") {
return Promise.resolve([userId]);
}
if (key === userId) {
return Promise.resolve(partialAccount);
}
if (key === "global") {
return Promise.resolve({ stateVersion: StateVersion.Four });
}
return Promise.resolve(null);
});
await migrationService["migrateStateFrom4To5"]();
expect(storageService.save).toHaveBeenCalledWith(
"global",
expect.objectContaining({ stateVersion: StateVersion.Five }),
expect.anything(),
);
});
it("should handle empty account list", async () => {
storageService.get.mockImplementation((key: string) => {
if (key === "authenticatedAccounts") {
return Promise.resolve([]);
}
if (key === "global") {
return Promise.resolve({ stateVersion: StateVersion.Four });
}
return Promise.resolve(null);
});
await migrationService["migrateStateFrom4To5"]();
expect(storageService.save).toHaveBeenCalledWith(
"global",
expect.objectContaining({ stateVersion: StateVersion.Five }),
expect.anything(),
);
expect(storageService.save).toHaveBeenCalledTimes(1);
});
it("should preserve directory configurations and settings", async () => {
const userId = "test-user-id";
const directoryConfigs = new DirectoryConfigurations();
directoryConfigs.ldap = { host: "ldap.example.com" } as any;
const directorySettings = new DirectorySettings();
directorySettings.organizationId = "org-123";
directorySettings.lastSyncHash = "hash-abc";
const oldAccount = {
profile: { userId: userId },
tokens: {},
keys: {},
directoryConfigurations: directoryConfigs,
directorySettings: directorySettings,
};
storageService.get.mockImplementation((key: string) => {
if (key === "authenticatedAccounts") {
return Promise.resolve([userId]);
}
if (key === userId) {
return Promise.resolve(oldAccount);
}
if (key === "global") {
return Promise.resolve({ stateVersion: StateVersion.Four });
}
return Promise.resolve(null);
});
await migrationService["migrateStateFrom4To5"]();
expect(storageService.save).toHaveBeenCalledWith(
"directory_ldap",
{ host: "ldap.example.com" },
expect.anything(),
);
expect(storageService.save).toHaveBeenCalledWith(
"organizationId",
"org-123",
expect.anything(),
);
expect(storageService.save).toHaveBeenCalledWith("lastSyncHash", "hash-abc", expect.anything());
});
it("should update state version after successful migration", async () => {
const userId = "test-user-id";
const oldAccount = {
profile: { userId: userId },
tokens: {},
keys: {},
directoryConfigurations: new DirectoryConfigurations(),
directorySettings: new DirectorySettings(),
};
storageService.get.mockImplementation((key: string) => {
if (key === "authenticatedAccounts") {
return Promise.resolve([userId]);
}
if (key === userId) {
return Promise.resolve(oldAccount);
}
if (key === "global") {
return Promise.resolve({ stateVersion: StateVersion.Four });
}
return Promise.resolve(null);
});
await migrationService["migrateStateFrom4To5"]();
expect(storageService.save).toHaveBeenCalledWith(
"global",
expect.objectContaining({ stateVersion: StateVersion.Five }),
expect.anything(),
);
});
});

View File

@@ -9,12 +9,12 @@ import { I18nService } from "../../jslib/common/src/abstractions/i18n.service";
import { LogService } from "../../jslib/common/src/abstractions/log.service";
import { getLdapConfiguration, getSyncConfiguration } from "../../utils/openldap/config-fixtures";
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
import { DirectoryType } from "../enums/directoryType";
import { BatchRequestBuilder } from "./batch-request-builder";
import { LdapDirectoryService } from "./directory-services/ldap-directory.service";
import { SingleRequestBuilder } from "./single-request-builder";
import { StateService } from "./state.service";
import { SyncService } from "./sync.service";
import * as constants from "./sync.service";
@@ -24,7 +24,7 @@ import { userFixtures } from "@/utils/openldap/user-fixtures";
describe("SyncService", () => {
let logService: MockProxy<LogService>;
let i18nService: MockProxy<I18nService>;
let stateService: MockProxy<StateService>;
let stateService: MockProxy<StateServiceVNext>;
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
let apiService: MockProxy<ApiService>;
let messagingService: MockProxy<MessagingService>;

View File

@@ -10,13 +10,13 @@ import { GroupEntry } from "@/src/models/groupEntry";
import { getSyncConfiguration } from "../../utils/openldap/config-fixtures";
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
import { DirectoryType } from "../enums/directoryType";
import { BatchRequestBuilder } from "./batch-request-builder";
import { LdapDirectoryService } from "./directory-services/ldap-directory.service";
import { I18nService } from "./i18n.service";
import { SingleRequestBuilder } from "./single-request-builder";
import { StateService } from "./state.service";
import { SyncService } from "./sync.service";
import * as constants from "./sync.service";
@@ -29,7 +29,7 @@ describe("SyncService", () => {
let messagingService: MockProxy<MessagingService>;
let i18nService: MockProxy<I18nService>;
let environmentService: MockProxy<EnvironmentService>;
let stateService: MockProxy<StateService>;
let stateService: MockProxy<StateServiceVNext>;
let directoryFactory: MockProxy<DirectoryFactoryService>;
let batchRequestBuilder: MockProxy<BatchRequestBuilder>;
let singleRequestBuilder: MockProxy<SingleRequestBuilder>;

View File

@@ -7,7 +7,7 @@ import { Utils } from "@/jslib/common/src/misc/utils";
import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest";
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
import { StateService } from "../abstractions/state.service";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
import { DirectoryType } from "../enums/directoryType";
import { GroupEntry } from "../models/groupEntry";
import { SyncConfiguration } from "../models/syncConfiguration";
@@ -32,7 +32,7 @@ export class SyncService {
private messagingService: MessagingService,
private i18nService: I18nService,
private environmentService: EnvironmentService,
private stateService: StateService,
private stateService: StateServiceVNext,
private batchRequestBuilder: BatchRequestBuilder,
private singleRequestBuilder: SingleRequestBuilder,
private directoryFactory: DirectoryFactoryService,

View File

@@ -1,9 +1,14 @@
const path = require("path");
import path from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const CopyWebpackPlugin = require("copy-webpack-plugin");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const webpack = require("webpack");
const nodeExternals = require("webpack-node-externals");
import CopyWebpackPlugin from "copy-webpack-plugin";
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
import webpack from "webpack";
import nodeExternals from "webpack-node-externals";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
if (process.env.NODE_ENV == null) {
process.env.NODE_ENV = "development";
@@ -69,4 +74,4 @@ const config = {
externals: [nodeExternals()],
};
module.exports = config;
export default config;

View File

@@ -1,8 +1,14 @@
const path = require("path");
const { merge } = require("webpack-merge");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const nodeExternals = require("webpack-node-externals");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
import path from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { merge } from "webpack-merge";
import CopyWebpackPlugin from "copy-webpack-plugin";
import nodeExternals from "webpack-node-externals";
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const common = {
module: {
@@ -62,4 +68,4 @@ const main = {
},
};
module.exports = merge(common, main);
export default merge(common, main);

View File

@@ -1,10 +1,17 @@
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { AngularWebpackPlugin } = require("@ngtools/webpack");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
import path from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import webpack from "webpack";
import { merge } from "webpack-merge";
import HtmlWebpackPlugin from "html-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import { AngularWebpackPlugin } from "@ngtools/webpack";
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
import sass from "sass";
// ESM __dirname polyfill for Node 20
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const common = {
module: {
@@ -99,7 +106,7 @@ const renderer = {
{
loader: "sass-loader",
options: {
implementation: require("sass"),
implementation: sass,
},
},
],
@@ -138,4 +145,4 @@ const renderer = {
],
};
module.exports = merge(common, renderer);
export default merge(common, renderer);