1
0
mirror of https://github.com/bitwarden/directory-connector synced 2026-02-24 16:43:06 +00:00

Compare commits

..

30 Commits

Author SHA1 Message Date
JaredScar
3abd3f0496 Refactor secure storage implementation to use native bindings and migrate from keytar. Update .gitignore for Rust artifacts, adjust package.json for new build scripts, and modify workflows for native module compilation. Enhance state versioning to support migration of credentials from keytar to desktop_core. 2026-02-24 11:42:16 -05:00
renovate[bot]
af430157e0 [deps]: Update minimatch to v10 [SECURITY] - abandoned (#1009)
* [deps]: Update minimatch to v10 [SECURITY]

* Remove erroneous failing dependencies

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Sven <svernyi@bitwarden.com>
2026-02-23 13:10:23 -06:00
Jared
db3e7aa685 Refactor error handling in LdapDirectoryService to ensure proper unbinding and error propagation (#995) 2026-02-23 12:42:39 -05:00
Brandon Treston
9a2168c1d7 add lint workflow (#1006) 2026-02-19 11:43:39 -05:00
renovate[bot]
1fd8bf318f [deps]: Update webpack to v5.105.1 (#999)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 11:49:12 -05:00
renovate[bot]
c472d5e199 [deps]: Lock file maintenance (#1001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jared <TheWolfBadger@gmail.com>
2026-02-17 11:04:46 -05:00
renovate[bot]
1a42e76c79 [deps]: Update eslint-plugin-rxjs-x to v0.9.1 (#998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 15:47:22 +00:00
Mick Letofsky
1aad9e1cbe Slim down and align with our current practices (#994) 2026-02-11 15:58:42 +01:00
Brandon Treston
3059934d4c remove substitute (#992) 2026-02-10 09:41:26 -05:00
Vincent Salucci
42cf13df08 chore: bump version to 2026.2.0 (#993) 2026-02-09 14:11:35 -06:00
renovate[bot]
1a9f0a2ca7 [deps]: Update babel-loader to v10 (#987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-06 15:08:41 +00:00
renovate[bot]
30b3595de3 [deps]: Update typescript-eslint monorepo to v8.54.0 (#976)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-06 14:34:42 +00:00
renovate[bot]
28f0ff4b24 [deps]: Update angular-cli monorepo to v21.1.2 (#982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-05 10:11:28 -06:00
renovate[bot]
14fc69c810 [deps]: Update ngx-toastr to v20 (#989)
* [deps]: Update ngx-toastr to v20

* Adjust to toastr v20

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Sven <svernyi@bitwarden.com>
2026-02-04 13:44:40 -06:00
renovate[bot]
1ad0aea61f [deps]: Update prettier to v3.8.1 (#985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 13:27:17 -05:00
renovate[bot]
f41156969c [deps]: Update angular monorepo (#981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jared <TheWolfBadger@gmail.com>
2026-02-04 13:20:42 -05:00
renovate[bot]
39b151b1e0 [deps]: Update mini-css-extract-plugin to v2.10.0 (#984)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 13:05:34 -05:00
renovate[bot]
483f26fa6f [deps]: Update type-fest to v5.4.2 (#986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 11:39:06 -05:00
renovate[bot]
8849385d1b [deps]: Update @angular/cdk to v21.1.1 (#980)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 09:41:55 -05:00
renovate[bot]
a7aff97360 [deps]: Lock file maintenance (#978)
* [deps]: Lock file maintenance

* add COEP and COOP headers to enabled SharedArrayBuffer

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Brandon <btreston@bitwarden.com>
2026-02-02 11:49:42 -05:00
renovate[bot]
7381857296 [deps]: Update gh minor (#973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 14:19:11 -05:00
renovate[bot]
ba17d5b438 [deps]: Update electron-updater to v6.7.3 (#974)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 11:06:25 -06:00
Daniel James Smith
b5d31e693b Replace deprecated codecov/test-results-action with codecov/codecov-action with report_type set to test_results (#979)
https://github.com/codecov/test-results-action?tab=readme-ov-file#%EF%B8%8F-deprecation-warning-%EF%B8%8F

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
2026-01-23 08:06:18 -06:00
Brandon Treston
2854a2eba1 Update Angular to v21 (#972)
* update jest to v.30.2.0

* ng update 21 wip

* update @angular/cdk@21

* NG 21 WIP

* @ngtools-webpack@21 and jest-preset-angular@16

* updated jest, add babel & jest-enveironment-jsdom

* add missing polyfils for TextEncoder & TextDecoder

* cleanup lock file

* tsconfig cleanup

* fix import

* cleanup

* clean up
2026-01-21 08:37:52 +10:00
renovate[bot]
4485ecab3c [deps]: Update ldapts to v8.1.3 (#975)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 11:44:49 +10:00
renovate[bot]
9e3b2d2d95 [deps]: Update jest-mock-extended to v4 (#977)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 09:42:05 -05:00
renovate[bot]
b2997358dc [deps]: Lock file maintenance (#834)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 08:07:48 +10:00
renovate[bot]
db258f0191 [deps]: Update @angular/compiler to v20.3.16 [SECURITY] (#967)
* [deps]: Update @angular/compiler to v20.3.16 [SECURITY]

* Upgrade all Angular packages

* Downgrade jest-mock-extended to support Jest 29

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
2026-01-14 07:36:46 +10:00
Vincent Salucci
19d7884933 chore: bump version to 2026.1.0 (#969) 2026-01-12 11:32:43 -06:00
Jared McCannon
21ce02f431 [PM-26889] - Typescript 5.9 upgrade with updates (#965)
* [deps]: Update typescript to v5.9.3

* Updated return types.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 10:07:27 -06:00
59 changed files with 9268 additions and 9276 deletions

View File

@@ -1,706 +1,203 @@
# Bitwarden Directory Connector - Claude Code Configuration
# Bitwarden Directory Connector
Sync users and groups from enterprise directory services (LDAP, Entra ID, Google Workspace, Okta, OneLogin) to Bitwarden organizations. Available as both a desktop GUI (Electron + Angular) and a CLI tool (`bwdc`).
## Project Overview
## Overview
Directory Connector is a TypeScript application that synchronizes users and groups from directory services to Bitwarden organizations. It provides both a desktop GUI (built with Angular and Electron) and a CLI tool (bwdc).
### What This Project Does
**Supported Directory Services:**
- Connects to enterprise identity providers and retrieves user/group membership data
- Syncs that data to Bitwarden organizations via the Directory Connector API
- Provides both a desktop GUI application (Electron) and a command-line interface (`bwdc`)
- LDAP (Lightweight Directory Access Protocol) - includes Active Directory and general LDAP servers
- Microsoft Entra ID (formerly Azure Active Directory)
- Google Workspace
- Okta
- OneLogin
### Key Concepts
**Technologies:**
- **Directory Service**: An identity provider (LDAP, Entra ID, GSuite, Okta, OneLogin) that stores users and groups
- **Sync**: The process of fetching entries from a directory and importing them to Bitwarden
- **Delta Sync**: Incremental synchronization that only fetches changes since the last sync
- **Entry**: Base class for `UserEntry` and `GroupEntry` - the core data models
- **Force Sync**: Ignores delta tokens and fetches all entries fresh
- **Test Mode**: Simulates sync without making API calls or updating state
- TypeScript
- Angular (GUI)
- Electron (Desktop wrapper)
- Node
- Jest for testing
---
## Code Architecture & Structure
## Architecture & Patterns
### System Architecture
### Directory Organization
```
User Request (GUI/CLI)
┌───────────────────────────────────┐
Entry Points │
│ main.ts (GUI) │ bwdc.ts (CLI) │
└───────────────────────────────────┘
┌───────────────────────────────────┐
│ SyncService │
│ Orchestrates the sync flow │
└───────────────────────────────────┘
┌───────────────────────────────────┐
│ DirectoryFactoryService │
│ Creates appropriate IDirectory │
└───────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Directory Services │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐ │
│ │ LDAP │ │ EntraID │ │ GSuite │ │ Okta/1Login │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────┘
┌───────────────────────────────────┐
│ [GroupEntry[], UserEntry[]]│
└───────────────────────────────────┘
┌───────────────────────────────────┐
│ RequestBuilder (Batched) │
│ SingleRequestBuilder (<2000) │
│ BatchRequestBuilder (>2000) │
└───────────────────────────────────┘
┌───────────────────────────────────┐
│ Bitwarden API │
│ POST /import endpoint │
└───────────────────────────────────┘
src/
├── abstractions/ # Interface definitions (e.g., IDirectoryService)
├── services/ # Business logic implementations for directory services, sync, auth
├── models/ # Data models (UserEntry, GroupEntry, etc.)
├── commands/ # CLI command implementations
├── app/ # Angular GUI components
└── utils/ # Test utilities and fixtures
src-cli/ # CLI-specific code (imports common code from src/)
jslib/ # Legacy folder structure (mix of deprecated/unused and current code - new code should not be added here)
```
### Key Architectural Patterns
1. **Abstractions = Interfaces**: All interfaces are defined in `/abstractions`
2. **Services = Business Logic**: Implementations live in `/services`
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
## Development Conventions
### Code Organization
```
src/
├── abstractions/ # Interface definitions (IDirectoryService, etc.)
├── app/ # Angular GUI components
│ ├── tabs/ # Tab-based navigation (Dashboard, Settings, More)
│ └── services/ # Angular service providers
├── commands/ # CLI command implementations
├── enums/ # TypeScript enums (DirectoryType, etc.)
├── models/ # Data models (Entry, UserEntry, GroupEntry)
├── services/ # Business logic implementations
│ └── directory-services/ # One service per directory provider
├── bwdc.ts # CLI entry point
├── main.ts # Electron main process entry point
└── program.ts # CLI command routing (Commander.js)
**File Naming:**
jslib/ # Legacy shared libraries (do not add new code here)
utils/ # Integration test fixtures
└── openldap/ # Docker configs, test data, certificates
```
- kebab-case for files: `ldap-directory.service.ts`
- Descriptive names that reflect purpose
### Key Principles
**Class/Function Naming:**
1. **Shared Service Layer**: GUI (Angular) and CLI share identical service implementations
2. **Factory Pattern**: `DirectoryFactoryService` instantiates the correct `IDirectoryService` based on `DirectoryType`
3. **Secure Storage**: Credentials stored in system keychain via `KeytarSecureStorageService`
4. **Delta Tracking**: Incremental sync via delta tokens to minimize API calls
- PascalCase for classes and interfaces
- camelCase for functions and variables
- Descriptive names that indicate purpose
### Core Patterns
**File Structure:**
#### Directory Service Pattern
- Keep files focused on single responsibility
- Create new service files for distinct directory integrations
- Separate models into individual files when complex
**Purpose**: Abstract different identity providers behind a common interface
### TypeScript Conventions
**Interface** (`src/abstractions/directory.service.ts`):
**Import Patterns:**
```typescript
export interface IDirectoryService {
getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]>;
}
```
- Use path aliases (`@/`) for project imports
- `@/` - project root
- `@/jslib/` - jslib folder
- ESLint enforces alphabetized import ordering with newlines between groups
**Implementations** in `src/services/directory-services/`:
**Type Safety:**
- `ldap-directory.service.ts` - LDAP/Active Directory
- `entra-id-directory.service.ts` - Microsoft Entra ID (Azure AD)
- `gsuite-directory.service.ts` - Google Workspace
- `okta-directory.service.ts` - Okta
- `onelogin-directory.service.ts` - OneLogin
- Avoid `any` types - use proper typing or `unknown` with type guards
- Prefer interfaces for contracts, types for unions/intersections
- Use strict null checks - handle `null` and `undefined` explicitly
- Leverage TypeScript's type inference where appropriate
**Factory** (`src/services/directory-factory.service.ts`):
**Configuration:**
```typescript
createService(type: DirectoryType): IDirectoryService
```
- Use configuration files or environment variables
- Never hardcode URLs or configuration values
#### State Service Pattern
## Security Best Practices
**Purpose**: Manage persistent state and credential storage
**Credential Handling:**
**Implementation** (`src/services/state.service.ts`):
- Never log directory service credentials, API keys, or tokens
- Use secure storage mechanisms for sensitive data
- Credentials should never be hardcoded
- Store credentials encrypted, never in plain text
- Configuration and sync settings stored in LowDB (JSON file)
- Sensitive data (passwords, API keys) stored in system keychain
- File locking via `proper-lockfile` to prevent concurrent access corruption
- Platform-specific app data directories:
- macOS: `~/Library/Application Support/Bitwarden Directory Connector`
- Windows: `%APPDATA%/Bitwarden Directory Connector`
- Linux: `~/.config/Bitwarden Directory Connector` or `$XDG_CONFIG_HOME`
**Sensitive Data:**
---
- User and group data from directories should be handled securely
- Avoid exposing sensitive information in error messages
- Sanitize data before logging
- Be cautious with data persistence
## Development Guide
**Input Validation:**
### Adding a New Directory Service
- Validate and sanitize data from external directory services
- Check for injection vulnerabilities (LDAP injection, etc.)
- Validate configuration inputs from users
**1. Create the enum value** (`src/enums/directoryType.ts`)
**API Security:**
```typescript
export enum DirectoryType {
Ldap = 0,
EntraID = 1,
GSuite = 2,
Okta = 3,
OneLogin = 4,
NewProvider = 5, // Add here
}
```
- Ensure authentication flows are implemented correctly
- Verify SSL/TLS is used for all external connections
- Check for secure token storage and refresh mechanisms
**2. Create the configuration model** (`src/models/newProviderConfiguration.ts`)
## Error Handling
```typescript
export class NewProviderConfiguration {
apiUrl: string;
apiToken: string;
// Provider-specific settings
}
```
**Best Practices:**
**3. Implement the directory service** (`src/services/directory-services/newprovider-directory.service.ts`)
1. **Try-catch for async operations** - Always wrap external API calls
2. **Meaningful error messages** - Provide context for debugging
3. **Error propagation** - Don't swallow errors silently
4. **User-facing errors** - Separate user messages from developer logs
```typescript
import { IDirectoryService } from "@/src/abstractions/directory.service";
import { GroupEntry } from "@/src/models/groupEntry";
import { UserEntry } from "@/src/models/userEntry";
import { BaseDirectoryService } from "./base-directory.service";
## Performance Best Practices
export class NewProviderDirectoryService extends BaseDirectoryService implements IDirectoryService {
constructor(
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
) {
super();
}
**Large Dataset Handling:**
async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {
const config = await this.stateService.getDirectory<NewProviderConfiguration>(
DirectoryType.NewProvider,
);
const syncConfig = await this.stateService.getSync();
- Use pagination for large user/group lists
- Avoid loading entire datasets into memory at once
- Consider streaming or batch processing for large operations
const groups: GroupEntry[] = [];
const users: UserEntry[] = [];
**API Rate Limiting:**
// Fetch from provider API
// Apply filters using inherited filter methods
- Respect rate limits for Microsoft Graph API, Google Admin SDK, etc.
- Consider batching large API calls where necessary
return [groups, users];
}
}
```
**Memory Management:**
**4. Register in the factory** (`src/services/directory-factory.service.ts`)
```typescript
case DirectoryType.NewProvider:
return new NewProviderDirectoryService(
this.logService,
this.i18nService,
this.stateService
);
```
**5. Add state service support** (`src/services/state.service.ts`)
```typescript
// Add to secure storage keys if credentials involved
// Add configuration getter/setter methods
```
**6. Write tests** (`src/services/directory-services/newprovider-directory.service.spec.ts`)
### Common Patterns
#### Error Handling with State Rollback
```typescript
async sync(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {
// Store initial state for rollback
const startingUserDelta = await this.stateService.getUserDelta();
const startingGroupDelta = await this.stateService.getGroupDelta();
try {
// Perform sync operations
const [groups, users] = await this.directoryService.getEntries(force, test);
// ... process and submit
return [groups, users];
} catch (e) {
if (!test) {
// Rollback deltas on failure
await this.stateService.setUserDelta(startingUserDelta);
await this.stateService.setGroupDelta(startingGroupDelta);
}
this.messagingService.send("dirSyncCompleted", { successfully: false });
throw e;
}
}
```
#### Filter Processing
```typescript
// In BaseDirectoryService
protected buildIncludeSet(filter: string): Set<string> {
// Parse filter like "include:user1@example.com,user2@example.com"
}
protected buildExcludeSet(filter: string): Set<string> {
// Parse filter like "exclude:user1@example.com"
}
protected shouldIncludeUser(user: UserEntry, include: Set<string>, exclude: Set<string>): boolean {
if (exclude.has(user.email)) return false;
if (include.size === 0) return true;
return include.has(user.email);
}
```
### Running the Desktop GUI (Development)
```bash
npm install
npm run rebuild # Rebuild native modules (keytar)
npm run electron # Run GUI with hot reload
```
### Running the CLI (Development)
```bash
npm install
npm run build:cli:watch # Build CLI with watch mode
node ./build-cli/bwdc.js --help # Run CLI commands
```
---
## Data Models
### Core Types
```typescript
// Base entry class (src/models/entry.ts)
abstract class Entry {
referenceId: string; // Unique ID within the directory (e.g., DN for LDAP)
externalId: string; // ID used for Bitwarden import
}
// User entry (src/models/userEntry.ts)
class UserEntry extends Entry {
email: string;
disabled: boolean;
deleted: boolean;
}
// Group entry (src/models/groupEntry.ts)
class GroupEntry extends Entry {
name: string;
userMemberExternalIds: Set<string>; // External IDs of member users
groupMemberReferenceIds: Set<string>; // Reference IDs of nested groups
users: UserEntry[]; // Populated for display/simulation
}
```
### Directory Type Enum
```typescript
// src/enums/directoryType.ts
enum DirectoryType {
Ldap = 0,
EntraID = 1,
GSuite = 2,
Okta = 3,
OneLogin = 4,
}
```
### Configuration Models
Each directory provider has a configuration class in `src/models/`:
- `LdapConfiguration` - hostname, port, SSL/TLS, bind credentials, auth mode
- `EntraIdConfiguration` - tenant, client ID, secret key
- `GSuiteConfiguration` - domain, admin user, client email, private key
- `OktaConfiguration` - organization URL, API token
- `OneLoginConfiguration` - client ID, client secret, region
### Sync Configuration
```typescript
// src/models/syncConfiguration.ts
interface SyncConfiguration {
users: boolean; // Sync users
groups: boolean; // Sync groups
interval: number; // Minutes between syncs (minimum 5)
userFilter: string; // Include/exclude filter
groupFilter: string; // Include/exclude filter
removeDisabled: boolean; // Remove disabled users from org
overwriteExisting: boolean; // Overwrite existing entries
largeImport: boolean; // Enable for >2000 entries
// LDAP-specific
groupObjectClass: string;
userObjectClass: string;
groupPath: string;
userPath: string;
// ... additional LDAP attributes
}
```
---
## Security & Configuration
### Security Rules
**MANDATORY - These rules have no exceptions:**
1. **Never log credentials**: API keys, passwords, tokens, and secrets must never appear in logs
2. **Never hardcode secrets**: All URLs, credentials, and sensitive data must come from configuration
3. **Use KeytarSecureStorageService**: All credentials must be stored in the system keychain
4. **Validate external data**: Sanitize all data received from directory services
5. **LDAP injection prevention**: Be cautious with user-provided LDAP filters
### Secure Storage Keys
The following are stored in the system keychain (not plain JSON):
- `ldapPassword` - LDAP bind password
- `gsuitePrivateKey` - Google Workspace private key
- `entraKey` - Microsoft Entra ID client secret
- `oktaToken` - Okta API token
- `oneLoginClientSecret` - OneLogin client secret
- User/group delta tokens
- Sync hashes
### Environment Variables
| Variable | Required | Description | Example |
| ------------------------------------------ | -------- | ---------------------------------------- | -------------------- |
| `BITWARDENCLI_CONNECTOR_APPDATA_DIR` | No | CLI app data directory override | `/custom/path` |
| `BITWARDEN_CONNECTOR_APPDATA_DIR` | No | GUI app data directory override | `/custom/path` |
| `BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS` | No | Store secrets in plain text (debug only) | `true` |
| `BITWARDENCLI_CONNECTOR_DEBUG` | No | Enable debug logging | `true` |
| `BW_CLIENTID` | No | CLI login client ID | `organization.xxxxx` |
| `BW_CLIENTSECRET` | No | CLI login client secret | `xxxxx` |
| `BW_NOINTERACTION` | No | Disable interactive prompts | `true` |
| `BW_PRETTY` | No | Pretty-print JSON output | `true` |
| `BW_RAW` | No | Raw output (no formatting) | `true` |
| `BW_RESPONSE` | No | JSON response format | `true` |
| `BW_QUIET` | No | Suppress stdout | `true` |
### Authentication & Authorization
- **API Token Authentication**: Uses organization `clientId` + `clientSecret`
- **Token Storage**: Access tokens and refresh tokens stored securely via Keytar
- **Token Refresh**: Automatic refresh when access token expires
- **Auth Service**: `src/services/auth.service.ts` handles the authentication flow
---
- Close connections and clean up resources
- Remove event listeners when components are destroyed
- Be cautious with caching large datasets
## Testing
### Test Structure
**Framework:**
```
src/
├── services/
│ ├── sync.service.spec.ts # Unit tests (colocated)
│ ├── sync.service.integration.spec.ts # Integration tests
│ └── directory-services/
│ ├── ldap-directory.service.spec.ts
│ └── ldap-directory.service.integration.spec.ts
utils/
└── openldap/
├── config-fixtures.ts # Test configuration helpers
├── user-fixtures.ts # Expected user data
├── group-fixtures.ts # Expected group data
├── certs/ # TLS certificates
└── docker-compose.yml # LDAP container config
```
- Jest with jest-preset-angular
- jest-mock-extended for type-safe mocks with `mock<Type>()`
### Writing Tests
**Test Organization:**
**Unit Test Template**:
- Tests colocated with source files
- `*.spec.ts` - Unit tests for individual components/services
- `*.integration.spec.ts` - Integration tests against live directory services
- Test helpers located in `utils/` directory
```typescript
import { mock, MockProxy } from "jest-mock-extended";
**Test Naming:**
describe("ServiceName", () => {
let logService: MockProxy<LogService>;
let stateService: MockProxy<StateService>;
let service: ServiceUnderTest;
- Descriptive, human-readable test names
- Example: `'should return empty array when no users exist in directory'`
beforeEach(() => {
logService = mock();
stateService = mock();
service = new ServiceUnderTest(logService, stateService);
});
**Test Coverage:**
it("should do something", async () => {
// Arrange
stateService.getSomeValue.mockResolvedValue(expectedValue);
- New features must include tests
- Bug fixes should include regression tests
- Changes to core sync logic or directory specific logic require integration tests
// Act
const result = await service.doSomething();
**Testing Approach:**
// Assert
expect(result).toEqual(expectedResult);
});
});
```
- **Unit tests**: Mock external API calls using jest-mock-extended
- **Integration tests**: Use live directory services (Docker containers or configured cloud services)
- Focus on critical paths (authentication, sync, data transformation)
- Test error scenarios and edge cases (empty results, malformed data, connection failures), not just happy paths
**Integration Test Template** (see `ldap-directory.service.integration.spec.ts`):
## Directory Service Patterns
```typescript
// Requires Docker containers running
// npm run test:integration:setup
### IDirectoryService Interface
describe("ldapDirectoryService", () => {
let stateService: MockProxy<StateService>;
let directoryService: LdapDirectoryService;
All directory services implement this core interface with methods:
beforeEach(() => {
stateService = mock();
stateService.getDirectoryType.mockResolvedValue(DirectoryType.Ldap);
stateService.getDirectory
.calledWith(DirectoryType.Ldap)
.mockResolvedValue(getLdapConfiguration());
});
- `getUsers()` - Retrieve users from directory and transform them into standard objects
- `getGroups()` - Retrieve groups from directory and transform them into standard objects
- Connection and authentication handling
it("syncs users and groups", async () => {
const result = await directoryService.getEntries(true, true);
expect(result).toEqual([groupFixtures, userFixtures]);
});
});
```
### Service-Specific Implementations
### Running Tests
Each directory service has unique authentication and query patterns:
```bash
npm test # All unit tests (excludes integration)
npm test -- path/to/file.spec.ts # Single test file
npm run test:watch # Watch mode
# Integration tests
npm run test:integration:setup # Start Docker containers
npm run test:integration # Run integration tests
npm run test:integration:watch # Watch mode for integration
```
### Test Environment
- **Mocking**: `jest-mock-extended` with `mock<Type>()` for type-safe mocks
- **Alternative**: `@fluffy-spoon/substitute` available for some tests
- **Integration**: Docker containers for LDAP (OpenLDAP)
- **Fixtures**: Located in `utils/openldap/`
---
## Code Style & Standards
### Formatting
- **Prettier**: Auto-formatting enforced via pre-commit hooks
- **Config**: `.prettierrc` in project root
### Naming Conventions
- `camelCase` for: variables, functions, method names
- `PascalCase` for: classes, interfaces, types, enums
- `SCREAMING_SNAKE_CASE` for: constants (rare in this codebase)
### Imports
**Path Aliases:**
- `@/` maps to project root
- Example: `import { SyncService } from "@/src/services/sync.service"`
**Import Order (ESLint enforced):**
1. External packages (node_modules)
2. jslib imports (`@/jslib/...`)
3. Project imports (`@/src/...`)
4. Alphabetized within each group with newlines between groups
```typescript
// External
import { mock, MockProxy } from "jest-mock-extended";
// jslib
import { LogService } from "@/jslib/common/src/abstractions/log.service";
// Project
import { DirectoryType } from "@/src/enums/directoryType";
import { SyncService } from "@/src/services/sync.service";
```
### Comments
- Avoid unnecessary comments; code should be self-documenting
- Use JSDoc only for public APIs that need documentation
- Inline comments for complex logic only
### Pre-commit Hooks
- **Husky**: Runs `lint-staged` on commit
- **lint-staged**: Runs Prettier on all files, ESLint on TypeScript files
```bash
npm run lint # Check ESLint + Prettier
npm run lint:fix # Auto-fix ESLint issues
npm run prettier # Auto-format with Prettier
npm run test:types # TypeScript type checking
```
---
## Anti-Patterns
### DO
- ✅ Use `KeytarSecureStorageService` for all credential storage
- ✅ Implement `IDirectoryService` interface for new directory providers
- ✅ Use the factory pattern via `DirectoryFactoryService`
- ✅ Write unit tests with `jest-mock-extended` mocks
- ✅ Handle errors with state rollback (delta tokens)
- ✅ Use path aliases (`@/src/...`) for imports
- ✅ Validate data from external directory services
- ✅ Use `force` and `test` parameters consistently in sync methods
### DON'T
- ❌ Log credentials, API keys, or tokens
- ❌ Hardcode URLs, secrets, or configuration values
- ❌ Store sensitive data in LowDB (JSON) - use Keytar
- ❌ Skip input validation for LDAP filters (injection risk)
- ❌ Use `any` types without explicit justification
- ❌ Add new code to `jslib/` (legacy, read-only)
- ❌ Ignore delta token rollback on sync failure
- ❌ Bypass `overwriteExisting` validation for batch imports (>2000 entries)
---
## Deployment
### Building
**Desktop GUI (Electron):**
```bash
npm run build # Build main + renderer
npm run build:dist # Full distribution build
npm run dist:win # Windows installer
npm run dist:mac # macOS installer
npm run dist:lin # Linux packages (AppImage, RPM)
```
**CLI Tool:**
```bash
npm run build:cli:prod # Production build
npm run dist:cli:win # Windows executable
npm run dist:cli:mac # macOS executable
npm run dist:cli:lin # Linux executable
```
### Versioning
Follow semantic versioning: `MAJOR.MINOR.PATCH`
- Version format: `YYYY.MM.PATCH` (e.g., `2025.12.0`)
- Managed in `package.json`
### Publishing
- **CI/CD**: GitHub Actions workflows in `.github/workflows/`
- **build.yml**: Multi-platform builds with code signing
- **release.yml**: Version bumping and publishing
- **Code Signing**: Azure Key Vault (Windows), App Store Connect (macOS)
- **Auto-update**: Electron Updater for GUI application
---
## Troubleshooting
### Common Issues
#### LDAP Connection Failures
**Problem**: Cannot connect to LDAP server, timeout or connection refused
**Solution**:
1. Verify hostname and port are correct
2. Check SSL/TLS settings match server configuration
3. For StartTLS, ensure SSL is enabled and use the non-secure port (389)
4. For LDAPS, use port 636 and provide CA certificate path
#### Keytar/Native Module Issues
**Problem**: `Error: Module did not self-register` or keytar-related crashes
**Solution**:
```bash
npm run rebuild # Rebuild native modules for current Electron version
npm run reset # Full reset of keytar module
```
#### Sync Hash Mismatch
**Problem**: Sync runs but no changes appear in Bitwarden
**Solution**: The sync service skips if the hash matches the previous sync. Use force sync:
```bash
bwdc sync --force # CLI
# Or clear cache
bwdc clear-cache
```
#### Large Import Failures
**Problem**: Sync fails for organizations with >2000 users/groups
**Solution**: Enable `largeImport` in sync settings. Note: `overwriteExisting` is incompatible with batch mode.
### Debug Tips
- Enable debug logging: `BITWARDENCLI_CONNECTOR_DEBUG=true`
- View data file location: `bwdc data-file`
- Test sync without making changes: `bwdc test`
- Check last sync times: `bwdc last-sync users` / `bwdc last-sync groups`
---
- **LDAP**: Direct LDAP queries, bind authentication
- **Microsoft Entra ID**: Microsoft Graph API, OAuth tokens
- **Google Workspace**: Google Admin SDK, service account credentials
- **Okta/OneLogin**: REST APIs with API tokens
## References
### Official Documentation
- [Directory Sync CLI Documentation](https://bitwarden.com/help/directory-sync-cli/)
- [Directory Connector Help](https://bitwarden.com/help/directory-sync/)
### Internal Documentation
- [Bitwarden Contributing Guidelines](https://contributing.bitwarden.com/contributing/)
- [Code Style Guide](https://contributing.bitwarden.com/contributing/code-style/)
### Tools & Libraries
- [ldapts](https://github.com/ldapts/ldapts) - LDAP client for Node.js
- [Keytar](https://github.com/atom/node-keytar) - Native keychain access
- [Commander.js](https://github.com/tj/commander.js) - CLI framework
- [LowDB](https://github.com/typicode/lowdb) - JSON database
- [Microsoft Graph Client](https://github.com/microsoftgraph/msgraph-sdk-javascript) - Entra ID API
- [Google APIs](https://github.com/googleapis/google-api-nodejs-client) - GSuite API
- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/)
- [Contributing Guidelines](https://contributing.bitwarden.com/contributing/)
- [Code Style](https://contributing.bitwarden.com/contributing/code-style/)
- [Security Whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/)
- [Security Definitions](https://contributing.bitwarden.com/architecture/security/definitions)

View File

@@ -1,30 +0,0 @@
---
description: "Provides a brief explanation of the code attached, including key components, notable patterns, and a code walkthrough."
---
# Code Explainer
Provide a brief explanation of the code attached. I'm trying to better understand it.
## Key Components
- Main classes/functions and their roles
- Important dependencies
- Critical flows
## Notable Patterns
- Design patterns used
- Architecture decisions
- Important abstractions
## Code Walkthrough
- How it works
- Key decision points
- Important considerations
## Gotchas & Tips
- Edge cases to watch for
- Performance considerations

View File

@@ -9,26 +9,3 @@
## 📸 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -51,42 +51,36 @@ jobs:
contents: read
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
- name: Update NPM
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up system dependencies
run: |
npm install -g node-gyp
node-gyp install "$(node -v)"
- name: Keytar
run: |
keytarVersion=$(cat package.json | jq -r '.dependencies.keytar')
keytarTar="keytar-v$keytarVersion-napi-v3-linux-x64.tar"
keytarTarGz="$keytarTar.gz"
keytarUrl="https://github.com/atom/node-keytar/releases/download/v$keytarVersion/$keytarTarGz"
mkdir -p ./keytar/linux
wget "$keytarUrl" -O "./keytar/linux/$keytarTarGz"
tar -xvf "./keytar/linux/$keytarTarGz" -C ./keytar/linux
sudo apt-get update
sudo apt-get -y install libdbus-1-dev libsecret-1-dev pkg-config
- name: Install
run: npm install
- name: Build native module
run: npm run build:native:release
- name: Package CLI
run: npm run dist:cli:lin
- name: Zip
run: zip -j "dist-cli/bwdc-linux-$_PACKAGE_VERSION.zip" "dist-cli/linux/bwdc" "keytar/linux/build/Release/keytar.node"
run: zip -j "dist-cli/bwdc-linux-$_PACKAGE_VERSION.zip" "dist-cli/linux/bwdc" "node_modules/dc-native/dc_native.linux-x64-gnu.node"
- name: Version Test
run: |
@@ -129,42 +123,31 @@ jobs:
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install "$(node -v)"
- name: Keytar
run: |
keytarVersion=$(cat package.json | jq -r '.dependencies.keytar')
keytarTar="keytar-v$keytarVersion-napi-v3-darwin-x64.tar"
keytarTarGz="$keytarTar.gz"
keytarUrl="https://github.com/atom/node-keytar/releases/download/v$keytarVersion/$keytarTarGz"
mkdir -p ./keytar/macos
wget "$keytarUrl" -O "./keytar/macos/$keytarTarGz"
tar -xvf "./keytar/macos/$keytarTarGz" -C ./keytar/macos
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Install
run: npm install
- name: Build native module
run: npm run build:native:release
- name: Package CLI
run: npm run dist:cli:mac
- name: Zip
run: zip -j "dist-cli/bwdc-macos-$_PACKAGE_VERSION.zip" "dist-cli/macos/bwdc" "keytar/macos/build/Release/keytar.node"
run: zip -j "dist-cli/bwdc-macos-$_PACKAGE_VERSION.zip" "dist-cli/macos/bwdc" "node_modules/dc-native/dc_native.darwin-x64.node"
- name: Version Test
run: |
@@ -200,7 +183,7 @@ jobs:
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -209,42 +192,29 @@ jobs:
choco install checksum --no-progress
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install $(node -v)
- name: Keytar
shell: pwsh
run: |
$keytarVersion = (Get-Content -Raw -Path ./package.json | ConvertFrom-Json).dependencies.keytar
$keytarTar = "keytar-v${keytarVersion}-napi-v3-{0}-x64.tar"
$keytarTarGz = "${keytarTar}.gz"
$keytarUrl = "https://github.com/atom/node-keytar/releases/download/v${keytarVersion}/${keytarTarGz}"
New-Item -ItemType directory -Path ./keytar/windows | Out-Null
Invoke-RestMethod -Uri $($keytarUrl -f "win32") -OutFile "./keytar/windows/$($keytarTarGz -f "win32")"
7z e "./keytar/windows/$($keytarTarGz -f "win32")" -o"./keytar/windows"
7z e "./keytar/windows/$($keytarTar -f "win32")" -o"./keytar/windows"
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Install
run: npm install
- name: Build native module
run: npm run build:native:release
- name: Package CLI
run: npm run dist:cli:win
- name: Zip
shell: cmd
run: 7z a .\dist-cli\bwdc-windows-%_PACKAGE_VERSION%.zip .\dist-cli\windows\bwdc.exe .\keytar\windows\keytar.node
run: 7z a .\dist-cli\bwdc-windows-%_PACKAGE_VERSION%.zip .\dist-cli\windows\bwdc.exe .\node_modules\dc-native\dc_native.win32-x64-msvc.node
- name: Version Test
shell: pwsh
@@ -279,21 +249,21 @@ jobs:
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install $(node -v)
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Print environment
run: |
@@ -379,26 +349,24 @@ jobs:
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install "$(node -v)"
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up environment
run: |
sudo apt-get update
sudo apt-get -y install pkg-config libxss-dev libsecret-1-dev
sudo apt-get -y install pkg-config libxss-dev libsecret-1-dev libdbus-1-dev
sudo apt-get -y install rpm
- name: NPM Install
@@ -439,21 +407,19 @@ jobs:
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install "$(node -v)"
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Print environment
run: |

View File

@@ -40,7 +40,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -52,7 +52,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.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@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.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,4 +143,6 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- name: Upload results to codecov.io
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
report_type: test_results

46
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -34,7 +34,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.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@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.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,4 +67,6 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- name: Upload results to codecov.io
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
report_type: test_results

View File

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

4
.gitignore vendored
View File

@@ -32,6 +32,10 @@ build
build-cli
.angular/cache
# Rust build artifacts
native/target
native/*.node
# Testing
coverage*
junit.xml*

2
.nvmrc
View File

@@ -1 +1 @@
v20
v22

View File

@@ -1,156 +0,0 @@
# ESM Migration Plan
## Migration Status: Partial Success
The ESM migration has been **partially completed**. The source code is now ESM-compatible with `"type": "module"` in package.json, and webpack outputs CommonJS bundles (`.cjs`) for Node.js compatibility.
### What Works
- ✅ CLI build (`bwdc.cjs`) - builds and runs successfully
- ✅ Electron main process (`main.cjs`) - builds successfully
- ✅ All 130 tests pass
- ✅ Source code uses ESM syntax (import/export)
### What Doesn't Work
- ❌ Electron renderer build - **pre-existing type errors in jslib** (not caused by this migration)
The renderer build was failing with 37 TypeScript errors in `jslib/` **before** the ESM migration began. These are ArrayBuffer/SharedArrayBuffer type compatibility issues in the jslib submodule that need to be addressed separately.
---
## Changes Made
### 1. package.json
```json
{
"type": "module",
"main": "main.cjs"
}
```
### 2. tsconfig.json
```json
{
"compilerOptions": {
"moduleResolution": "node",
"module": "ES2020",
"skipLibCheck": true,
"noEmitOnError": false
}
}
```
### 3. Webpack Configurations
**CLI (webpack.cli.cjs)**
- Output changed to `.cjs` extension
- Added `transpileOnly: true` to ts-loader for faster builds
**Main (webpack.main.cjs)**
- Output changed to `.cjs` extension
- Added `transpileOnly: true` to ts-loader
**Renderer (webpack.renderer.cjs)**
- Created separate `tsconfig.renderer.json` to isolate Angular compilation
- Removed ESM output experiments (not compatible with Angular's webpack plugin)
### 4. src-cli/package.json
```json
{
"type": "module",
"bin": {
"bwdc": "../build-cli/bwdc.cjs"
}
}
```
### 5. New File: tsconfig.renderer.json
Dedicated TypeScript config for Angular renderer to isolate from jslib type issues.
---
## Architecture Decision
### Why CJS Output Instead of ESM Output?
The migration uses a **hybrid approach**:
- **Source code**: ESM syntax (`import`/`export`)
- **Build output**: CommonJS (`.cjs` files)
This approach was chosen because:
1. **lowdb v1 incompatibility**: The legacy lowdb v1 used in jslib doesn't work properly with ESM output due to lodash interop issues
2. **Native module compatibility**: keytar and other native modules work better with CJS
3. **Electron compatibility**: Electron's main process ESM support is still maturing
4. **jslib constraints**: The jslib submodule is read-only and contains CJS-only patterns
The webpack bundler transpiles ESM source to CJS output, giving us modern syntax in the codebase while maintaining runtime compatibility.
---
## Blocking Issues for Full ESM
### 1. jslib Submodule (Read-Only)
The jslib folder contains:
- `lowdb` v1.0.0 usage (CJS-only, v7 is ESM but has breaking API changes)
- `node-fetch` v2.7.0 usage (CJS-only, v3 is ESM-only)
- Pre-existing TypeScript errors (ArrayBuffer type mismatches)
### 2. Angular Webpack Plugin
The `@ngtools/webpack` plugin does its own TypeScript compilation and doesn't support `transpileOnly` mode, so it surfaces type errors from jslib.
---
## Future Work
To complete full ESM migration:
1. **Update jslib submodule** - Fix type errors, upgrade to ESM-compatible dependencies
2. **Upgrade lowdb** - From v1 to v7 (requires rewriting storage layer)
3. **Remove node-fetch** - Use native `fetch` (Node 18+) or upgrade to v3
4. **Enable ESM output** - Once dependencies are updated, change webpack output to ESM
---
## Testing the Migration
```bash
# Build CLI
npm run build:cli
node ./build-cli/bwdc.cjs --help
# Build Electron main
npm run build:main
# Run tests
npm test
```
---
## Files Changed
| File | Change |
| ------------------------ | ---------------------------------------------------- |
| `package.json` | Added `"type": "module"`, changed main to `main.cjs` |
| `tsconfig.json` | Added `skipLibCheck`, `noEmitOnError` |
| `tsconfig.renderer.json` | New file for Angular compilation |
| `webpack.cli.cjs` | Output to `.cjs`, added `transpileOnly` |
| `webpack.main.cjs` | Output to `.cjs`, added `transpileOnly` |
| `webpack.renderer.cjs` | Use separate tsconfig |
| `src-cli/package.json` | Added `"type": "module"`, updated bin path |

View File

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

View File

@@ -11,6 +11,7 @@
"app": "build"
},
"afterSign": "scripts/notarize.js",
"asarUnpack": ["node_modules/dc-native/*.node"],
"mac": {
"artifactName": "Bitwarden-Connector-${version}-mac.${ext}",
"category": "public.app-category.productivity",

View File

@@ -23,6 +23,7 @@ export default [
"eslint.config.mjs",
"scripts/**/*.js",
"**/node_modules/**",
"native/**",
],
},

View File

@@ -24,20 +24,13 @@ module.exports = {
roots: ["<rootDir>"],
modulePaths: [compilerOptions.baseUrl],
moduleNameMapper: {
...pathsToModuleNameMapper(compilerOptions.paths, { prefix: "<rootDir>/" }),
// ESM compatibility: mock import.meta.url for tests
"^(\\.{1,2}/.*)\\.js$": "$1",
},
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: "<rootDir>/" }),
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
// Workaround for a memory leak that crashes tests in CI:
// https://github.com/facebook/jest/issues/9430#issuecomment-1149882002
// Also anecdotally improves performance when run locally
maxWorkers: 3,
// ESM support
extensionsToTreatAsEsm: [".ts"],
transform: {
"^.+\\.tsx?$": [
"jest-preset-angular",
@@ -50,8 +43,6 @@ module.exports = {
// Makes tests run faster and reduces size/rate of leak, but loses typechecking on test code
// See https://bitwarden.atlassian.net/browse/EC-497 for more info
isolatedModules: true,
// ESM support
useESM: true,
},
],
},

View File

@@ -1,75 +1,77 @@
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 as BaseToast,
ToastPackage,
ToastrService,
TOAST_CONFIG,
} from "ngx-toastr";
import { DefaultNoComponentGlobalConfig, GlobalConfig, Toast, TOAST_CONFIG } from "ngx-toastr";
@Component({
selector: "[toast-component2]",
template: `
<button
*ngIf="options.closeButton"
(click)="remove()"
type="button"
class="toast-close-button"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
@if (options().closeButton) {
<button (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>
<div *ngIf="title" [class]="options.titleClass" [attr.aria-label]="title">
{{ title }} <ng-container *ngIf="duplicatesCount">[{{ duplicatesCount + 1 }}]</ng-container>
</div>
<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>
@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>
}
`,
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;
}
}
`,
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 BaseToast {
constructor(
protected toastrService: ToastrService,
public toastPackage: ToastPackage,
) {
super(toastrService, toastPackage);
}
}
export class BitwardenToast extends Toast {}
export const BitwardenToastGlobalConfig: GlobalConfig = {
...DefaultNoComponentGlobalConfig,

View File

@@ -1,195 +0,0 @@
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).toThrowError("Must provide key");
expect(t).toThrow("Must provide key");
});
describe("guesses encKey from key length", () => {
@@ -63,7 +63,7 @@ describe("SymmetricCryptoKey", () => {
new SymmetricCryptoKey(makeStaticByteArray(30));
};
expect(t).toThrowError("Unable to determine encType.");
expect(t).toThrow("Unable to determine encType.");
});
});
});

View File

@@ -1,84 +0,0 @@
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,7 +1,3 @@
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;
@@ -21,17 +17,10 @@ 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++) {
arr[i] = start + i;
}
return arr;
return arr.buffer;
}

View File

@@ -3,5 +3,6 @@ export enum StateVersion {
Two = 2, // Move to a typed State object
Three = 3, // Fix migration of users' premium status
Four = 4, // Fix 'Never Lock' option by removing stale data
Latest = Four,
Five = 5, // Migrate Windows keychain credentials from keytar (UTF-8) to desktop_core (UTF-16)
Latest = Five,
}

View File

@@ -26,9 +26,4 @@ export class NodeUtils {
.on("error", (err) => reject(err));
});
}
// https://stackoverflow.com/a/31394257
static bufferToArrayBuffer(buf: Buffer): ArrayBuffer {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
}

View File

@@ -36,7 +36,7 @@ export class Utils {
Utils.global = Utils.isNode && !Utils.isBrowser ? global : window;
}
static fromB64ToArray(str: string): Uint8Array {
static fromB64ToArray(str: string): Uint8Array<ArrayBuffer> {
if (Utils.isNode) {
return new Uint8Array(Buffer.from(str, "base64"));
} else {
@@ -49,11 +49,11 @@ export class Utils {
}
}
static fromUrlB64ToArray(str: string): Uint8Array {
static fromUrlB64ToArray(str: string): Uint8Array<ArrayBuffer> {
return Utils.fromB64ToArray(Utils.fromUrlB64ToB64(str));
}
static fromHexToArray(str: string): Uint8Array {
static fromHexToArray(str: string): Uint8Array<ArrayBuffer> {
if (Utils.isNode) {
return new Uint8Array(Buffer.from(str, "hex"));
} else {
@@ -65,7 +65,7 @@ export class Utils {
}
}
static fromUtf8ToArray(str: string): Uint8Array {
static fromUtf8ToArray(str: string): Uint8Array<ArrayBuffer> {
if (Utils.isNode) {
return new Uint8Array(Buffer.from(str, "utf8"));
} else {
@@ -78,7 +78,7 @@ export class Utils {
}
}
static fromByteStringToArray(str: string): Uint8Array {
static fromByteStringToArray(str: string): Uint8Array<ArrayBuffer> {
const arr = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
arr[i] = str.charCodeAt(i);
@@ -99,8 +99,8 @@ export class Utils {
}
}
static fromBufferToUrlB64(buffer: ArrayBuffer): string {
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
static fromBufferToUrlB64(buffer: Uint8Array<ArrayBuffer>): string {
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer.buffer));
}
static fromB64toUrlB64(b64Str: string) {

View File

@@ -636,9 +636,9 @@ export class CryptoService implements CryptoServiceAbstraction {
const encBytes = new Uint8Array(encBuf);
const encType = encBytes[0];
let ctBytes: Uint8Array = null;
let ivBytes: Uint8Array = null;
let macBytes: Uint8Array = null;
let ctBytes: Uint8Array<ArrayBuffer> = null;
let ivBytes: Uint8Array<ArrayBuffer> = null;
let macBytes: Uint8Array<ArrayBuffer> = null;
switch (encType) {
case EncryptionType.AesCbc128_HmacSha256_B64:

View File

@@ -5,7 +5,7 @@ import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions"
export class ElectronRendererSecureStorageService implements StorageService {
async get<T>(key: string, options?: StorageOptions): Promise<T> {
const val = ipcRenderer.sendSync("keytar", {
const val = ipcRenderer.sendSync("nativeSecureStorage", {
action: "getPassword",
key: key,
keySuffix: options?.keySuffix ?? "",
@@ -14,7 +14,7 @@ export class ElectronRendererSecureStorageService implements StorageService {
}
async has(key: string, options?: StorageOptions): Promise<boolean> {
const val = ipcRenderer.sendSync("keytar", {
const val = ipcRenderer.sendSync("nativeSecureStorage", {
action: "hasPassword",
key: key,
keySuffix: options?.keySuffix ?? "",
@@ -23,7 +23,7 @@ export class ElectronRendererSecureStorageService implements StorageService {
}
async save(key: string, obj: any, options?: StorageOptions): Promise<any> {
ipcRenderer.sendSync("keytar", {
ipcRenderer.sendSync("nativeSecureStorage", {
action: "setPassword",
key: key,
keySuffix: options?.keySuffix ?? "",
@@ -33,7 +33,7 @@ export class ElectronRendererSecureStorageService implements StorageService {
}
async remove(key: string, options?: StorageOptions): Promise<any> {
ipcRenderer.sendSync("keytar", {
ipcRenderer.sendSync("nativeSecureStorage", {
action: "deletePassword",
key: key,
keySuffix: options?.keySuffix ?? "",

View File

@@ -127,6 +127,13 @@ 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();
}

View File

@@ -94,7 +94,7 @@ describe("NodeCrypto Function Service", () => {
it("should fail with prk too small", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const f = cryptoFunctionService.hkdfExpand(
Utils.fromB64ToArray(prk16Byte),
Utils.fromB64ToArray(prk16Byte).buffer,
"info",
32,
"sha256",
@@ -105,7 +105,7 @@ describe("NodeCrypto Function Service", () => {
it("should fail with outputByteSize is too large", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const f = cryptoFunctionService.hkdfExpand(
Utils.fromB64ToArray(prk32Byte),
Utils.fromB64ToArray(prk32Byte).buffer,
"info",
8161,
"sha256",
@@ -341,7 +341,7 @@ function testHkdf(
utf8Key: string,
unicodeKey: string,
) {
const ikm = Utils.fromB64ToArray("criAmKtfzxanbgea5/kelQ==");
const ikm = Utils.fromB64ToArray("criAmKtfzxanbgea5/kelQ==").buffer;
const regularSalt = "salt";
const utf8Salt = "üser_salt";
@@ -393,7 +393,7 @@ function testHkdfExpand(
it("should create valid " + algorithm + " " + outputByteSize + " byte okm", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const okm = await cryptoFunctionService.hkdfExpand(
Utils.fromB64ToArray(b64prk),
Utils.fromB64ToArray(b64prk).buffer,
info,
outputByteSize,
algorithm,

3498
native/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

27
native/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "dc_native"
version = "0.1.0"
edition = "2021"
description = "Native keychain bindings for Bitwarden Directory Connector"
license = "GPL-3.0"
[lib]
crate-type = ["cdylib"]
name = "dc_native"
[dependencies]
anyhow = "=1.0.100"
desktop_core = { git = "https://github.com/bitwarden/clients", rev = "00cf24972d944638bbd1adc00a0ae3eeabb6eb9a", package = "desktop_core" }
napi = { version = "=3.3.0", features = ["async"] }
napi-derive = "=3.2.5"
[target.'cfg(windows)'.dependencies]
scopeguard = "=1.2.0"
widestring = "=1.2.0"
windows = { version = "=0.61.1", features = [
"Win32_Foundation",
"Win32_Security_Credentials",
] }
[build-dependencies]
napi-build = "=2.2.3"

5
native/build.rs Normal file
View File

@@ -0,0 +1,5 @@
extern crate napi_build;
fn main() {
napi_build::setup();
}

34
native/index.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
export declare namespace passwords {
/** The error message returned when a password is not found during retrieval or deletion. */
export const PASSWORD_NOT_FOUND: string;
/**
* Fetch the stored password from the keychain.
* Throws an Error with message PASSWORD_NOT_FOUND if the password does not exist.
*/
export function getPassword(service: string, account: string): Promise<string>;
/**
* Save the password to the keychain. Adds an entry if none exists, otherwise updates it.
*/
export function setPassword(service: string, account: string, password: string): Promise<void>;
/**
* Delete the stored password from the keychain.
* Throws an Error with message PASSWORD_NOT_FOUND if the password does not exist.
*/
export function deletePassword(service: string, account: string): Promise<void>;
/**
* Check if OS secure storage is available.
*/
export function isAvailable(): Promise<boolean>;
/**
* Migrate a credential previously stored by keytar (UTF-8 blob on Windows) to the UTF-16
* format used by desktop_core. No-ops on non-Windows platforms.
*
* Returns true if a migration was performed, false otherwise.
*/
export function migrateKeytarPassword(service: string, account: string): Promise<boolean>;
}

67
native/index.js Normal file
View File

@@ -0,0 +1,67 @@
const { existsSync } = require("fs");
const { join } = require("path");
const { platform, arch } = process;
let nativeBinding = null;
let loadError = null;
function loadFirstAvailable(localFiles) {
for (const localFile of localFiles) {
const filePath = join(__dirname, localFile);
if (existsSync(filePath)) {
return require(filePath);
}
}
throw new Error(`Could not find dc-native binary. Run 'npm run build:native' to compile it.`);
}
switch (platform) {
case "win32":
switch (arch) {
case "x64":
nativeBinding = loadFirstAvailable(["dc_native.win32-x64-msvc.node"]);
break;
case "arm64":
nativeBinding = loadFirstAvailable(["dc_native.win32-arm64-msvc.node"]);
break;
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`);
}
break;
case "darwin":
switch (arch) {
case "x64":
nativeBinding = loadFirstAvailable(["dc_native.darwin-x64.node"]);
break;
case "arm64":
nativeBinding = loadFirstAvailable(["dc_native.darwin-arm64.node"]);
break;
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`);
}
break;
case "linux":
switch (arch) {
case "x64":
nativeBinding = loadFirstAvailable(["dc_native.linux-x64-gnu.node"]);
break;
case "arm64":
nativeBinding = loadFirstAvailable(["dc_native.linux-arm64-gnu.node"]);
break;
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`);
}
break;
default:
throw new Error(`Unsupported platform: ${platform}, architecture: ${arch}`);
}
if (!nativeBinding) {
if (loadError) {
throw loadError;
}
throw new Error(`Failed to load dc-native binding`);
}
module.exports = nativeBinding;

15
native/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "dc-native",
"version": "1.0.0",
"description": "Native keychain bindings for Bitwarden Directory Connector",
"main": "index.js",
"types": "index.d.ts",
"license": "GPL-3.0",
"scripts": {
"build": "napi build --platform",
"build:release": "napi build --platform --release"
},
"devDependencies": {
"@napi-rs/cli": "^3.0.0"
}
}

70
native/src/lib.rs Normal file
View File

@@ -0,0 +1,70 @@
#[macro_use]
extern crate napi_derive;
#[napi]
pub mod passwords {
/// The error message returned when a password is not found during retrieval or deletion.
#[napi]
pub const PASSWORD_NOT_FOUND: &str = desktop_core::password::PASSWORD_NOT_FOUND;
/// Fetch the stored password from the keychain.
/// Throws an Error with message PASSWORD_NOT_FOUND if the password does not exist.
#[napi]
pub async fn get_password(service: String, account: String) -> napi::Result<String> {
desktop_core::password::get_password(&service, &account)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Save the password to the keychain. Adds an entry if none exists, otherwise updates it.
#[napi]
pub async fn set_password(
service: String,
account: String,
password: String,
) -> napi::Result<()> {
desktop_core::password::set_password(&service, &account, &password)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Delete the stored password from the keychain.
/// Throws an Error with message PASSWORD_NOT_FOUND if the password does not exist.
#[napi]
pub async fn delete_password(service: String, account: String) -> napi::Result<()> {
desktop_core::password::delete_password(&service, &account)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Check if OS secure storage is available.
#[napi]
pub async fn is_available() -> napi::Result<bool> {
desktop_core::password::is_available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Migrate a credential that was stored by keytar (UTF-8 blob) to the new UTF-16 format
/// used by desktop_core on Windows. No-ops on non-Windows platforms.
///
/// Returns true if a migration was performed, false if the credential was already in the
/// correct format or does not exist.
#[napi]
pub async fn migrate_keytar_password(service: String, account: String) -> napi::Result<bool> {
#[cfg(windows)]
{
crate::migration::migrate_keytar_password(&service, &account)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[cfg(not(windows))]
{
let _ = (service, account);
Ok(false)
}
}
}
#[cfg(windows)]
mod migration;

67
native/src/migration.rs Normal file
View File

@@ -0,0 +1,67 @@
/// Windows-only: migrates credentials stored by keytar (UTF-8 blob via CredWriteA) to the
/// UTF-16 format expected by desktop_core (CredWriteW).
///
/// Keytar used CredWriteA on Windows, which stored the credential blob as raw UTF-8 bytes.
/// desktop_core uses CredWriteW with a UTF-16 encoded blob. Reading old keytar credentials
/// through desktop_core's get_password produces garbled output because the UTF-8 bytes are
/// reinterpreted as UTF-16.
///
/// This function detects the old format by checking whether the raw blob bytes are valid UTF-8
/// without null bytes (UTF-16 LE encoding of ASCII always contains null bytes). If so, it
/// re-saves the credential using desktop_core's set_password (UTF-16 encoding).
use anyhow::{anyhow, Result};
use widestring::U16CString;
use windows::{
core::PCWSTR,
Win32::Security::Credentials::{CredFree, CredReadW, CRED_TYPE_GENERIC},
};
pub async fn migrate_keytar_password(service: &str, account: &str) -> Result<bool> {
let target = format!("{}/{}", service, account);
let target_wide = U16CString::from_str(&target)?;
let mut credential = std::ptr::null_mut();
let result = unsafe {
CredReadW(
PCWSTR(target_wide.as_ptr()),
CRED_TYPE_GENERIC,
None,
&mut credential,
)
};
scopeguard::defer! {{
unsafe { CredFree(credential as *mut _) };
}};
if result.is_err() {
// Credential does not exist; nothing to migrate.
return Ok(false);
}
let blob_bytes: Vec<u8> = unsafe {
let blob_ptr = (*credential).CredentialBlob;
let blob_size = (*credential).CredentialBlobSize as usize;
if blob_ptr.is_null() || blob_size == 0 {
return Ok(false);
}
std::slice::from_raw_parts(blob_ptr, blob_size).to_vec()
};
// UTF-16 LE encoding of ASCII always contains null bytes (e.g. 'A' → 0x41 0x00).
// Keytar stored raw UTF-8 bytes which will never contain null bytes for valid JSON.
// If the blob is valid UTF-8 and contains no null bytes, it was written by keytar.
let blob_is_utf8 = std::str::from_utf8(&blob_bytes)
.map(|s| !s.contains('\0'))
.unwrap_or(false);
if !blob_is_utf8 {
// Already UTF-16 or unrecognised format; no migration needed.
return Ok(false);
}
let utf8_value = String::from_utf8(blob_bytes).map_err(|e| anyhow!(e))?;
desktop_core::password::set_password(service, account, &utf8_value).await?;
Ok(true)
}

12304
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,7 @@
"name": "@bitwarden/directory-connector",
"productName": "Bitwarden Directory Connector",
"description": "Sync your user directory to your Bitwarden organization.",
"version": "2025.12.0",
"type": "module",
"version": "2026.2.0",
"keywords": [
"bitwarden",
"password",
@@ -17,7 +16,7 @@
"url": "https://github.com/bitwarden/directory-connector"
},
"license": "GPL-3.0",
"main": "main.cjs",
"main": "main.js",
"scripts": {
"sub:init": "git submodule update --init --recursive",
"sub:update": "git submodule update --remote",
@@ -27,15 +26,16 @@
"symlink:win": "rm -rf ./jslib && cmd /c mklink /J .\\jslib ..\\jslib",
"symlink:mac": "npm run symlink:lin",
"symlink:lin": "rm -rf ./jslib && ln -s ../jslib ./jslib",
"rebuild": "electron-rebuild",
"reset": "rimraf --glob ./node_modules/keytar/* && npm install",
"build:native": "cd native && npm install && npm run build",
"build:native:release": "cd native && npm install && npm run build:release",
"rebuild": "npm run build:native:release",
"lint": "eslint . && prettier --check .",
"lint:fix": "eslint . --fix",
"build": "concurrently -n Main,Rend -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "webpack --config webpack.main.cjs",
"build:renderer": "webpack --config webpack.renderer.cjs",
"build:renderer:watch": "webpack --config webpack.renderer.cjs --watch",
"build:dist": "npm run reset && npm run rebuild && npm run build",
"build:dist": "npm run rebuild && npm run build",
"build:cli": "webpack --config webpack.cli.cjs",
"build:cli:watch": "webpack --config webpack.cli.cjs --watch",
"build:cli:prod": "cross-env NODE_ENV=production webpack --config webpack.cli.cjs",
@@ -74,17 +74,15 @@
"test:types": "npx tsc --noEmit"
},
"devDependencies": {
"@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",
"@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",
"@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": "20.3.3",
"@ngtools/webpack": "21.1.2",
"@types/inquirer": "8.2.10",
"@types/jest": "29.5.14",
"@types/jest": "30.0.0",
"@types/lowdb": "1.0.15",
"@types/node": "22.19.2",
"@types/node-fetch": "2.6.12",
@@ -92,10 +90,11 @@
"@types/proper-lockfile": "4.1.4",
"@types/semver": "7.7.1",
"@types/tldjs": "2.3.4",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@typescript-eslint/eslint-plugin": "8.54.0",
"@typescript-eslint/parser": "8.54.0",
"@yao-pkg/pkg": "5.16.1",
"clean-webpack-plugin": "4.0.0",
"babel-loader": "10.0.0",
"jest-environment-jsdom": "30.2.0",
"concurrently": "9.2.0",
"copy-webpack-plugin": "13.0.0",
"cross-env": "7.0.3",
@@ -106,28 +105,27 @@
"electron-log": "5.4.1",
"electron-reload": "2.0.0-alpha.1",
"electron-store": "8.2.0",
"electron-updater": "6.6.2",
"electron-updater": "6.7.3",
"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.8.3",
"eslint-plugin-rxjs-x": "0.9.1",
"form-data": "4.0.4",
"glob": "13.0.0",
"glob": "13.0.6",
"html-loader": "5.1.0",
"html-webpack-plugin": "5.6.3",
"husky": "9.1.7",
"jest": "29.7.0",
"jest": "30.2.0",
"jest-junit": "16.0.0",
"jest-mock-extended": "4.0.0",
"jest-preset-angular": "14.6.0",
"jest-preset-angular": "16.0.0",
"lint-staged": "16.2.6",
"mini-css-extract-plugin": "2.9.2",
"minimatch": "5.1.2",
"mini-css-extract-plugin": "2.10.0",
"node-forge": "1.3.2",
"node-loader": "2.1.0",
"prettier": "3.7.4",
"prettier": "3.8.1",
"rimraf": "6.1.0",
"rxjs": "7.8.2",
"sass": "1.97.1",
@@ -135,25 +133,25 @@
"ts-jest": "29.4.1",
"ts-loader": "9.5.2",
"tsconfig-paths-webpack-plugin": "4.2.0",
"type-fest": "5.3.0",
"typescript": "5.8.3",
"webpack": "5.104.1",
"type-fest": "5.4.2",
"typescript": "5.9.3",
"webpack": "5.105.1",
"webpack-cli": "6.0.1",
"webpack-merge": "6.0.1",
"webpack-node-externals": "3.0.0",
"zone.js": "0.15.1"
"zone.js": "0.16.0"
},
"dependencies": {
"@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",
"@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",
"@microsoft/microsoft-graph-client": "3.0.7",
"big-integer": "1.6.52",
"bootstrap": "5.3.7",
@@ -164,20 +162,20 @@
"googleapis": "149.0.0",
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.6",
"keytar": "7.9.0",
"ldapts": "8.0.1",
"dc-native": "file:./native",
"ldapts": "8.1.3",
"lowdb": "1.0.0",
"ngx-toastr": "19.1.0",
"ngx-toastr": "20.0.4",
"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.15.1"
"zone.js": "0.16.0"
},
"engines": {
"node": "~20",
"node": "~22",
"npm": "~10"
},
"lint-staged": {

View File

@@ -3,17 +3,16 @@
"productName": "Bitwarden Directory Connector",
"description": "Sync your user directory to your Bitwarden organization.",
"version": "2.9.5",
"type": "module",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",
"main": "main.mjs",
"main": "main.js",
"repository": {
"type": "git",
"url": "https://github.com/bitwarden/directory-connector"
},
"bin": {
"bwdc": "../build-cli/bwdc.cjs"
"bwdc": "../build-cli/bwdc.js"
},
"pkg": {
"assets": "../build-cli/**/*"

View File

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

View File

@@ -3,17 +3,25 @@
<div class="card-body">
<p>
{{ "lastGroupSync" | i18n }}:
<span *ngIf="!lastGroupSync">-</span>
@if (!lastGroupSync) {
<span>-</span>
}
{{ lastGroupSync | date: "medium" }}
<br />
{{ "lastUserSync" | i18n }}:
<span *ngIf="!lastUserSync">-</span>
@if (!lastUserSync) {
<span>-</span>
}
{{ lastUserSync | date: "medium" }}
</p>
<p>
{{ "syncStatus" | i18n }}:
<strong *ngIf="syncRunning" class="text-success">{{ "running" | i18n }}</strong>
<strong *ngIf="!syncRunning" class="text-danger">{{ "stopped" | i18n }}</strong>
@if (syncRunning) {
<strong class="text-success">{{ "running" | i18n }}</strong>
}
@if (!syncRunning) {
<strong class="text-danger">{{ "stopped" | i18n }}</strong>
}
</p>
<form #startForm [appApiAction]="startPromise" class="d-inline">
<button
@@ -60,57 +68,85 @@
/>
<label class="form-check-label" for="simSinceLast">{{ "testLastSync" | i18n }}</label>
</div>
<ng-container *ngIf="!simForm.loading && (simUsers || simGroups)">
@if (!simForm.loading && (simUsers || simGroups)) {
<hr />
<div class="row">
<div class="col-lg">
<h4>{{ "users" | i18n }}</h4>
<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>
@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>
}
<h4>{{ "disabledUsers" | i18n }}</h4>
<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>
@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>
}
<h4>{{ "deletedUsers" | i18n }}</h4>
<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>
@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>
}
</div>
<div class="col-lg">
<h4>{{ "groups" | i18n }}</h4>
<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 }}
@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>
}
</li>
</ul>
</li>
</ul>
<p *ngIf="!simGroups || !simGroups.length">{{ "noGroups" | i18n }}</p>
}
</ul>
}
@if (!simGroups || !simGroups.length) {
<p>{{ "noGroups" | i18n }}</p>
}
</div>
</div>
</ng-container>
}
</div>
</div>

View File

@@ -6,9 +6,11 @@
<div class="mb-3">
<label for="directory" class="form-label">{{ "type" | i18n }}</label>
<select class="form-select" id="directory" name="Directory" [(ngModel)]="directory">
<option *ngFor="let o of directoryOptions" [ngValue]="o.value">
{{ o.name }}
</option>
@for (o of directoryOptions; track o) {
<option [ngValue]="o.value">
{{ o.name }}
</option>
}
</select>
</div>
<div [hidden]="directory != directoryType.Ldap">
@@ -51,20 +53,22 @@
<label class="form-check-label" for="ad">{{ "ldapAd" | i18n }}</label>
</div>
</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>
@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>
</div>
}
<div class="mb-3">
<div class="form-check">
<input
@@ -79,116 +83,122 @@
}}</label>
</div>
</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>
@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>
<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>
@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>
</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
@@ -211,10 +221,12 @@
name="Username"
[(ngModel)]="ldap.username"
/>
<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>
@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>
<div class="mb-3">
<label for="password" class="form-label">{{ "password" | i18n }}</label>
@@ -604,18 +616,24 @@
name="UserFilter"
[(ngModel)]="sync.userFilter"
></textarea>
<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>
@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>
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
<label for="userPath" class="form-label">{{ "userPath" | i18n }}</label>
@@ -681,18 +699,20 @@
name="GroupFilter"
[(ngModel)]="sync.groupFilter"
></textarea>
<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>
@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>
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
<label for="groupPath" class="form-label">{{ "groupPath" | i18n }}</label>
@@ -703,8 +723,12 @@
name="GroupPath"
[(ngModel)]="sync.groupPath"
/>
<div class="form-text" *ngIf="!ldap.ad">{{ "ex" | i18n }} CN=Groups</div>
<div class="form-text" *ngIf="ldap.ad">{{ "ex" | i18n }} CN=Users</div>
@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>
<div [hidden]="directory != directoryType.Ldap || ldap.ad">
<div class="mb-3">

View File

@@ -24,8 +24,8 @@ import { AuthService } from "./services/auth.service";
import { BatchRequestBuilder } from "./services/batch-request-builder";
import { DefaultDirectoryFactoryService } from "./services/directory-factory.service";
import { I18nService } from "./services/i18n.service";
import { KeytarSecureStorageService } from "./services/keytarSecureStorage.service";
import { LowdbStorageService } from "./services/lowdbStorage.service";
import { NativeSecureStorageService } from "./services/nativeSecureStorage.service";
import { SingleRequestBuilder } from "./services/single-request-builder";
import { StateService } from "./services/state.service";
import { StateMigrationService } from "./services/stateMigration.service";
@@ -100,7 +100,7 @@ export class Main {
);
this.secureStorageService = plaintextSecrets
? this.storageService
: new KeytarSecureStorageService(applicationName);
: new NativeSecureStorageService(applicationName);
this.stateMigrationService = new StateMigrationService(
this.storageService,

View File

@@ -1,11 +1,11 @@
import { passwords } from "dc-native";
import { ipcMain } from "electron";
import { deletePassword, getPassword, setPassword } from "keytar";
export class DCCredentialStorageListener {
constructor(private serviceName: string) {}
init() {
ipcMain.on("keytar", async (event: any, message: any) => {
ipcMain.on("nativeSecureStorage", async (event: any, message: any) => {
try {
let serviceName = this.serviceName;
message.keySuffix = "_" + (message.keySuffix ?? "");
@@ -16,14 +16,14 @@ export class DCCredentialStorageListener {
let val: string | boolean = null;
if (message.action && message.key) {
if (message.action === "getPassword") {
val = await getPassword(serviceName, message.key);
val = await passwords.getPassword(serviceName, message.key);
} else if (message.action === "hasPassword") {
const result = await getPassword(serviceName, message.key);
const result = await passwords.getPassword(serviceName, message.key);
val = result != null;
} else if (message.action === "setPassword" && message.value) {
await setPassword(serviceName, message.key, message.value);
await passwords.setPassword(serviceName, message.key, message.value);
} else if (message.action === "deletePassword") {
await deletePassword(serviceName, message.key);
await passwords.deletePassword(serviceName, message.key);
}
}
event.returnValue = val;

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

@@ -1,7 +1,8 @@
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { mock } from "jest-mock-extended";
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 {
@@ -11,7 +12,6 @@ import {
} from "@/jslib/common/src/models/domain/account";
import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identityTokenResponse";
import { MessagingService } from "../../jslib/common/src/abstractions/messaging.service";
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
import { AuthService } from "./auth.service";
@@ -35,22 +35,22 @@ export function identityTokenResponseFactory() {
}
describe("AuthService", () => {
let apiService: SubstituteOf<ApiService>;
let appIdService: SubstituteOf<AppIdService>;
let platformUtilsService: SubstituteOf<PlatformUtilsService>;
let messagingService: SubstituteOf<MessagingService>;
let stateService: SubstituteOf<StateService>;
let apiService: jest.Mocked<ApiService>;
let appIdService: jest.Mocked<AppIdService>;
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
let messagingService: jest.Mocked<MessagingService>;
let stateService: jest.Mocked<StateService>;
let authService: AuthService;
beforeEach(async () => {
apiService = Substitute.for();
appIdService = Substitute.for();
platformUtilsService = Substitute.for();
stateService = Substitute.for();
messagingService = Substitute.for();
apiService = mock<ApiService>();
appIdService = mock<AppIdService>();
platformUtilsService = mock<PlatformUtilsService>();
stateService = mock<StateService>();
messagingService = mock<MessagingService>();
appIdService.getAppId().resolves(deviceId);
appIdService.getAppId.mockResolvedValue(deviceId);
authService = new AuthService(
apiService,
@@ -62,11 +62,12 @@ describe("AuthService", () => {
});
it("sets the local environment after a successful login", async () => {
apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory());
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
await authService.logIn({ clientId, clientSecret });
stateService.received(1).addAccount(
expect(stateService.addAccount).toHaveBeenCalledTimes(1);
expect(stateService.addAccount).toHaveBeenCalledWith(
new Account({
profile: {
...new AccountProfile(),

View File

@@ -68,10 +68,12 @@ export class LdapDirectoryService implements IDirectoryService {
}
groups = await this.getGroups(groupForce);
}
} finally {
} catch (e) {
await this.client.unbind();
throw e;
}
await this.client.unbind();
return [groups, users];
}
@@ -453,8 +455,9 @@ export class LdapDirectoryService implements IDirectoryService {
try {
await this.client.bind(user, pass);
} catch {
} catch (error) {
await this.client.unbind();
throw error;
}
}

View File

@@ -1,31 +0,0 @@
import { deletePassword, getPassword, setPassword } from "keytar";
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
export class KeytarSecureStorageService implements StorageService {
constructor(private serviceName: string) {}
get<T>(key: string): Promise<T> {
return getPassword(this.serviceName, key).then((val) => {
return JSON.parse(val) as T;
});
}
async has(key: string): Promise<boolean> {
return (await this.get(key)) != null;
}
save(key: string, obj: any): Promise<any> {
// keytar throws if you try to save a falsy value: https://github.com/atom/node-keytar/issues/86
// handle this by removing the key instead
if (!obj) {
return this.remove(key);
}
return setPassword(this.serviceName, key, JSON.stringify(obj));
}
remove(key: string): Promise<any> {
return deletePassword(this.serviceName, key);
}
}

View File

@@ -0,0 +1,28 @@
import { passwords } from "dc-native";
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
export class NativeSecureStorageService implements StorageService {
constructor(private serviceName: string) {}
get<T>(key: string): Promise<T> {
return passwords.getPassword(this.serviceName, key).then((val) => {
return JSON.parse(val) as T;
});
}
async has(key: string): Promise<boolean> {
return (await this.get(key)) != null;
}
save(key: string, obj: any): Promise<any> {
if (!obj) {
return this.remove(key);
}
return passwords.setPassword(this.serviceName, key, JSON.stringify(obj));
}
remove(key: string): Promise<any> {
return passwords.deletePassword(this.serviceName, key);
}
}

View File

@@ -1,3 +1,5 @@
import { passwords } from "dc-native";
import { StateVersion } from "@/jslib/common/src/enums/stateVersion";
import { StateMigrationService as BaseStateMigrationService } from "@/jslib/common/src/services/stateMigration.service";
@@ -61,6 +63,13 @@ export class StateMigrationService extends BaseStateMigrationService {
break;
case StateVersion.Two:
await this.migrateStateFrom2To3();
break;
case StateVersion.Three:
await this.migrateStateFrom3To4();
break;
case StateVersion.Four:
await this.migrateStateFrom4To5();
break;
}
currentStateVersion += 1;
}
@@ -168,6 +177,50 @@ export class StateMigrationService extends BaseStateMigrationService {
}
}
}
/**
* Migrates Windows credential store entries previously written by keytar (UTF-8 blob) to
* the UTF-16 format expected by desktop_core. No-ops on non-Windows platforms.
*
* This migration is needed because keytar used CredWriteA (storing blobs as raw UTF-8 bytes)
* while desktop_core uses CredWriteW (storing blobs as UTF-16). Reading old keytar credentials
* through desktop_core produces garbled output without this migration.
*/
protected async migrateStateFrom3To4(useSecureStorageForSecrets = true): Promise<void> {
if (useSecureStorageForSecrets && process.platform === "win32") {
const serviceName = "Bitwarden Directory Connector";
const authenticatedUserIds = await this.get<string[]>(StateKeys.authenticatedAccounts);
if (authenticatedUserIds?.length) {
const credentialKeys = [
SecureStorageKeys.ldap,
SecureStorageKeys.gsuite,
SecureStorageKeys.azure,
SecureStorageKeys.entra,
SecureStorageKeys.okta,
SecureStorageKeys.oneLogin,
];
await Promise.all(
authenticatedUserIds.flatMap((userId) =>
credentialKeys.map((key) =>
passwords.migrateKeytarPassword(serviceName, `${userId}_${key}`),
),
),
);
}
}
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Four;
await this.set(StateKeys.global, globals);
}
protected async migrateStateFrom4To5(): Promise<void> {
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Five;
await this.set(StateKeys.global, globals);
}
protected async migrateStateFrom2To3(useSecureStorageForSecrets = true): Promise<void> {
if (useSecureStorageForSecrets) {
const authenticatedUserIds = await this.get<string[]>(StateKeys.authenticatedAccounts);

View File

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

View File

@@ -5,9 +5,9 @@
},
"compilerOptions": {
"pretty": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"noImplicitAny": true,
"target": "ES2020",
"target": "ES2016",
"module": "ES2020",
"lib": ["es5", "es6", "es7", "dom"],
"sourceMap": true,
@@ -18,8 +18,6 @@
"outDir": "dist",
"baseUrl": ".",
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmitOnError": false,
"paths": {
"tldjs": ["./jslib/common/src/misc/tldjs.noop"],
"@/*": ["./*"]

View File

@@ -1,13 +0,0 @@
{
"extends": "./tsconfig.json",
"angularCompilerOptions": {
"strictTemplates": true,
"preserveWhitespaces": true
},
"compilerOptions": {
"skipLibCheck": true,
"noEmitOnError": false
},
"include": ["src/app"],
"exclude": ["jslib", "**/*.spec.ts"]
}

View File

@@ -1,6 +1,5 @@
const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const webpack = require("webpack");
@@ -14,12 +13,7 @@ const ENV = (process.env.ENV = process.env.NODE_ENV);
const moduleRules = [
{
test: /\.ts$/,
use: {
loader: "ts-loader",
options: {
transpileOnly: true,
},
},
use: "ts-loader",
exclude: path.resolve(__dirname, "node_modules"),
},
{
@@ -29,7 +23,6 @@ const moduleRules = [
];
const plugins = [
new CleanWebpackPlugin(),
new CopyWebpackPlugin({
patterns: [{ from: "./src/locales", to: "locales" }],
}),
@@ -67,8 +60,9 @@ const config = {
modules: [path.resolve("node_modules")],
},
output: {
filename: "[name].cjs",
filename: "[name].js",
path: path.resolve(__dirname, "build-cli"),
clean: true,
},
module: { rules: moduleRules },
plugins: plugins,

View File

@@ -1,7 +1,6 @@
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");
@@ -10,12 +9,7 @@ const common = {
rules: [
{
test: /\.tsx?$/,
use: {
loader: "ts-loader",
options: {
transpileOnly: true,
},
},
use: "ts-loader",
exclude: /node_modules\/(?!(@bitwarden)\/).*/,
},
],
@@ -28,6 +22,7 @@ const common = {
output: {
filename: "[name].js",
path: path.resolve(__dirname, "build"),
clean: true,
},
};
@@ -53,7 +48,6 @@ const main = {
],
},
plugins: [
new CleanWebpackPlugin(),
new CopyWebpackPlugin({
patterns: [
"./package.json",
@@ -62,12 +56,9 @@ const main = {
],
}),
],
output: {
filename: "[name].cjs",
},
externals: {
"electron-reload": "commonjs2 electron-reload",
keytar: "commonjs2 keytar",
"dc-native": "commonjs2 dc-native",
},
};

View File

@@ -38,7 +38,7 @@ const common = {
plugins: [],
resolve: {
extensions: [".tsx", ".ts", ".js", ".json"],
plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.renderer.json" })],
plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.json" })],
symlinks: false,
modules: [path.resolve("node_modules")],
},
@@ -55,6 +55,9 @@ const renderer = {
node: {
__dirname: false,
},
externals: {
"dc-native": "commonjs2 dc-native",
},
entry: {
"app/main": "./src/app/main.ts",
},
@@ -113,7 +116,7 @@ const renderer = {
},
plugins: [
new AngularWebpackPlugin({
tsConfigPath: "tsconfig.renderer.json",
tsConfigPath: "tsconfig.json",
entryModule: "src/app/app.module#AppModule",
sourceMap: true,
}),