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

Compare commits

..

2 Commits

Author SHA1 Message Date
Brandon
5761a391f7 wip 2026-01-09 17:01:26 -05:00
Brandon
8cd2850e8d add docs and tests 2026-01-09 12:05:14 -05:00
73 changed files with 9202 additions and 8126 deletions

View File

@@ -1,7 +1,3 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Bitwarden Directory Connector
## Project Overview
@@ -24,392 +20,6 @@ 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
@@ -435,32 +45,6 @@ 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

View File

@@ -1,239 +0,0 @@
# 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

@@ -1,130 +0,0 @@
---
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

@@ -9,3 +9,26 @@
## 📸 Screenshots
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
## ⏰ Reminders before review
- Contributor guidelines followed
- All formatters and local linters executed and passed
- Written new unit and / or integration tests where applicable
- Used internationalization (i18n) for all UI strings
- CI builds passed
- Communicated to DevOps any deployment requirements
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
## 🦮 Reviewer guidelines
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
- 👍 (`:+1:`) or similar for great changes
- 📝 (`:memo:`) or (`:information_source:`) for notes or general info
- ❓ (`:question:`) for questions
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
- 🎨 (`:art:`) for suggestions / improvements
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
- ⛏ (`:pick:`) for minor or nitpick changes

View File

@@ -23,7 +23,7 @@ jobs:
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
steps:
- name: Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -51,12 +51,12 @@ jobs:
contents: read
steps:
- name: Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -129,12 +129,12 @@ jobs:
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
steps:
- name: Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -200,7 +200,7 @@ jobs:
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
steps:
- name: Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -209,7 +209,7 @@ jobs:
choco install checksum --no-progress
- name: Set up Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -279,12 +279,12 @@ jobs:
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -379,12 +379,12 @@ jobs:
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -439,12 +439,12 @@ jobs:
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -40,7 +40,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -52,7 +52,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -129,7 +129,7 @@ jobs:
- name: Report test results
id: report
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
# PRs from the repository and all other events are OK.
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
@@ -143,6 +143,4 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- name: Upload results to codecov.io
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
report_type: test_results
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1

View File

@@ -1,46 +0,0 @@
name: Lint
on:
workflow_dispatch:
push:
branches:
- "main"
- "rc"
- "hotfix-rc"
pull_request:
permissions:
contents: read
jobs:
lint:
name: Run linter
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }}
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Get Node version
id: retrieve-node-version
run: |
NODE_NVMRC=$(cat .nvmrc)
NODE_VERSION=${NODE_NVMRC/v/''}
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
- name: Install Node dependencies
run: npm ci
- name: Run ESLint and Prettier
run: npm run lint

View File

@@ -26,7 +26,7 @@ jobs:
release_version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -34,7 +34,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -53,7 +53,7 @@ jobs:
run: npm run test --coverage
- name: Report test results
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
# PRs from the repository and all other events are OK.
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
@@ -67,6 +67,4 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- name: Upload results to codecov.io
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
report_type: test_results
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1

View File

@@ -50,7 +50,7 @@ jobs:
permission-contents: write
- name: Checkout Branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true

View File

@@ -18,17 +18,15 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": {
"base": "dist"
},
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "tsconfig.json",
"assets": [],
"styles": [],
"scripts": [],
"browser": "src/main.ts"
"scripts": []
}
}
}

300
docs/google-workspace.md Normal file
View File

@@ -0,0 +1,300 @@
# Google Workspace Directory Integration
This document provides technical documentation for the Google Workspace (formerly G Suite) directory integration in Bitwarden Directory Connector.
## Overview
The Google Workspace integration synchronizes users and groups from Google Workspace to Bitwarden organizations using the Google Admin SDK Directory API. The service uses a service account with domain-wide delegation to authenticate and access directory data.
## Architecture
### Service Location
- **Implementation**: `src/services/directory-services/gsuite-directory.service.ts`
- **Configuration Model**: `src/models/gsuiteConfiguration.ts`
- **Integration Tests**: `src/services/directory-services/gsuite-directory.service.integration.spec.ts`
### Authentication Flow
The Google Workspace integration uses **OAuth 2.0 with Service Accounts** and domain-wide delegation:
1. A service account is created in Google Cloud Console
2. The service account is granted domain-wide delegation authority
3. The service account is authorized for specific OAuth scopes in Google Workspace Admin Console
4. The Directory Connector uses the service account's private key to generate JWT tokens
5. JWT tokens are exchanged for access tokens to call the Admin SDK APIs
### Required OAuth Scopes
The service account must be granted the following OAuth 2.0 scopes:
```
https://www.googleapis.com/auth/admin.directory.user.readonly
https://www.googleapis.com/auth/admin.directory.group.readonly
https://www.googleapis.com/auth/admin.directory.group.member.readonly
```
## Configuration
### Required Fields
| Field | Description |
| ------------- | --------------------------------------------------------------------------------------- |
| `clientEmail` | Service account email address (e.g., `service-account@project.iam.gserviceaccount.com`) |
| `privateKey` | Service account private key in PEM format |
| `adminUser` | Admin user email to impersonate for domain-wide delegation |
| `domain` | Primary domain of the Google Workspace organization |
### Optional Fields
| Field | Description |
| ---------- | ---------------------------------------------------------- |
| `customer` | Customer ID for multi-domain organizations (rarely needed) |
### Example Configuration
```typescript
{
clientEmail: "directory-connector@my-project.iam.gserviceaccount.com",
privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
adminUser: "admin@example.com",
domain: "example.com",
customer: "" // Usually not required
}
```
## Setup Instructions
### 1. Create a Service Account
1. Go to [Google Cloud Console](https://console.cloud.google.com)
2. Create or select a project
3. Navigate to **IAM & Admin** > **Service Accounts**
4. Click **Create Service Account**
5. Enter a name and description
6. Click **Create and Continue**
7. Skip granting roles (not needed for this use case)
8. Click **Done**
### 2. Generate Service Account Key
1. Click on the newly created service account
2. Navigate to the **Keys** tab
3. Click **Add Key** > **Create new key**
4. Select **JSON** format
5. Click **Create** and download the key file
6. Extract `client_email` and `private_key` from the JSON file
### 3. Enable Domain-Wide Delegation
1. In the service account details, click **Show Advanced Settings**
2. Under **Domain-wide delegation**, click **Enable Google Workspace Domain-wide Delegation**
3. Note the **Client ID** (numeric ID)
### 4. Authorize the Service Account in Google Workspace
1. Go to [Google Workspace Admin Console](https://admin.google.com)
2. Navigate to **Security** > **API Controls** > **Domain-wide Delegation**
3. Click **Add new**
4. Enter the **Client ID** from step 3
5. Enter the following OAuth scopes (comma-separated):
```
https://www.googleapis.com/auth/admin.directory.user.readonly,
https://www.googleapis.com/auth/admin.directory.group.readonly,
https://www.googleapis.com/auth/admin.directory.group.member.readonly
```
6. Click **Authorize**
### 5. Configure Directory Connector
Use the extracted values to configure the Directory Connector:
- **Client Email**: From `client_email` in the JSON key file
- **Private Key**: From `private_key` in the JSON key file (keep the `\n` line breaks)
- **Admin User**: Email of a super admin user in your Google Workspace domain
- **Domain**: Your primary Google Workspace domain
## Sync Behavior
### User Synchronization
The service synchronizes the following user attributes:
| Google Workspace Field | Bitwarden Field | Notes |
| ------------------------- | --------------------------- | ----------------------------------------- |
| `id` | `referenceId`, `externalId` | User's unique Google ID |
| `primaryEmail` | `email` | Normalized to lowercase |
| `suspended` OR `archived` | `disabled` | User is disabled if suspended or archived |
| Deleted status | `deleted` | Set to true for deleted users |
**Special Behavior:**
- The service queries both **active users** and **deleted users** separately
- Suspended and archived users are included but marked as disabled
- Deleted users are included with the `deleted` flag set to true
### Group Synchronization
The service synchronizes the following group attributes:
| Google Workspace Field | Bitwarden Field | Notes |
| ----------------------- | --------------------------- | ------------------------ |
| `id` | `referenceId`, `externalId` | Group's unique Google ID |
| `name` | `name` | Group display name |
| Members (type=USER) | `userMemberExternalIds` | Individual user members |
| Members (type=GROUP) | `groupMemberReferenceIds` | Nested group members |
| Members (type=CUSTOMER) | `userMemberExternalIds` | All domain users |
**Member Types:**
- **USER**: Individual user accounts (only ACTIVE status users are synced)
- **GROUP**: Nested groups (allows group hierarchy)
- **CUSTOMER**: Special member type that includes all users in the domain
### Filtering
#### User Filter Examples
```
exclude:testuser1@bwrox.dev | testuser1@bwrox.dev # Exclude multiple users
|orgUnitPath='/Integration testing' # Users in Integration testing Organizational unit (OU)
exclude:testuser1@bwrox.dev | orgUnitPath='/Integration testing' # Combined filter: get users in OU excluding provided user
|email:testuser* # Users with email starting with "testuser"
```
#### Group Filter Examples
An important note for group filters is that it implicitly only syncs users that are in groups. For example, in the case of
the integration test data, `admin@bwrox.dev` is not a member of any group. Therefore, the first example filter below will
also implicitly exclude `admin@bwrox.dev`, who is not in any group. This is important because when it is paired with an
empty user filter, this query may semantically be understood as "sync everyone not in Integration Test Group A," while in
practice it means "Only sync members of groups not in integration Test Groups A."
```
exclude:Integration Test Group A # Get all users in groups excluding the provided group.
```
### User AND Group Filter Examples
```
```
**Filter Syntax:**
- Prefix with `|` for custom filters
- Use `:` for pattern matching (supports `*` wildcard)
- Combine multiple conditions with spaces (AND logic)
### Pagination
The service automatically handles pagination for all API calls:
- Users API (active and deleted)
- Groups API
- Group Members API
Each API call processes all pages using the `nextPageToken` mechanism until no more results are available.
## Error Handling
### Common Errors
| Error | Cause | Resolution |
| ---------------------- | ------------------------------------- | ---------------------------------------------------------- |
| "dirConfigIncomplete" | Missing required configuration fields | Verify all required fields are provided |
| "authenticationFailed" | Invalid credentials or unauthorized | Check service account key and domain-wide delegation setup |
| API returns 401/403 | Missing OAuth scopes | Verify scopes are authorized in Admin Console |
| API returns 404 | Invalid domain or customer ID | Check domain configuration |
### Security Considerations
The service implements the following security measures:
1. **Credential sanitization**: Error messages do not expose private keys or sensitive credentials
2. **Secure authentication**: Uses OAuth 2.0 with JWT tokens, not API keys
3. **Read-only access**: Only requires read-only scopes for directory data
4. **No credential logging**: Service account credentials are not logged
## Testing
### Integration Tests
Integration tests are located in `src/services/directory-services/gsuite-directory.service.integration.spec.ts`.
**Test Coverage:**
- Basic sync (users and groups)
- Sync with filters
- Users-only sync
- Groups-only sync
- User filtering scenarios
- Group filtering scenarios
- Disabled users handling
- Group membership scenarios
- Error handling
**Running Integration Tests:**
Integration tests require live Google Workspace credentials:
1. Create a `.env` file in the `utils/` folder with:
```
GOOGLE_ADMIN_USER=admin@example.com
GOOGLE_CLIENT_EMAIL=service-account@project.iam.gserviceaccount.com
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
GOOGLE_DOMAIN=example.com
```
2. Run tests:
```bash
# Run all integration tests (includes LDAP, Google Workspace, etc.)
npm run test:integration
# Run only Google Workspace integration tests
npx jest gsuite-directory.service.integration.spec.ts
```
**Test Data:**
The integration tests expect specific test data in Google Workspace:
- **Users**: 5 test users in organizational unit `/Integration testing`
- testuser1@bwrox.dev (in Group A)
- testuser2@bwrox.dev (in Groups A & B)
- testuser3@bwrox.dev (in Group B)
- testuser4@bwrox.dev (no groups)
- testuser5@bwrox.dev (disabled)
- **Groups**: 2 test groups with name pattern `Integration*`
- Integration Test Group A
- Integration Test Group B
## API Reference
### Google Admin SDK APIs Used
- **Users API**: `admin.users.list()`
- [Documentation](https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/list)
- **Groups API**: `admin.groups.list()`
- [Documentation](https://developers.google.com/admin-sdk/directory/reference/rest/v1/groups/list)
- **Members API**: `admin.members.list()`
- [Documentation](https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/list)
### Rate Limits
Google Workspace Directory API rate limits:
- Default: 2,400 queries per minute per user, per Google Cloud Project
The service does not implement rate limiting logic; it relies on API error responses.
## Resources
- [Google Admin SDK Directory API Guide](https://developers.google.com/admin-sdk/directory/v1/guides)
- [Service Account Authentication](https://developers.google.com/identity/protocols/oauth2/service-account)
- [Domain-wide Delegation](https://support.google.com/a/answer/162106)
- [Google Workspace Admin Console](https://admin.google.com)
- [Bitwarden Directory Connector Documentation](https://bitwarden.com/help/directory-sync/)

View File

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

View File

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

View File

@@ -1,77 +1,75 @@
import { animate, state, style, transition, trigger } from "@angular/animations";
import { CommonModule } from "@angular/common";
import { Component, ModuleWithProviders, NgModule } from "@angular/core";
import { DefaultNoComponentGlobalConfig, GlobalConfig, Toast, TOAST_CONFIG } from "ngx-toastr";
import {
DefaultNoComponentGlobalConfig,
GlobalConfig,
Toast as BaseToast,
ToastPackage,
ToastrService,
TOAST_CONFIG,
} from "ngx-toastr";
@Component({
selector: "[toast-component2]",
template: `
@if (options().closeButton) {
<button (click)="remove()" type="button" class="toast-close-button" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
}
<button
*ngIf="options.closeButton"
(click)="remove()"
type="button"
class="toast-close-button"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<div class="icon">
<i></i>
</div>
<div>
@if (title()) {
<div [class]="options().titleClass" [attr.aria-label]="title()">
{{ title() }}
@if (duplicatesCount) {
[{{ duplicatesCount + 1 }}]
}
</div>
}
@if (message() && options().enableHtml) {
<div
role="alertdialog"
aria-live="polite"
[class]="options().messageClass"
[innerHTML]="message()"
></div>
}
@if (message() && !options().enableHtml) {
<div
role="alertdialog"
aria-live="polite"
[class]="options().messageClass"
[attr.aria-label]="message()"
>
{{ message() }}
</div>
}
</div>
@if (options().progressBar) {
<div>
<div class="toast-progress" [style.width]="width + '%'"></div>
<div *ngIf="title" [class]="options.titleClass" [attr.aria-label]="title">
{{ title }} <ng-container *ngIf="duplicatesCount">[{{ duplicatesCount + 1 }}]</ng-container>
</div>
}
`,
styles: `
:host {
&.toast-in {
animation: toast-animation var(--animation-duration) var(--animation-easing);
}
&.toast-out {
animation: toast-animation var(--animation-duration) var(--animation-easing) reverse
forwards;
}
}
@keyframes toast-animation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
<div
*ngIf="message && options.enableHtml"
role="alertdialog"
aria-live="polite"
[class]="options.messageClass"
[innerHTML]="message"
></div>
<div
*ngIf="message && !options.enableHtml"
role="alertdialog"
aria-live="polite"
[class]="options.messageClass"
[attr.aria-label]="message"
>
{{ message }}
</div>
</div>
<div *ngIf="options.progressBar">
<div class="toast-progress" [style.width]="width + '%'"></div>
</div>
`,
animations: [
trigger("flyInOut", [
state("inactive", style({ opacity: 0 })),
state("active", style({ opacity: 1 })),
state("removed", style({ opacity: 0 })),
transition("inactive => active", animate("{{ easeTime }}ms {{ easing }}")),
transition("active => removed", animate("{{ easeTime }}ms {{ easing }}")),
]),
],
preserveWhitespaces: false,
standalone: false,
})
export class BitwardenToast extends Toast {}
export class BitwardenToast extends BaseToast {
constructor(
protected toastrService: ToastrService,
public toastPackage: ToastPackage,
) {
super(toastrService, toastPackage);
}
}
export const BitwardenToastGlobalConfig: GlobalConfig = {
...DefaultNoComponentGlobalConfig,

View File

@@ -0,0 +1,195 @@
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { CryptoService } from "@/jslib/common/src/abstractions/crypto.service";
import { EncryptionType } from "@/jslib/common/src/enums/encryptionType";
import { EncString } from "@/jslib/common/src/models/domain/encString";
import { SymmetricCryptoKey } from "@/jslib/common/src/models/domain/symmetricCryptoKey";
import { ContainerService } from "@/jslib/common/src/services/container.service";
describe("EncString", () => {
afterEach(() => {
(window as any).bitwardenContainerService = undefined;
});
describe("Rsa2048_OaepSha256_B64", () => {
it("constructor", () => {
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
expect(encString).toEqual({
data: "data",
encryptedString: "3.data",
encryptionType: 3,
});
});
describe("parse existing", () => {
it("valid", () => {
const encString = new EncString("3.data");
expect(encString).toEqual({
data: "data",
encryptedString: "3.data",
encryptionType: 3,
});
});
it("invalid", () => {
const encString = new EncString("3.data|test");
expect(encString).toEqual({
encryptedString: "3.data|test",
encryptionType: 3,
});
});
});
describe("decrypt", () => {
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
const cryptoService = Substitute.for<CryptoService>();
cryptoService.getOrgKey(null).resolves(null);
cryptoService.decryptToUtf8(encString, Arg.any()).resolves("decrypted");
beforeEach(() => {
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
});
it("decrypts correctly", async () => {
const decrypted = await encString.decrypt(null);
expect(decrypted).toBe("decrypted");
});
it("result should be cached", async () => {
const decrypted = await encString.decrypt(null);
cryptoService.received(1).decryptToUtf8(Arg.any(), Arg.any());
expect(decrypted).toBe("decrypted");
});
});
});
describe("AesCbc256_B64", () => {
it("constructor", () => {
const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv");
expect(encString).toEqual({
data: "data",
encryptedString: "0.iv|data",
encryptionType: 0,
iv: "iv",
});
});
describe("parse existing", () => {
it("valid", () => {
const encString = new EncString("0.iv|data");
expect(encString).toEqual({
data: "data",
encryptedString: "0.iv|data",
encryptionType: 0,
iv: "iv",
});
});
it("invalid", () => {
const encString = new EncString("0.iv|data|mac");
expect(encString).toEqual({
encryptedString: "0.iv|data|mac",
encryptionType: 0,
});
});
});
});
describe("AesCbc256_HmacSha256_B64", () => {
it("constructor", () => {
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
expect(encString).toEqual({
data: "data",
encryptedString: "2.iv|data|mac",
encryptionType: 2,
iv: "iv",
mac: "mac",
});
});
it("valid", () => {
const encString = new EncString("2.iv|data|mac");
expect(encString).toEqual({
data: "data",
encryptedString: "2.iv|data|mac",
encryptionType: 2,
iv: "iv",
mac: "mac",
});
});
it("invalid", () => {
const encString = new EncString("2.iv|data");
expect(encString).toEqual({
encryptedString: "2.iv|data",
encryptionType: 2,
});
});
});
it("Exit early if null", () => {
const encString = new EncString(null);
expect(encString).toEqual({
encryptedString: null,
});
});
describe("decrypt", () => {
it("throws exception when bitwarden container not initialized", async () => {
const encString = new EncString(null);
expect.assertions(1);
try {
await encString.decrypt(null);
} catch (e) {
expect(e.message).toEqual("global bitwardenContainerService not initialized.");
}
});
it("handles value it can't decrypt", async () => {
const encString = new EncString(null);
const cryptoService = Substitute.for<CryptoService>();
cryptoService.getOrgKey(null).resolves(null);
cryptoService.decryptToUtf8(encString, Arg.any()).throws("error");
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
const decrypted = await encString.decrypt(null);
expect(decrypted).toBe("[error: cannot decrypt]");
expect(encString).toEqual({
decryptedValue: "[error: cannot decrypt]",
encryptedString: null,
});
});
it("passes along key", async () => {
const encString = new EncString(null);
const key = Substitute.for<SymmetricCryptoKey>();
const cryptoService = Substitute.for<CryptoService>();
cryptoService.getOrgKey(null).resolves(null);
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
await encString.decrypt(null, key);
cryptoService.received().decryptToUtf8(encString, key);
});
});
});

View File

@@ -9,7 +9,7 @@ describe("SymmetricCryptoKey", () => {
new SymmetricCryptoKey(null);
};
expect(t).toThrow("Must provide key");
expect(t).toThrowError("Must provide key");
});
describe("guesses encKey from key length", () => {
@@ -63,7 +63,7 @@ describe("SymmetricCryptoKey", () => {
new SymmetricCryptoKey(makeStaticByteArray(30));
};
expect(t).toThrow("Unable to determine encType.");
expect(t).toThrowError("Unable to determine encType.");
});
});
});

View File

@@ -0,0 +1,84 @@
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
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 } from "@/jslib/common/src/models/domain/account";
import { GlobalState } from "@/jslib/common/src/models/domain/globalState";
import { StateMigrationService } from "@/jslib/common/src/services/stateMigration.service";
const userId = "USER_ID";
describe("State Migration Service", () => {
let storageService: SubstituteOf<StorageService>;
let secureStorageService: SubstituteOf<StorageService>;
let stateFactory: SubstituteOf<StateFactory>;
let stateMigrationService: StateMigrationService;
beforeEach(() => {
storageService = Substitute.for<StorageService>();
secureStorageService = Substitute.for<StorageService>();
stateFactory = Substitute.for<StateFactory>();
stateMigrationService = new StateMigrationService(
storageService,
secureStorageService,
stateFactory,
);
});
describe("StateVersion 3 to 4 migration", async () => {
beforeEach(() => {
const globalVersion3: Partial<GlobalState> = {
stateVersion: StateVersion.Three,
};
storageService.get("global", Arg.any()).resolves(globalVersion3);
storageService.get("authenticatedAccounts", Arg.any()).resolves([userId]);
});
it("clears everBeenUnlocked", async () => {
const accountVersion3: Account = {
profile: {
apiKeyClientId: null,
convertAccountToKeyConnector: null,
email: "EMAIL",
emailVerified: true,
everBeenUnlocked: true,
hasPremiumPersonally: false,
kdfIterations: 100000,
kdfType: 0,
keyHash: "KEY_HASH",
lastSync: "LAST_SYNC",
userId: userId,
usesKeyConnector: false,
forcePasswordReset: false,
},
};
const expectedAccountVersion4: Account = {
profile: {
...accountVersion3.profile,
},
};
delete expectedAccountVersion4.profile.everBeenUnlocked;
storageService.get(userId, Arg.any()).resolves(accountVersion3);
await stateMigrationService.migrate();
storageService.received(1).save(userId, expectedAccountVersion4, Arg.any());
});
it("updates StateVersion number", async () => {
await stateMigrationService.migrate();
storageService.received(1).save(
"global",
Arg.is((globals: GlobalState) => globals.stateVersion === StateVersion.Four),
Arg.any(),
);
});
});
});

View File

@@ -1,3 +1,7 @@
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { EncString } from "@/jslib/common/src/models/domain/encString";
function newGuid() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
@@ -17,6 +21,13 @@ export function BuildTestObject<T, K extends keyof T = keyof T>(
return Object.assign(constructor === null ? {} : new constructor(), def) as T;
}
export function mockEnc(s: string): EncString {
const mock = Substitute.for<EncString>();
mock.decrypt(Arg.any(), Arg.any()).resolves(s);
return mock;
}
export function makeStaticByteArray(length: number, start = 0) {
const arr = new Uint8Array(length);
for (let i = 0; i < length; i++) {

View File

@@ -3,6 +3,5 @@ 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
Five = 5, // New state service implementation
Latest = Five,
Latest = Four,
}

View File

@@ -1,11 +1,13 @@
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

@@ -127,13 +127,6 @@ export class WindowMain {
},
});
// Enable SharedArrayBuffer. See https://developer.chrome.com/blog/enabling-shared-array-buffer/#cross-origin-isolation
this.win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
details.responseHeaders["Cross-Origin-Opener-Policy"] = ["same-origin"];
details.responseHeaders["Cross-Origin-Embedder-Policy"] = ["require-corp"];
callback({ responseHeaders: details.responseHeaders });
});
if (this.windowStates[mainWindowSizeKey].isMaximized) {
this.win.maximize();
}

12397
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "@bitwarden/directory-connector",
"productName": "Bitwarden Directory Connector",
"description": "Sync your user directory to your Bitwarden organization.",
"version": "2026.2.0",
"version": "2025.12.0",
"keywords": [
"bitwarden",
"password",
@@ -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.mjs",
"build:renderer": "webpack --config webpack.renderer.mjs",
"build:renderer:watch": "webpack --config webpack.renderer.mjs --watch",
"build:main": "webpack --config webpack.main.cjs",
"build:renderer": "webpack --config webpack.renderer.cjs",
"build:renderer:watch": "webpack --config webpack.renderer.cjs --watch",
"build:dist": "npm run reset && npm run rebuild && npm run build",
"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",
"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",
"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/*",
@@ -73,16 +73,17 @@
"test:types": "npx tsc --noEmit"
},
"devDependencies": {
"@angular-eslint/eslint-plugin-template": "21.1.0",
"@angular-eslint/template-parser": "21.1.0",
"@angular/build": "21.1.2",
"@angular/compiler-cli": "21.1.1",
"@angular-devkit/build-angular": "20.3.3",
"@angular-eslint/eslint-plugin-template": "20.7.0",
"@angular-eslint/template-parser": "20.7.0",
"@angular/compiler-cli": "20.3.15",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "4.0.1",
"@fluffy-spoon/substitute": "1.208.0",
"@microsoft/microsoft-graph-types": "2.43.1",
"@ngtools/webpack": "21.1.2",
"@ngtools/webpack": "20.3.3",
"@types/inquirer": "8.2.10",
"@types/jest": "30.0.0",
"@types/jest": "29.5.14",
"@types/lowdb": "1.0.15",
"@types/node": "22.19.2",
"@types/node-fetch": "2.6.12",
@@ -90,11 +91,10 @@
"@types/proper-lockfile": "4.1.4",
"@types/semver": "7.7.1",
"@types/tldjs": "2.3.4",
"@typescript-eslint/eslint-plugin": "8.54.0",
"@typescript-eslint/parser": "8.54.0",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@yao-pkg/pkg": "5.16.1",
"babel-loader": "10.0.0",
"jest-environment-jsdom": "30.2.0",
"clean-webpack-plugin": "4.0.0",
"concurrently": "9.2.0",
"copy-webpack-plugin": "13.0.0",
"cross-env": "7.0.3",
@@ -105,27 +105,28 @@
"electron-log": "5.4.1",
"electron-reload": "2.0.0-alpha.1",
"electron-store": "8.2.0",
"electron-updater": "6.7.3",
"electron-updater": "6.6.2",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.5",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-rxjs-angular-x": "0.1.0",
"eslint-plugin-rxjs-x": "0.9.1",
"eslint-plugin-rxjs-x": "0.8.3",
"form-data": "4.0.4",
"glob": "13.0.6",
"glob": "13.0.0",
"html-loader": "5.1.0",
"html-webpack-plugin": "5.6.3",
"husky": "9.1.7",
"jest": "30.2.0",
"jest": "29.7.0",
"jest-junit": "16.0.0",
"jest-mock-extended": "4.0.0",
"jest-preset-angular": "16.0.0",
"jest-preset-angular": "14.6.0",
"lint-staged": "16.2.6",
"mini-css-extract-plugin": "2.10.0",
"mini-css-extract-plugin": "2.9.2",
"minimatch": "5.1.2",
"node-forge": "1.3.2",
"node-loader": "2.1.0",
"prettier": "3.8.1",
"prettier": "3.7.4",
"rimraf": "6.1.0",
"rxjs": "7.8.2",
"sass": "1.97.1",
@@ -133,25 +134,25 @@
"ts-jest": "29.4.1",
"ts-loader": "9.5.2",
"tsconfig-paths-webpack-plugin": "4.2.0",
"type-fest": "5.4.2",
"type-fest": "5.3.0",
"typescript": "5.9.3",
"webpack": "5.105.1",
"webpack": "5.104.1",
"webpack-cli": "6.0.1",
"webpack-merge": "6.0.1",
"webpack-node-externals": "3.0.0",
"zone.js": "0.16.0"
"zone.js": "0.15.1"
},
"dependencies": {
"@angular/animations": "21.1.1",
"@angular/cdk": "21.1.1",
"@angular/cli": "21.1.2",
"@angular/common": "21.1.1",
"@angular/compiler": "21.1.1",
"@angular/core": "21.1.1",
"@angular/forms": "21.1.1",
"@angular/platform-browser": "21.1.1",
"@angular/platform-browser-dynamic": "21.1.1",
"@angular/router": "21.1.1",
"@angular/animations": "20.3.15",
"@angular/cdk": "20.2.14",
"@angular/cli": "20.3.3",
"@angular/common": "20.3.15",
"@angular/compiler": "20.3.15",
"@angular/core": "20.3.15",
"@angular/forms": "20.3.15",
"@angular/platform-browser": "20.3.15",
"@angular/platform-browser-dynamic": "20.3.15",
"@angular/router": "20.3.15",
"@microsoft/microsoft-graph-client": "3.0.7",
"big-integer": "1.6.52",
"bootstrap": "5.3.7",
@@ -163,16 +164,16 @@
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.6",
"keytar": "7.9.0",
"ldapts": "8.1.3",
"ldapts": "8.0.1",
"lowdb": "1.0.0",
"ngx-toastr": "20.0.4",
"ngx-toastr": "19.1.0",
"node-fetch": "2.7.0",
"parse5": "8.0.0",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.2",
"tldjs": "2.3.1",
"uuid": "11.1.0",
"zone.js": "0.16.0"
"zone.js": "0.15.1"
},
"engines": {
"node": "~20",

View File

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

View File

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

View File

@@ -1,60 +0,0 @@
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 { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.service";
import { EnvironmentComponent } from "./environment.component";
@@ -41,7 +41,7 @@ export class ApiKeyComponent {
private platformUtilsService: PlatformUtilsService,
private modalService: ModalService,
private logService: LogService,
private stateService: StateServiceVNext,
private stateService: StateService,
) {}
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 { StateServiceVNext } from "../abstractions/state-vNext.service";
import { StateService } from "../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
private logService: LogService,
) {}

View File

@@ -1,4 +1,4 @@
import { enableProdMode, provideZoneChangeDetection } from "@angular/core";
import { enableProdMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { isDev } from "@/jslib/electron/src/utils";
@@ -11,7 +11,4 @@ if (!isDev()) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule, {
applicationProviders: [provideZoneChangeDetection()],
preserveWhitespaces: true,
});
platformBrowserDynamic().bootstrapModule(AppModule, { preserveWhitespaces: true });

View File

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

View File

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

View File

@@ -31,14 +31,12 @@ 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 { 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 { StateService } from "../../services/state.service";
import { StateMigrationService } from "../../services/stateMigration.service";
import { SyncService } from "../../services/sync.service";
import { AuthGuardService } from "./auth-guard.service";
@@ -50,7 +48,7 @@ export function initFactory(
environmentService: EnvironmentServiceAbstraction,
i18nService: I18nServiceAbstraction,
platformUtilsService: PlatformUtilsServiceAbstraction,
stateService: StateServiceVNext,
stateService: StateServiceAbstraction,
cryptoService: CryptoServiceAbstraction,
): () => Promise<void> {
return async () => {
@@ -91,7 +89,7 @@ export function initFactory(
EnvironmentServiceAbstraction,
I18nServiceAbstraction,
PlatformUtilsServiceAbstraction,
StateServiceVNext,
StateServiceAbstraction,
CryptoServiceAbstraction,
],
multi: true,
@@ -168,7 +166,7 @@ export function initFactory(
AppIdServiceAbstraction,
PlatformUtilsServiceAbstraction,
MessagingServiceAbstraction,
StateServiceVNext,
StateServiceAbstraction,
],
}),
safeProvider({
@@ -180,7 +178,7 @@ export function initFactory(
MessagingServiceAbstraction,
I18nServiceAbstraction,
EnvironmentServiceAbstraction,
StateServiceVNext,
StateServiceAbstraction,
BatchRequestBuilder,
SingleRequestBuilder,
DirectoryFactoryService,
@@ -224,29 +222,6 @@ 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: [],
@@ -258,7 +233,7 @@ export function initFactory(
safeProvider({
provide: DirectoryFactoryService,
useClass: DefaultDirectoryFactoryService,
deps: [LogServiceAbstraction, I18nServiceAbstraction, StateServiceVNext],
deps: [LogServiceAbstraction, I18nServiceAbstraction, StateServiceAbstraction],
}),
] satisfies SafeProvider[],
})

View File

@@ -3,25 +3,17 @@
<div class="card-body">
<p>
{{ "lastGroupSync" | i18n }}:
@if (!lastGroupSync) {
<span>-</span>
}
<span *ngIf="!lastGroupSync">-</span>
{{ lastGroupSync | date: "medium" }}
<br />
{{ "lastUserSync" | i18n }}:
@if (!lastUserSync) {
<span>-</span>
}
<span *ngIf="!lastUserSync">-</span>
{{ lastUserSync | date: "medium" }}
</p>
<p>
{{ "syncStatus" | i18n }}:
@if (syncRunning) {
<strong class="text-success">{{ "running" | i18n }}</strong>
}
@if (!syncRunning) {
<strong class="text-danger">{{ "stopped" | i18n }}</strong>
}
<strong *ngIf="syncRunning" class="text-success">{{ "running" | i18n }}</strong>
<strong *ngIf="!syncRunning" class="text-danger">{{ "stopped" | i18n }}</strong>
</p>
<form #startForm [appApiAction]="startPromise" class="d-inline">
<button
@@ -68,85 +60,57 @@
/>
<label class="form-check-label" for="simSinceLast">{{ "testLastSync" | i18n }}</label>
</div>
@if (!simForm.loading && (simUsers || simGroups)) {
<ng-container *ngIf="!simForm.loading && (simUsers || simGroups)">
<hr />
<div class="row">
<div class="col-lg">
<h4>{{ "users" | i18n }}</h4>
@if (simEnabledUsers && simEnabledUsers.length) {
<ul class="bwi-ul testing-list">
@for (u of simEnabledUsers; track u) {
<li title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
}
</ul>
}
@if (!simEnabledUsers || !simEnabledUsers.length) {
<p>
{{ "noUsers" | i18n }}
</p>
}
<ul class="bwi-ul testing-list" *ngIf="simEnabledUsers && simEnabledUsers.length">
<li *ngFor="let u of simEnabledUsers" title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
</ul>
<p *ngIf="!simEnabledUsers || !simEnabledUsers.length">
{{ "noUsers" | i18n }}
</p>
<h4>{{ "disabledUsers" | i18n }}</h4>
@if (simDisabledUsers && simDisabledUsers.length) {
<ul class="bwi-ul testing-list">
@for (u of simDisabledUsers; track u) {
<li title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
}
</ul>
}
@if (!simDisabledUsers || !simDisabledUsers.length) {
<p>
{{ "noUsers" | i18n }}
</p>
}
<ul class="bwi-ul testing-list" *ngIf="simDisabledUsers && simDisabledUsers.length">
<li *ngFor="let u of simDisabledUsers" title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
</ul>
<p *ngIf="!simDisabledUsers || !simDisabledUsers.length">
{{ "noUsers" | i18n }}
</p>
<h4>{{ "deletedUsers" | i18n }}</h4>
@if (simDeletedUsers && simDeletedUsers.length) {
<ul class="bwi-ul testing-list">
@for (u of simDeletedUsers; track u) {
<li title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
}
</ul>
}
@if (!simDeletedUsers || !simDeletedUsers.length) {
<p>
{{ "noUsers" | i18n }}
</p>
}
<ul class="bwi-ul testing-list" *ngIf="simDeletedUsers && simDeletedUsers.length">
<li *ngFor="let u of simDeletedUsers" title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
</ul>
<p *ngIf="!simDeletedUsers || !simDeletedUsers.length">
{{ "noUsers" | i18n }}
</p>
</div>
<div class="col-lg">
<h4>{{ "groups" | i18n }}</h4>
@if (simGroups && simGroups.length) {
<ul class="bwi-ul testing-list">
@for (g of simGroups; track g) {
<li title="{{ g.referenceId }}">
<i class="bwi bwi-li bwi-sitemap"></i>
{{ g.displayName }}
@if (g.users && g.users.length) {
<ul class="small">
@for (u of g.users; track u) {
<li title="{{ u.referenceId }}">
{{ u.displayName }}
</li>
}
</ul>
}
<ul class="bwi-ul testing-list" *ngIf="simGroups && simGroups.length">
<li *ngFor="let g of simGroups" title="{{ g.referenceId }}">
<i class="bwi bwi-li bwi-sitemap"></i>
{{ g.displayName }}
<ul class="small" *ngIf="g.users && g.users.length">
<li *ngFor="let u of g.users" title="{{ u.referenceId }}">
{{ u.displayName }}
</li>
}
</ul>
}
@if (!simGroups || !simGroups.length) {
<p>{{ "noGroups" | i18n }}</p>
}
</ul>
</li>
</ul>
<p *ngIf="!simGroups || !simGroups.length">{{ "noGroups" | i18n }}</p>
</div>
</div>
}
</ng-container>
</div>
</div>

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 { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
) {}
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 { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.service";
const BroadcasterSubscriptionId = "MoreComponent";
@@ -26,7 +26,7 @@ export class MoreComponent implements OnInit {
private broadcasterService: BroadcasterService,
private ngZone: NgZone,
private changeDetectorRef: ChangeDetectorRef,
private stateService: StateServiceVNext,
private stateService: StateService,
) {}
async ngOnInit() {

View File

@@ -6,11 +6,9 @@
<div class="mb-3">
<label for="directory" class="form-label">{{ "type" | i18n }}</label>
<select class="form-select" id="directory" name="Directory" [(ngModel)]="directory">
@for (o of directoryOptions; track o) {
<option [ngValue]="o.value">
{{ o.name }}
</option>
}
<option *ngFor="let o of directoryOptions" [ngValue]="o.value">
{{ o.name }}
</option>
</select>
</div>
<div [hidden]="directory != directoryType.Ldap">
@@ -53,22 +51,20 @@
<label class="form-check-label" for="ad">{{ "ldapAd" | i18n }}</label>
</div>
</div>
@if (!ldap.ad) {
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="pagedSearch"
[(ngModel)]="ldap.pagedSearch"
name="PagedSearch"
/>
<label class="form-check-label" for="pagedSearch">{{
"ldapPagedResults" | i18n
}}</label>
</div>
<div class="mb-3" *ngIf="!ldap.ad">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="pagedSearch"
[(ngModel)]="ldap.pagedSearch"
name="PagedSearch"
/>
<label class="form-check-label" for="pagedSearch">{{
"ldapPagedResults" | i18n
}}</label>
</div>
}
</div>
<div class="mb-3">
<div class="form-check">
<input
@@ -83,122 +79,116 @@
}}</label>
</div>
</div>
@if (ldap.ssl) {
<div class="ms-4">
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="radio"
[value]="false"
id="ssl"
[(ngModel)]="ldap.startTls"
name="SSL"
/>
<label class="form-check-label" for="ssl">{{ "ldapSsl" | i18n }}</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
[value]="true"
id="startTls"
[(ngModel)]="ldap.startTls"
name="StartTLS"
/>
<label class="form-check-label" for="startTls">{{ "ldapTls" | i18n }}</label>
</div>
<div class="ms-4" *ngIf="ldap.ssl">
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="radio"
[value]="false"
id="ssl"
[(ngModel)]="ldap.startTls"
name="SSL"
/>
<label class="form-check-label" for="ssl">{{ "ldapSsl" | i18n }}</label>
</div>
@if (ldap.startTls) {
<div class="ms-4">
<p>{{ "ldapTlsUntrustedDesc" | i18n }}</p>
<div class="mb-3">
<label for="tlsCaPath" class="form-label">{{ "ldapTlsCa" | i18n }}</label>
<input
type="file"
class="form-control mb-2"
id="tlsCaPath_file"
(change)="setSslPath('tlsCaPath')"
/>
<input
type="text"
class="form-control"
id="tlsCaPath"
name="TLSCaPath"
[(ngModel)]="ldap.tlsCaPath"
/>
</div>
</div>
}
@if (!ldap.startTls) {
<div class="ms-4">
<p>{{ "ldapSslUntrustedDesc" | i18n }}</p>
<div class="mb-3">
<label for="sslCertPath" class="form-label">{{ "ldapSslCert" | i18n }}</label>
<input
type="file"
class="form-control mb-2"
id="sslCertPath_file"
(change)="setSslPath('sslCertPath')"
/>
<input
type="text"
class="form-control"
id="sslCertPath"
name="SSLCertPath"
[(ngModel)]="ldap.sslCertPath"
/>
</div>
<div class="mb-3">
<label for="sslKeyPath" class="form-label">{{ "ldapSslKey" | i18n }}</label>
<input
type="file"
class="form-control mb-2"
id="sslKeyPath_file"
(change)="setSslPath('sslKeyPath')"
/>
<input
type="text"
class="form-control"
id="sslKeyPath"
name="SSLKeyPath"
[(ngModel)]="ldap.sslKeyPath"
/>
</div>
<div class="mb-3">
<label for="sslCaPath" class="form-label">{{ "ldapSslCa" | i18n }}</label>
<input
type="file"
class="form-control mb-2"
id="sslCaPath_file"
(change)="setSslPath('sslCaPath')"
/>
<input
type="text"
class="form-control"
id="sslCaPath"
name="SSLCaPath"
[(ngModel)]="ldap.sslCaPath"
/>
</div>
</div>
}
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="certDoNotVerify"
[(ngModel)]="ldap.sslAllowUnauthorized"
name="CertDoNoVerify"
/>
<label class="form-check-label" for="certDoNotVerify">{{
"ldapCertDoNotVerify" | i18n
}}</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
[value]="true"
id="startTls"
[(ngModel)]="ldap.startTls"
name="StartTLS"
/>
<label class="form-check-label" for="startTls">{{ "ldapTls" | i18n }}</label>
</div>
</div>
}
<div class="ms-4" *ngIf="ldap.startTls">
<p>{{ "ldapTlsUntrustedDesc" | i18n }}</p>
<div class="mb-3">
<label for="tlsCaPath" class="form-label">{{ "ldapTlsCa" | i18n }}</label>
<input
type="file"
class="form-control mb-2"
id="tlsCaPath_file"
(change)="setSslPath('tlsCaPath')"
/>
<input
type="text"
class="form-control"
id="tlsCaPath"
name="TLSCaPath"
[(ngModel)]="ldap.tlsCaPath"
/>
</div>
</div>
<div class="ms-4" *ngIf="!ldap.startTls">
<p>{{ "ldapSslUntrustedDesc" | i18n }}</p>
<div class="mb-3">
<label for="sslCertPath" class="form-label">{{ "ldapSslCert" | i18n }}</label>
<input
type="file"
class="form-control mb-2"
id="sslCertPath_file"
(change)="setSslPath('sslCertPath')"
/>
<input
type="text"
class="form-control"
id="sslCertPath"
name="SSLCertPath"
[(ngModel)]="ldap.sslCertPath"
/>
</div>
<div class="mb-3">
<label for="sslKeyPath" class="form-label">{{ "ldapSslKey" | i18n }}</label>
<input
type="file"
class="form-control mb-2"
id="sslKeyPath_file"
(change)="setSslPath('sslKeyPath')"
/>
<input
type="text"
class="form-control"
id="sslKeyPath"
name="SSLKeyPath"
[(ngModel)]="ldap.sslKeyPath"
/>
</div>
<div class="mb-3">
<label for="sslCaPath" class="form-label">{{ "ldapSslCa" | i18n }}</label>
<input
type="file"
class="form-control mb-2"
id="sslCaPath_file"
(change)="setSslPath('sslCaPath')"
/>
<input
type="text"
class="form-control"
id="sslCaPath"
name="SSLCaPath"
[(ngModel)]="ldap.sslCaPath"
/>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="certDoNotVerify"
[(ngModel)]="ldap.sslAllowUnauthorized"
name="CertDoNoVerify"
/>
<label class="form-check-label" for="certDoNotVerify">{{
"ldapCertDoNotVerify" | i18n
}}</label>
</div>
</div>
</div>
<div class="mb-3" [hidden]="true">
<div class="form-check">
<input
@@ -221,12 +211,10 @@
name="Username"
[(ngModel)]="ldap.username"
/>
@if (ldap.ad) {
<div class="form-text">{{ "ex" | i18n }} company\admin</div>
}
@if (!ldap.ad) {
<div class="form-text">{{ "ex" | i18n }} cn=admin,dc=company,dc=com</div>
}
<div class="form-text" *ngIf="ldap.ad">{{ "ex" | i18n }} company\admin</div>
<div class="form-text" *ngIf="!ldap.ad">
{{ "ex" | i18n }} cn=admin,dc=company,dc=com
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">{{ "password" | i18n }}</label>
@@ -616,24 +604,18 @@
name="UserFilter"
[(ngModel)]="sync.userFilter"
></textarea>
@if (directory === directoryType.Ldap) {
<div class="form-text">
{{ "ex" | i18n }} (&amp;(givenName=John)(|(l=Dallas)(l=Austin)))
</div>
}
@if (directory === directoryType.EntraID) {
<div class="form-text">{{ "ex" | i18n }} exclude:joe&#64;company.com</div>
}
@if (directory === directoryType.Okta) {
<div class="form-text">
{{ "ex" | i18n }} exclude:joe&#64;company.com | profile.firstName eq "John"
</div>
}
@if (directory === directoryType.GSuite) {
<div class="form-text">
{{ "ex" | i18n }} exclude:joe&#64;company.com | orgUnitPath=/Engineering
</div>
}
<div class="form-text" *ngIf="directory === directoryType.Ldap">
{{ "ex" | i18n }} (&amp;(givenName=John)(|(l=Dallas)(l=Austin)))
</div>
<div class="form-text" *ngIf="directory === directoryType.EntraID">
{{ "ex" | i18n }} exclude:joe&#64;company.com
</div>
<div class="form-text" *ngIf="directory === directoryType.Okta">
{{ "ex" | i18n }} exclude:joe&#64;company.com | profile.firstName eq "John"
</div>
<div class="form-text" *ngIf="directory === directoryType.GSuite">
{{ "ex" | i18n }} exclude:joe&#64;company.com | orgUnitPath=/Engineering
</div>
</div>
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
<label for="userPath" class="form-label">{{ "userPath" | i18n }}</label>
@@ -699,20 +681,18 @@
name="GroupFilter"
[(ngModel)]="sync.groupFilter"
></textarea>
@if (directory === directoryType.Ldap) {
<div class="form-text">
{{ "ex" | i18n }} (&amp;(objectClass=group)(!(cn=Sales*))(!(cn=IT*)))
</div>
}
@if (directory === directoryType.EntraID) {
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT</div>
}
@if (directory === directoryType.Okta) {
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT | type eq "APP_GROUP"</div>
}
@if (directory === directoryType.GSuite) {
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT</div>
}
<div class="form-text" *ngIf="directory === directoryType.Ldap">
{{ "ex" | i18n }} (&amp;(objectClass=group)(!(cn=Sales*))(!(cn=IT*)))
</div>
<div class="form-text" *ngIf="directory === directoryType.EntraID">
{{ "ex" | i18n }} include:Sales,IT
</div>
<div class="form-text" *ngIf="directory === directoryType.Okta">
{{ "ex" | i18n }} include:Sales,IT | type eq "APP_GROUP"
</div>
<div class="form-text" *ngIf="directory === directoryType.GSuite">
{{ "ex" | i18n }} include:Sales,IT
</div>
</div>
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
<label for="groupPath" class="form-label">{{ "groupPath" | i18n }}</label>
@@ -723,12 +703,8 @@
name="GroupPath"
[(ngModel)]="sync.groupPath"
/>
@if (!ldap.ad) {
<div class="form-text">{{ "ex" | i18n }} CN=Groups</div>
}
@if (ldap.ad) {
<div class="form-text">{{ "ex" | i18n }} CN=Users</div>
}
<div class="form-text" *ngIf="!ldap.ad">{{ "ex" | i18n }} CN=Groups</div>
<div class="form-text" *ngIf="ldap.ad">{{ "ex" | i18n }} CN=Users</div>
</div>
<div [hidden]="directory != directoryType.Ldap || ldap.ad">
<div class="mb-3">

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 { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
) {
this.directoryOptions = [
{ name: this.i18nService.t("select"), value: null },

View File

@@ -1,6 +1,4 @@
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";
@@ -19,10 +17,7 @@ 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";
@@ -32,15 +27,12 @@ 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 { 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 { StateService } from "./services/state.service";
import { StateMigrationService } from "./services/stateMigration.service";
import { SyncService } from "./services/sync.service";
// ESM __dirname polyfill for Node 20
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// eslint-disable-next-line
const packageJson = require("../package.json");
export class Main {
dataFilePath: string;
@@ -61,7 +53,6 @@ export class Main {
cryptoFunctionService: NodeCryptoFunctionService;
authService: AuthService;
syncService: SyncService;
stateServiceVNext: StateServiceVNext;
stateService: StateService;
stateMigrationService: StateMigrationService;
directoryFactoryService: DirectoryFactoryService;
@@ -125,14 +116,6 @@ 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,
@@ -167,13 +150,13 @@ export class Main {
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.stateServiceVNext,
this.stateService,
);
this.directoryFactoryService = new DefaultDirectoryFactoryService(
this.logService,
this.i18nService,
this.stateServiceVNext,
this.stateService,
);
this.batchRequestBuilder = new BatchRequestBuilder();
@@ -185,7 +168,7 @@ export class Main {
this.messagingService,
this.i18nService,
this.environmentService,
this.stateServiceVNext,
this.stateService,
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 { StateServiceVNext } from "../abstractions/state-vNext.service";
import { StateService } from "../abstractions/state.service";
export class ClearCacheCommand {
constructor(
private i18nService: I18nService,
private stateService: StateServiceVNext,
private stateService: StateService,
) {}
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 { StateServiceVNext } from "../abstractions/state-vNext.service";
import { StateService } from "../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
) {}
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 { StateServiceVNext } from "../abstractions/state-vNext.service";
import { StateService } from "../abstractions/state.service";
export class LastSyncCommand {
constructor(private stateService: StateServiceVNext) {}
constructor(private stateService: StateService) {}
async run(object: string): Promise<Response> {
try {

View File

@@ -1,9 +1,6 @@
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";
@@ -14,21 +11,12 @@ 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 { 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
import { StateService } from "./services/state.service";
export class Main {
logService: ElectronLogService;
@@ -36,7 +24,6 @@ export class Main {
storageService: ElectronStorageService;
messagingService: ElectronMainMessagingService;
credentialStorageListener: DCCredentialStorageListener;
stateServiceVNext: StateServiceVNext;
stateService: StateService;
windowMain: WindowMain;
@@ -63,7 +50,8 @@ export class Main {
const watch = args.some((val) => val === "--watch");
if (watch) {
electronReload(__dirname, {});
// eslint-disable-next-line
require("electron-reload")(__dirname, {});
}
this.logService = new ElectronLogService(null, app.getPath("userData"));
@@ -78,14 +66,6 @@ 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,3 +1,5 @@
import { Account as BaseAccount } from "@/jslib/common/src/models/domain/account";
import { DirectoryType } from "@/src/enums/directoryType";
import { EntraIdConfiguration } from "./entraIdConfiguration";
@@ -7,39 +9,23 @@ import { OktaConfiguration } from "./oktaConfiguration";
import { OneLoginConfiguration } from "./oneLoginConfiguration";
import { SyncConfiguration } from "./syncConfiguration";
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();
export class Account extends BaseAccount {
directoryConfigurations?: DirectoryConfigurations = new DirectoryConfigurations();
directorySettings: DirectorySettings = new DirectorySettings();
// 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;
clientKeys: ClientKeys = new ClientKeys();
constructor(init: Partial<Account>) {
this.userId = init?.userId;
this.entityId = init?.entityId;
this.apiKeyClientId = init?.apiKeyClientId;
this.accessToken = init?.accessToken;
this.refreshToken = init?.refreshToken;
this.apiKeyClientSecret = init?.apiKeyClientSecret;
super(init);
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;

View File

@@ -1,108 +0,0 @@
// ===================================================================
// 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

@@ -28,4 +28,4 @@ $danger: map_get($theme-colors, "danger");
$secondary: map_get($theme-colors, "secondary");
$secondary-alt: map_get($theme-colors, "secondary-alt");
@import "bootstrap/scss/bootstrap.scss";
@import "~bootstrap/scss/bootstrap.scss";

View File

@@ -1,4 +1,4 @@
@import "bootstrap/scss/_variables.scss";
@import "~bootstrap/scss/_variables.scss";
html.os_windows {
body {

View File

@@ -1,4 +1,4 @@
@import "bootstrap/scss/_variables.scss";
@import "~bootstrap/scss/_variables.scss";
body {
padding: 10px 0 20px 0;

View File

@@ -1,6 +1,6 @@
@import "ngx-toastr/toastr";
@import "~ngx-toastr/toastr";
@import "bootstrap/scss/_variables.scss";
@import "~bootstrap/scss/_variables.scss";
.toast-container {
.toast-close-button {

View File

@@ -2,12 +2,18 @@ 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 { StateServiceVNext } from "../abstractions/state-vNext.service";
import { StateService } from "../abstractions/state.service";
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
export class AuthService {
constructor(
@@ -15,7 +21,7 @@ export class AuthService {
private appIdService: AppIdService,
private platformUtilsService: PlatformUtilsService,
private messagingService: MessagingService,
private stateService: StateServiceVNext,
private stateService: StateService,
) {}
async logIn(credentials: { clientId: string; clientSecret: string }) {
@@ -52,9 +58,34 @@ export class AuthService {
) {
const clientId = tokenRequest.clientId;
const entityId = clientId.split("organization.")[1];
const clientSecret = tokenRequest.clientSecret;
// 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);
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(),
}),
);
}
}

View File

@@ -1,15 +1,21 @@
import { mock } from "jest-mock-extended";
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
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 { 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 { StateServiceVNext } from "../abstractions/state-vNext.service";
import { MessagingService } from "../../jslib/common/src/abstractions/messaging.service";
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
import { AuthService } from "./auth.service";
import { StateService } from "./state.service";
const clientId = "organization.CLIENT_ID";
const clientSecret = "CLIENT_SECRET";
@@ -29,22 +35,22 @@ export function identityTokenResponseFactory() {
}
describe("AuthService", () => {
let apiService: jest.Mocked<ApiService>;
let appIdService: jest.Mocked<AppIdService>;
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
let messagingService: jest.Mocked<MessagingService>;
let stateService: jest.Mocked<StateServiceVNext>;
let apiService: SubstituteOf<ApiService>;
let appIdService: SubstituteOf<AppIdService>;
let platformUtilsService: SubstituteOf<PlatformUtilsService>;
let messagingService: SubstituteOf<MessagingService>;
let stateService: SubstituteOf<StateService>;
let authService: AuthService;
beforeEach(async () => {
apiService = mock<ApiService>();
appIdService = mock<AppIdService>();
platformUtilsService = mock<PlatformUtilsService>();
stateService = mock<StateServiceVNext>();
messagingService = mock<MessagingService>();
apiService = Substitute.for();
appIdService = Substitute.for();
platformUtilsService = Substitute.for();
stateService = Substitute.for();
messagingService = Substitute.for();
appIdService.getAppId.mockResolvedValue(deviceId);
appIdService.getAppId().resolves(deviceId);
authService = new AuthService(
apiService,
@@ -55,12 +61,37 @@ describe("AuthService", () => {
);
});
it("sets the organization ID after a successful login", async () => {
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
it("sets the local environment after a successful login", async () => {
apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory());
await authService.logIn({ clientId, clientSecret });
expect(stateService.setOrganizationId).toHaveBeenCalledTimes(1);
expect(stateService.setOrganizationId).toHaveBeenCalledWith("CLIENT_ID");
stateService.received(1).addAccount(
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(),
}),
);
});
});

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 { StateServiceVNext } from "../abstractions/state-vNext.service";
import { StateService } from "../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
) {}
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 { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
) {
super();
this.init();

View File

@@ -1,8 +1,6 @@
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 {
@@ -12,6 +10,7 @@ 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";
@@ -35,7 +34,7 @@ jest.setTimeout(15000);
describe("gsuiteDirectoryService", () => {
let logService: MockProxy<LogService>;
let i18nService: MockProxy<I18nService>;
let stateService: MockProxy<StateServiceVNext>;
let stateService: MockProxy<StateService>;
let directoryService: GSuiteDirectoryService;
@@ -51,36 +50,221 @@ describe("gsuiteDirectoryService", () => {
directoryService = new GSuiteDirectoryService(logService, i18nService, stateService);
});
it("syncs without using filters (includes test data)", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
describe("basic sync fetching users and groups", () => {
it("syncs without using filters (includes test data)", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
groups: true,
users: true,
const syncConfig = getSyncConfiguration({
groups: true,
users: true,
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
expect(result[0]).toEqual(expect.arrayContaining(groupFixtures));
expect(result[1]).toEqual(expect.arrayContaining(userFixtures));
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
it("syncs using user and group filters (exact match for test data)", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
expect(result[0]).toEqual(expect.arrayContaining(groupFixtures));
expect(result[1]).toEqual(expect.arrayContaining(userFixtures));
const syncConfig = getSyncConfiguration({
groups: true,
users: true,
userFilter: INTEGRATION_USER_FILTER,
groupFilter: INTEGRATION_GROUP_FILTER,
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
expect(result).toEqual([groupFixtures, userFixtures]);
});
it("syncs only users when groups sync is disabled", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
groups: false,
users: true,
userFilter: INTEGRATION_USER_FILTER,
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
expect(result[0]).toBeUndefined();
expect(result[1]).toEqual(expect.arrayContaining(userFixtures));
});
it("syncs only groups when users sync is disabled", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
groups: true,
users: false,
groupFilter: INTEGRATION_GROUP_FILTER,
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
expect(result[0]).toEqual(expect.arrayContaining(groupFixtures));
expect(result[1]).toEqual([]);
});
});
it("syncs using user and group filters (exact match for test data)", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
describe("users", () => {
it("includes disabled users in sync results", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
groups: true,
users: true,
userFilter: INTEGRATION_USER_FILTER,
groupFilter: INTEGRATION_GROUP_FILTER,
const syncConfig = getSyncConfiguration({
users: true,
userFilter: INTEGRATION_USER_FILTER,
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
const disabledUser = userFixtures.find((u) => u.email === "testuser5@bwrox.dev");
expect(result[1]).toContainEqual(disabledUser);
expect(disabledUser.disabled).toBe(true);
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
it("filters users by org unit path", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
expect(result).toEqual([groupFixtures, userFixtures]);
const syncConfig = getSyncConfiguration({
users: true,
userFilter: INTEGRATION_USER_FILTER,
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
expect(result[1]).toEqual(userFixtures);
expect(result[1].length).toBe(5);
});
it("filters users by email pattern", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
users: true,
userFilter: "|email:testuser1*",
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
const testuser1 = userFixtures.find((u) => u.email === "testuser1@bwrox.dev");
expect(result[1]).toContainEqual(testuser1);
expect(result[1].length).toBeGreaterThanOrEqual(1);
});
});
describe("groups", () => {
it("filters groups by name pattern", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
groups: true,
users: true,
userFilter: INTEGRATION_USER_FILTER,
groupFilter: INTEGRATION_GROUP_FILTER,
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
expect(result[0]).toEqual(groupFixtures);
expect(result[0].length).toBe(2);
});
it("includes group members correctly", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
groups: true,
users: true,
userFilter: INTEGRATION_USER_FILTER,
groupFilter: INTEGRATION_GROUP_FILTER,
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
const groupA = result[0].find((g) => g.name === "Integration Test Group A");
expect(groupA).toBeDefined();
expect(groupA.userMemberExternalIds.size).toBe(2);
expect(groupA.userMemberExternalIds.has("111605910541641314041")).toBe(true);
expect(groupA.userMemberExternalIds.has("111147009830456099026")).toBe(true);
const groupB = result[0].find((g) => g.name === "Integration Test Group B");
expect(groupB).toBeDefined();
expect(groupB.userMemberExternalIds.size).toBe(2);
expect(groupB.userMemberExternalIds.has("111147009830456099026")).toBe(true);
expect(groupB.userMemberExternalIds.has("100150970267699397306")).toBe(true);
});
it("handles groups with no members", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
groups: true,
users: true,
userFilter: INTEGRATION_USER_FILTER,
groupFilter: "|name:Integration*",
});
stateService.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
// All test groups should have members, but ensure the code handles empty groups
expect(result[0]).toBeDefined();
expect(Array.isArray(result[0])).toBe(true);
});
});
describe("error handling", () => {
it("throws error when directory configuration is incomplete", async () => {
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(
getGSuiteConfiguration({
clientEmail: "",
}),
);
const syncConfig = getSyncConfiguration({
users: true,
});
stateService.getSync.mockResolvedValue(syncConfig);
await expect(directoryService.getEntries(true, true)).rejects.toThrow();
});
it("throws error when authentication fails with invalid credentials", async () => {
const directoryConfig = getGSuiteConfiguration({
privateKey: "-----BEGIN PRIVATE KEY-----\nINVALID_KEY\n-----END PRIVATE KEY-----\n",
});
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
users: true,
});
stateService.getSync.mockResolvedValue(syncConfig);
await expect(directoryService.getEntries(true, true)).rejects.toThrow();
});
});
});

View File

@@ -4,8 +4,7 @@ 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 { StateServiceVNext } from "@/src/abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.service";
import { DirectoryType } from "../../enums/directoryType";
import { GroupEntry } from "../../models/groupEntry";
import { GSuiteConfiguration } from "../../models/gsuiteConfiguration";
@@ -15,6 +14,22 @@ import { BaseDirectoryService } from "../baseDirectory.service";
import { IDirectoryService } from "./directory.service";
/**
* Google Workspace (formerly G Suite) Directory Service
*
* This service integrates with Google Workspace to synchronize users and groups
* to Bitwarden organizations using the Google Admin SDK Directory API.
*
* @remarks
* Authentication is performed using a service account with domain-wide delegation.
* The service account must be granted the following OAuth 2.0 scopes:
* - https://www.googleapis.com/auth/admin.directory.user.readonly
* - https://www.googleapis.com/auth/admin.directory.group.readonly
* - https://www.googleapis.com/auth/admin.directory.group.member.readonly
*
* @see {@link https://developers.google.com/admin-sdk/directory/v1/guides | Google Admin SDK Directory API}
* @see {@link https://support.google.com/a/answer/162106 | Domain-wide delegation of authority}
*/
export class GSuiteDirectoryService extends BaseDirectoryService implements IDirectoryService {
private client: JWT;
private service: admin_directory_v1.Admin;
@@ -25,12 +40,35 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
constructor(
private logService: LogService,
private i18nService: I18nService,
private stateService: StateServiceVNext,
private stateService: StateService,
) {
super();
this.service = google.admin("directory_v1");
}
/**
* Retrieves users and groups from Google Workspace directory
* @returns A tuple containing [groups, users] arrays
*
* @remarks
* This function:
* 1. Validates the directory type matches GSuite
* 2. Loads directory and sync configuration
* 3. Authenticates with Google Workspace using service account credentials
* 4. Retrieves users (if enabled in sync config)
* 5. Retrieves groups and their members (if enabled in sync config)
* 6. Applies any user/group filters specified in sync configuration
*
* User and group filters follow Google Workspace Directory API query syntax:
* - Use `|` prefix for custom filters (e.g., "|orgUnitPath='/Engineering'")
* - Multiple conditions can be combined with AND/OR operators
*
* @example
* ```typescript
* const [groups, users] = await service.getEntries(true, false);
* console.log(`Synced ${users.length} users and ${groups.length} groups`);
* ```
*/
async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {
const type = await this.stateService.getDirectoryType();
if (type !== DirectoryType.GSuite) {
@@ -66,6 +104,26 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
return [groups, users];
}
/**
* Retrieves all users from Google Workspace directory
*
* @returns Array of UserEntry objects representing users in the directory
*
* @remarks
* This method performs two separate queries:
* 1. Active users (including suspended and archived)
* 2. Deleted users (marked with deleted flag)
*
* The method handles pagination automatically, fetching all pages of results.
* Users are filtered based on the userFilter specified in sync configuration.
*
* User properties mapped:
* - referenceId: User's unique Google ID
* - externalId: User's unique Google ID (same as referenceId)
* - email: User's primary email address (lowercase)
* - disabled: True if user is suspended or archived
* - deleted: True if user is deleted from the directory
*/
private async getUsers(): Promise<UserEntry[]> {
const entries: UserEntry[] = [];
const query = this.createDirectoryQuery(this.syncConfig.userFilter);
@@ -133,6 +191,13 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
return entries;
}
/**
* Transforms a Google Workspace user object into a UserEntry
*
* @param user - Google Workspace user object from the API
* @param deleted - Whether this user is from the deleted users list
* @returns UserEntry object or null if user data is invalid
*/
private buildUser(user: admin_directory_v1.Schema$User, deleted: boolean) {
if ((user.emails == null || user.emails === "") && !deleted) {
return null;
@@ -147,6 +212,17 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
return entry;
}
/**
* Retrieves all groups from Google Workspace directory
*
* @param setFilter - Tuple of [isWhitelist, Set<string>] for filtering groups
* @param users - Array of UserEntry objects to reference when processing members
* @returns Array of GroupEntry objects representing groups in the directory
*
* @remarks
* For each group, the method also retrieves all group members by calling the
* members API. Groups are filtered based on the groupFilter in sync configuration.
*/
private async getGroups(
setFilter: [boolean, Set<string>],
users: UserEntry[],
@@ -186,6 +262,19 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
return entries;
}
/**
* Transforms a Google Workspace group object into a GroupEntry with members
*
* @param group - Google Workspace group object from the API
* @param users - Array of UserEntry objects for reference
* @returns GroupEntry object with all members populated
*
* @remarks
* This method retrieves all members of the group, handling three member types:
* - USER: Individual user members (only active status users are included)
* - GROUP: Nested group members
* - CUSTOMER: Special type that includes all users in the domain
*/
private async buildGroup(group: admin_directory_v1.Schema$Group, users: UserEntry[]) {
let nextPageToken: string = null;
@@ -231,6 +320,26 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
return entry;
}
/**
* Authenticates with Google Workspace using service account credentials
*
* @throws Error if required configuration fields are missing or authentication fails
*
* @remarks
* Authentication uses a JWT with the following required fields:
* - clientEmail: Service account email address
* - privateKey: Service account private key (PEM format)
* - subject: Admin user email to impersonate (for domain-wide delegation)
*
* The service account must be configured with domain-wide delegation and granted
* the required OAuth scopes in the Google Workspace Admin Console.
*
* Optional configuration:
* - domain: Filters results to a specific domain
* - customer: Customer ID for multi-domain organizations
*
* @see {@link https://developers.google.com/identity/protocols/oauth2/service-account | Service account authentication}
*/
private async auth() {
if (
this.dirConfig.clientEmail == null ||

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<StateServiceVNext>;
let stateService: MockProxy<StateService>;
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 { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
) {}
async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {
@@ -68,12 +68,10 @@ export class LdapDirectoryService implements IDirectoryService {
}
groups = await this.getGroups(groupForce);
}
} catch (e) {
} finally {
await this.client.unbind();
throw e;
}
await this.client.unbind();
return [groups, users];
}
@@ -455,9 +453,8 @@ export class LdapDirectoryService implements IDirectoryService {
try {
await this.client.bind(user, pass);
} catch (error) {
} catch {
await this.client.unbind();
throw error;
}
}

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 { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
) {
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 { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
) {
super();
}

View File

@@ -1,488 +0,0 @@
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

@@ -1,420 +0,0 @@
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,13 +16,32 @@ 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
@@ -539,16 +558,18 @@ export class StateService
protected async scaffoldNewAccountDiskStorage(account: Account): Promise<void> {
const storageOptions = this.reconcileOptions(
{ userId: account.userId },
{ userId: account.profile.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,
@@ -579,7 +600,7 @@ export class StateService
protected resetAccount(account: Account) {
const persistentAccountInformation = {
settings: account.settings, // Required by base class (unused by DC)
settings: account.settings,
directorySettings: account.directorySettings,
directoryConfigurations: account.directoryConfigurations,
};

View File

@@ -1,196 +0,0 @@
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

@@ -8,14 +8,48 @@ 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();
@@ -27,13 +61,6 @@ 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;
}
@@ -116,10 +143,15 @@ export class StateMigrationService extends BaseStateMigrationService {
const account = await this.get<Account>(userId);
account.directoryConfigurations = directoryConfigs;
account.directorySettings = directorySettings;
account.userId = userId;
account.entityId = userId;
account.apiKeyClientId = clientId;
account.apiKeyClientSecret = clientSecret;
account.profile = {
userId: userId,
entityId: userId,
apiKeyClientId: clientId,
};
account.clientKeys = {
clientId: clientId,
clientSecret: clientSecret,
};
await this.set(userId, account);
await clearDirectoryConnectorV1Keys();
@@ -166,131 +198,4 @@ 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

@@ -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<StateServiceVNext>;
let stateService: MockProxy<StateService>;
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
let apiService: MockProxy<ApiService>;
let messagingService: MockProxy<MessagingService>;

View File

@@ -6,17 +6,15 @@ import { MessagingService } from "@/jslib/common/src/abstractions/messaging.serv
import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest";
import { ApiService } from "@/jslib/common/src/services/api.service";
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 +27,7 @@ describe("SyncService", () => {
let messagingService: MockProxy<MessagingService>;
let i18nService: MockProxy<I18nService>;
let environmentService: MockProxy<EnvironmentService>;
let stateService: MockProxy<StateServiceVNext>;
let stateService: MockProxy<StateService>;
let directoryFactory: MockProxy<DirectoryFactoryService>;
let batchRequestBuilder: MockProxy<BatchRequestBuilder>;
let singleRequestBuilder: MockProxy<SingleRequestBuilder>;
@@ -136,134 +134,4 @@ describe("SyncService", () => {
expect(apiService.postPublicImportDirectory).not.toHaveBeenCalled();
});
describe("nested and circular group handling", () => {
function createGroup(
name: string,
userExternalIds: string[] = [],
groupMemberReferenceIds: string[] = [],
) {
return GroupEntry.fromJSON({
name,
referenceId: name,
externalId: name,
userMemberExternalIds: userExternalIds,
groupMemberReferenceIds: groupMemberReferenceIds,
users: [],
});
}
function setupSyncWithGroups(groups: GroupEntry[]) {
const mockDirectoryService = mock<LdapDirectoryService>();
mockDirectoryService.getEntries.mockResolvedValue([groups, []]);
directoryFactory.createService.mockReturnValue(mockDirectoryService);
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));
stateService.getLastSyncHash.mockResolvedValue("unique hash");
singleRequestBuilder.buildRequest.mockReturnValue([
{ members: [], groups: [], overwriteExisting: true, largeImport: false },
]);
}
it("should handle simple circular reference (A ↔ B) without stack overflow", async () => {
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
const groupB = createGroup("GroupB", ["userB"], ["GroupA"]);
setupSyncWithGroups([groupA, groupB]);
const [groups] = await syncService.sync(true, true);
const [a, b] = groups;
expect(a.userMemberExternalIds).toEqual(new Set(["userA", "userB"]));
expect(b.userMemberExternalIds).toEqual(new Set(["userA", "userB"]));
});
it("should handle longer circular chain (A → B → C → A) without stack overflow", async () => {
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
const groupB = createGroup("GroupB", ["userB"], ["GroupC"]);
const groupC = createGroup("GroupC", ["userC"], ["GroupA"]);
setupSyncWithGroups([groupA, groupB, groupC]);
const [groups] = await syncService.sync(true, true);
const allUsers = new Set(["userA", "userB", "userC"]);
for (const group of groups) {
expect(group.userMemberExternalIds).toEqual(allUsers);
}
});
it("should handle diamond structure (A → [B, C] → D)", async () => {
const groupA = createGroup("GroupA", ["userA"], ["GroupB", "GroupC"]);
const groupB = createGroup("GroupB", ["userB"], ["GroupD"]);
const groupC = createGroup("GroupC", ["userC"], ["GroupD"]);
const groupD = createGroup("GroupD", ["userD"], []);
setupSyncWithGroups([groupA, groupB, groupC, groupD]);
const [groups] = await syncService.sync(true, true);
const [a, b, c, d] = groups;
expect(a.userMemberExternalIds).toEqual(new Set(["userA", "userB", "userC", "userD"]));
expect(b.userMemberExternalIds).toEqual(new Set(["userB", "userD"]));
expect(c.userMemberExternalIds).toEqual(new Set(["userC", "userD"]));
expect(d.userMemberExternalIds).toEqual(new Set(["userD"]));
});
it("should handle deep nesting with circular reference at leaf", async () => {
// Structure: A → B → C → D → B (cycle back to B)
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
const groupB = createGroup("GroupB", ["userB"], ["GroupC"]);
const groupC = createGroup("GroupC", ["userC"], ["GroupD"]);
const groupD = createGroup("GroupD", ["userD"], ["GroupB"]);
setupSyncWithGroups([groupA, groupB, groupC, groupD]);
const [groups] = await syncService.sync(true, true);
const [a, b, c, d] = groups;
const cycleUsers = new Set(["userB", "userC", "userD"]);
expect(a.userMemberExternalIds).toEqual(new Set(["userA", ...cycleUsers]));
expect(b.userMemberExternalIds).toEqual(cycleUsers);
expect(c.userMemberExternalIds).toEqual(cycleUsers);
expect(d.userMemberExternalIds).toEqual(cycleUsers);
});
it("should handle complex structure with multiple cycles and shared members", async () => {
// Structure:
// A → [B, C]
// B → [D, E]
// C → [E, F]
// D → A (cycle)
// E → C (cycle)
// F → (leaf)
const groupA = createGroup("GroupA", ["userA"], ["GroupB", "GroupC"]);
const groupB = createGroup("GroupB", ["userB"], ["GroupD", "GroupE"]);
const groupC = createGroup("GroupC", ["userC"], ["GroupE", "GroupF"]);
const groupD = createGroup("GroupD", ["userD"], ["GroupA"]);
const groupE = createGroup("GroupE", ["userE"], ["GroupC"]);
const groupF = createGroup("GroupF", ["userF"], []);
setupSyncWithGroups([groupA, groupB, groupC, groupD, groupE, groupF]);
const [groups] = await syncService.sync(true, true);
const allUsers = new Set(["userA", "userB", "userC", "userD", "userE", "userF"]);
const a = groups.find((g) => g.name === "GroupA");
const b = groups.find((g) => g.name === "GroupB");
const c = groups.find((g) => g.name === "GroupC");
const d = groups.find((g) => g.name === "GroupD");
const e = groups.find((g) => g.name === "GroupE");
const f = groups.find((g) => g.name === "GroupF");
// A can reach all groups, so it gets all users
expect(a.userMemberExternalIds).toEqual(allUsers);
// B reaches D, E, and through cycles reaches everything
expect(b.userMemberExternalIds).toEqual(allUsers);
// C reaches E (which cycles back to C) and F
expect(c.userMemberExternalIds).toEqual(new Set(["userC", "userE", "userF"]));
// D cycles to A, which reaches everything
expect(d.userMemberExternalIds).toEqual(allUsers);
// E cycles to C, picking up C's descendants
expect(e.userMemberExternalIds).toEqual(new Set(["userC", "userE", "userF"]));
// F is a leaf
expect(f.userMemberExternalIds).toEqual(new Set(["userF"]));
});
});
});

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 { StateServiceVNext } from "../abstractions/state-vNext.service";
import { StateService } from "../abstractions/state.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: StateServiceVNext,
private stateService: StateService,
private batchRequestBuilder: BatchRequestBuilder,
private singleRequestBuilder: SingleRequestBuilder,
private directoryFactory: DirectoryFactoryService,
@@ -196,27 +196,14 @@ export class SyncService {
return users == null ? null : users.filter((u) => u.email?.length <= 256);
}
private flattenUsersToGroups(
levelGroups: GroupEntry[],
allGroups: GroupEntry[],
visitedGroups?: Set<string>,
): Set<string> {
private flattenUsersToGroups(levelGroups: GroupEntry[], allGroups: GroupEntry[]): Set<string> {
let allUsers = new Set<string>();
if (allGroups == null) {
return allUsers;
}
for (const group of levelGroups) {
const visited = visitedGroups ?? new Set<string>();
if (visited.has(group.referenceId)) {
continue;
}
visited.add(group.referenceId);
const childGroups = allGroups.filter((g) => group.groupMemberReferenceIds.has(g.referenceId));
const childUsers = this.flattenUsersToGroups(childGroups, allGroups, visited);
const childUsers = this.flattenUsersToGroups(childGroups, allGroups);
childUsers.forEach((id) => group.userMemberExternalIds.add(id));
allUsers = new Set([...allUsers, ...group.userMemberExternalIds]);
}

View File

@@ -1,7 +1,7 @@
import { webcrypto } from "crypto";
import { TextEncoder, TextDecoder } from "util";
Object.assign(globalThis, { TextEncoder, TextDecoder });
import "jest-preset-angular/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {
value: () => {

View File

@@ -5,7 +5,7 @@
},
"compilerOptions": {
"pretty": true,
"moduleResolution": "bundler",
"moduleResolution": "node",
"noImplicitAny": true,
"target": "ES2016",
"module": "ES2020",

View File

@@ -1,308 +0,0 @@
version: 1
dn: dc=bitwarden,dc=com
dc: bitwarden
objectClass: dcObject
objectClass: organization
o: Bitwarden
# Organizational Units
dn: ou=Human Resources,dc=bitwarden,dc=com
changetype: add
ou: Human Resources
objectClass: top
objectClass: organizationalUnit
dn: ou=Engineering,dc=bitwarden,dc=com
changetype: add
ou: Engineering
objectClass: top
objectClass: organizationalUnit
dn: ou=Marketing,dc=bitwarden,dc=com
changetype: add
ou: Marketing
objectClass: top
objectClass: organizationalUnit
# Users - Human Resources
dn: cn=Roland Dyke,ou=Human Resources,dc=bitwarden,dc=com
changetype: add
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
cn: Roland Dyke
sn: Dyke
description: This is Roland Dyke's description
facsimileTelephoneNumber: +1 804 674-5794
l: San Francisco
ou: Human Resources
postalAddress: Human Resources$San Francisco
telephoneNumber: +1 804 831-5121
title: Supreme Human Resources Writer
userPassword: Password1
uid: DykeR
givenName: Roland
mail: DykeR@220af87272f04218bb8dd81d50fb19f5.bitwarden.com
carLicense: 4CMGOJ
departmentNumber: 2838
employeeType: Contract
homePhone: +1 804 936-4965
initials: R. D.
mobile: +1 804 592-3734
pager: +1 804 285-2962
roomNumber: 9890
dn: cn=Teirtza Kara,ou=Human Resources,dc=bitwarden,dc=com
changetype: add
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
cn: Teirtza Kara
sn: Kara
description: This is Teirtza Kara's description
facsimileTelephoneNumber: +1 206 759-2040
l: San Francisco
ou: Human Resources
postalAddress: Human Resources$San Francisco
telephoneNumber: +1 206 562-1407
title: Junior Human Resources President
userPassword: Password1
uid: KaraT
givenName: Teirtza
mail: KaraT@c2afe8b3509f4a20b2b784841685bd74.bitwarden.com
carLicense: O9GAN2
departmentNumber: 3880
employeeType: Employee
homePhone: +1 206 154-4842
initials: T. K.
mobile: +1 206 860-1835
pager: +1 206 684-1438
roomNumber: 9079
# Users - Engineering
dn: cn=Alice Chen,ou=Engineering,dc=bitwarden,dc=com
changetype: add
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
cn: Alice Chen
sn: Chen
description: Senior DevOps Engineer
l: Seattle
ou: Engineering
telephoneNumber: +1 206 555-0101
title: Senior DevOps Engineer
userPassword: Password1
uid: ChenA
givenName: Alice
mail: ChenA@bitwarden.com
employeeType: Employee
dn: cn=Bob Martinez,ou=Engineering,dc=bitwarden,dc=com
changetype: add
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
cn: Bob Martinez
sn: Martinez
description: Platform Engineer
l: Austin
ou: Engineering
telephoneNumber: +1 512 555-0102
title: Platform Engineer
userPassword: Password1
uid: MartinezB
givenName: Bob
mail: MartinezB@bitwarden.com
employeeType: Employee
dn: cn=Carol Williams,ou=Engineering,dc=bitwarden,dc=com
changetype: add
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
cn: Carol Williams
sn: Williams
description: QA Lead
l: Denver
ou: Engineering
telephoneNumber: +1 303 555-0103
title: QA Lead
userPassword: Password1
uid: WilliamsC
givenName: Carol
mail: WilliamsC@bitwarden.com
employeeType: Employee
dn: cn=David Kim,ou=Engineering,dc=bitwarden,dc=com
changetype: add
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
cn: David Kim
sn: Kim
description: QA Engineer
l: Portland
ou: Engineering
telephoneNumber: +1 503 555-0104
title: QA Engineer
userPassword: Password1
uid: KimD
givenName: David
mail: KimD@bitwarden.com
employeeType: Contractor
# Users - Marketing
dn: cn=Eva Johnson,ou=Marketing,dc=bitwarden,dc=com
changetype: add
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
cn: Eva Johnson
sn: Johnson
description: Marketing Director
l: New York
ou: Marketing
telephoneNumber: +1 212 555-0105
title: Marketing Director
userPassword: Password1
uid: JohnsonE
givenName: Eva
mail: JohnsonE@bitwarden.com
employeeType: Employee
dn: cn=Frank Lee,ou=Marketing,dc=bitwarden,dc=com
changetype: add
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
cn: Frank Lee
sn: Lee
description: Content Strategist
l: Chicago
ou: Marketing
telephoneNumber: +1 312 555-0106
title: Content Strategist
userPassword: Password1
uid: LeeF
givenName: Frank
mail: LeeF@bitwarden.com
employeeType: Employee
# ============================================================
# GROUP HIERARCHY
# ============================================================
# Structure (arrows show "contains" relationship):
#
# AllStaff
# ├── Engineering ◄────────────────┐ (CYCLE from Platform)
# │ ├── DevOps │
# │ │ └── Platform ────────┘
# │ └── QA
# ├── Marketing
# └── HR
#
# Contractors ─── DevOps (diamond: second path to Platform)
#
# TestNestA ◄──► TestNestB (simple bidirectional cycle)
#
# ============================================================
# Leaf group - Platform team (CYCLES BACK to Engineering)
dn: cn=Platform,dc=bitwarden,dc=com
changetype: add
cn: Platform
member: cn=Bob Martinez,ou=Engineering,dc=bitwarden,dc=com
member: cn=Engineering,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top
# DevOps group - contains Platform subgroup
dn: cn=DevOps,dc=bitwarden,dc=com
changetype: add
cn: DevOps
member: cn=Alice Chen,ou=Engineering,dc=bitwarden,dc=com
member: cn=Platform,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top
# QA group
dn: cn=QA,dc=bitwarden,dc=com
changetype: add
cn: QA
member: cn=Carol Williams,ou=Engineering,dc=bitwarden,dc=com
member: cn=David Kim,ou=Engineering,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top
# Engineering group - contains DevOps and QA subgroups
dn: cn=Engineering,dc=bitwarden,dc=com
changetype: add
cn: Engineering
member: cn=DevOps,dc=bitwarden,dc=com
member: cn=QA,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top
# Marketing group
dn: cn=Marketing,dc=bitwarden,dc=com
changetype: add
cn: Marketing
member: cn=Eva Johnson,ou=Marketing,dc=bitwarden,dc=com
member: cn=Frank Lee,ou=Marketing,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top
# HR group
dn: cn=HR,dc=bitwarden,dc=com
changetype: add
cn: HR
member: cn=Roland Dyke,ou=Human Resources,dc=bitwarden,dc=com
member: cn=Teirtza Kara,ou=Human Resources,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top
# AllStaff - top-level group containing all departments
dn: cn=AllStaff,dc=bitwarden,dc=com
changetype: add
cn: AllStaff
member: cn=Engineering,dc=bitwarden,dc=com
member: cn=Marketing,dc=bitwarden,dc=com
member: cn=HR,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top
# Contractors group - creates diamond pattern (second path to Platform via DevOps)
dn: cn=Contractors,dc=bitwarden,dc=com
changetype: add
cn: Contractors
member: cn=DevOps,dc=bitwarden,dc=com
member: cn=David Kim,ou=Engineering,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top
# Simple bidirectional cycle test groups (preserved from original)
dn: cn=TestNestA,dc=bitwarden,dc=com
changetype: add
cn: TestNestA
member: cn=TestNestB,dc=bitwarden,dc=com
member: cn=Roland Dyke,ou=Human Resources,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top
dn: cn=TestNestB,dc=bitwarden,dc=com
changetype: add
cn: TestNestB
member: cn=TestNestA,dc=bitwarden,dc=com
member: cn=Teirtza Kara,ou=Human Resources,dc=bitwarden,dc=com
objectclass: groupOfNames
objectclass: top

View File

@@ -1,14 +1,10 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const path = require("path");
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);
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const webpack = require("webpack");
const nodeExternals = require("webpack-node-externals");
if (process.env.NODE_ENV == null) {
process.env.NODE_ENV = "development";
@@ -28,6 +24,7 @@ const moduleRules = [
];
const plugins = [
new CleanWebpackPlugin(),
new CopyWebpackPlugin({
patterns: [{ from: "./src/locales", to: "locales" }],
}),
@@ -67,11 +64,10 @@ const config = {
output: {
filename: "[name].js",
path: path.resolve(__dirname, "build-cli"),
clean: true,
},
module: { rules: moduleRules },
plugins: plugins,
externals: [nodeExternals()],
};
export default config;
module.exports = config;

View File

@@ -1,14 +1,9 @@
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 path = require("path");
const { merge } = require("webpack-merge");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const nodeExternals = require("webpack-node-externals");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const common = {
module: {
@@ -28,7 +23,6 @@ const common = {
output: {
filename: "[name].js",
path: path.resolve(__dirname, "build"),
clean: true,
},
};
@@ -54,6 +48,7 @@ const main = {
],
},
plugins: [
new CleanWebpackPlugin(),
new CopyWebpackPlugin({
patterns: [
"./package.json",
@@ -68,4 +63,4 @@ const main = {
},
};
export default merge(common, main);
module.exports = merge(common, main);

View File

@@ -1,17 +1,10 @@
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 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");
const common = {
module: {
@@ -106,7 +99,7 @@ const renderer = {
{
loader: "sass-loader",
options: {
implementation: sass,
implementation: require("sass"),
},
},
],
@@ -145,4 +138,4 @@ const renderer = {
],
};
export default merge(common, renderer);
module.exports = merge(common, renderer);