1
0
mirror of https://github.com/bitwarden/directory-connector synced 2026-01-10 20:43:52 +00:00

Compare commits

..

18 Commits

Author SHA1 Message Date
Brandon
5761a391f7 wip 2026-01-09 17:01:26 -05:00
Brandon
8cd2850e8d add docs and tests 2026-01-09 12:05:14 -05:00
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
renovate[bot]
1af8fc1067 [deps]: Update gh minor (#955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-08 15:30:59 +10:00
renovate[bot]
6c2f54bad5 [deps]: Update webpack to v5.104.1 (#963)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-08 14:51:53 +10:00
renovate[bot]
bb9a6a61ee [deps]: Update sass to v1.97.1 (#956)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-06 15:00:07 -05:00
renovate[bot]
f0a19b6267 [deps]: Update actions/upload-artifact action to v6 (#958)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-06 14:51:01 +00:00
Mick Letofsky
220d6c02c7 Revert review Code Triggered by labeled event (#962) 2025-12-31 11:04:31 -05:00
Mick Letofsky
321db6e771 Review Code Triggered by labeled event (#961) 2025-12-30 18:15:46 +01:00
Daniel James Smith
554e14d7a8 Update copyright year to 2026 (#960) 2025-12-30 07:50:18 +10:00
renovate[bot]
f195e27938 [deps]: Update typescript-eslint monorepo to v8.50.0 (#957)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-23 10:33:04 +00:00
renovate[bot]
d1ac1e667e [deps]: Update eslint to v9 (#867)
* [deps]: Update eslint to v9

* resolve lint errors, upgrade eslint, replace unmaintained packages

* refresh lockfile

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Brandon <btreston@bitwarden.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
2025-12-19 19:48:36 -06:00
Mick Letofsky
b9867b131f Remove additional code review prompt file (#954) 2025-12-19 16:57:48 +01:00
renovate[bot]
bb165441ee [deps]: Update @types/node to v22.19.2 (#910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 14:58:52 -06:00
renovate[bot]
b8964aa382 [deps]: Update angular-eslint monorepo to v20.7.0 (#940)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 11:47:23 -06:00
Vincent Salucci
db5268ccd1 chore: bump version to 2025.12.0 (#952)
* chore: bump version to 2025.12.0

* chore: npm install to update package-lock
2025-12-15 13:44:21 -06:00
renovate[bot]
9a719c9e4e [deps]: Update glob to v13 (#950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 14:35:36 -06:00
renovate[bot]
2f49f4d5f1 [deps]: Update jest-mock-extended to v4 (#868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 14:42:43 +00:00
57 changed files with 1470 additions and 1561 deletions

View File

@@ -1,27 +0,0 @@
Please review this pull request with a focus on:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
Note: The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code
snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note
what was done well without explaining why or praising excessively.

View File

@@ -1,11 +0,0 @@
dist
build
build-cli
coverage
webpack.cli.js
webpack.main.js
webpack.renderer.js
**/node_modules
**/jest.config.js

View File

@@ -1,95 +0,0 @@
{
"root": true,
"env": {
"browser": true,
"node": true
},
"overrides": [
{
"files": ["*.ts", "*.js"],
"plugins": ["@typescript-eslint", "rxjs", "rxjs-angular", "import"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": ["./tsconfig.eslint.json"],
"sourceType": "module",
"ecmaVersion": 2020
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"prettier",
"plugin:rxjs/recommended"
],
"settings": {
"import/parsers": {
"@typescript-eslint/parser": [".ts"]
},
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
},
"rules": {
"@typescript-eslint/explicit-member-accessibility": [
"error",
{ "accessibility": "no-public" }
],
"@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled
"@typescript-eslint/no-misused-promises": ["error", { "checksVoidReturn": false }],
"@typescript-eslint/no-this-alias": ["error", { "allowedNames": ["self"] }],
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"no-console": "error",
"import/no-unresolved": "off", // TODO: Look into turning off once each package is an actual package.
"import/order": [
"error",
{
"alphabetize": {
"order": "asc"
},
"newlines-between": "always",
"pathGroups": [
{
"pattern": "@/jslib/**/*",
"group": "external",
"position": "after"
},
{
"pattern": "@/src/**/*",
"group": "parent",
"position": "before"
}
],
"pathGroupsExcludedImportTypes": ["builtin"]
}
],
"rxjs-angular/prefer-takeuntil": "error",
"rxjs/no-exposed-subjects": ["error", { "allowProtected": true }],
"no-restricted-syntax": [
"error",
{
"message": "Calling `svgIcon` directly is not allowed",
"selector": "CallExpression[callee.name='svgIcon']"
},
{
"message": "Accessing FormGroup using `get` is not allowed, use `.value` instead",
"selector": "ChainExpression[expression.object.callee.property.name='get'][expression.property.name='value']"
}
],
"curly": ["error", "all"],
"import/namespace": ["off"], // This doesn't resolve namespace imports correctly, but TS will throw for this anyway
"no-restricted-imports": ["error", { "patterns": ["src/**/*"] }]
}
},
{
"files": ["*.html"],
"parser": "@angular-eslint/template-parser",
"plugins": ["@angular-eslint/template"],
"rules": {
"@angular-eslint/template/button-has-type": "error"
}
}
]
}

View File

@@ -56,7 +56,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -111,7 +111,7 @@ jobs:
fi
- name: Upload Linux Zip to GitHub
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: bwdc-linux-${{ env._PACKAGE_VERSION }}.zip
path: ./dist-cli/bwdc-linux-${{ env._PACKAGE_VERSION }}.zip
@@ -134,7 +134,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -182,7 +182,7 @@ jobs:
fi
- name: Upload Mac Zip to GitHub
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: bwdc-macos-${{ env._PACKAGE_VERSION }}.zip
path: ./dist-cli/bwdc-macos-${{ env._PACKAGE_VERSION }}.zip
@@ -209,7 +209,7 @@ jobs:
choco install checksum --no-progress
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -258,7 +258,7 @@ jobs:
}
- name: Upload Windows Zip to GitHub
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: bwdc-windows-${{ env._PACKAGE_VERSION }}.zip
path: ./dist-cli/bwdc-windows-${{ env._PACKAGE_VERSION }}.zip
@@ -284,7 +284,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -338,28 +338,28 @@ jobs:
SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }}
- name: Upload Portable Executable to GitHub
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Bitwarden-Connector-Portable-${{ env._PACKAGE_VERSION }}.exe
path: ./dist/Bitwarden-Connector-Portable-${{ env._PACKAGE_VERSION }}.exe
if-no-files-found: error
- name: Upload Installer Executable to GitHub
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe
path: ./dist/Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe
if-no-files-found: error
- name: Upload Installer Executable Blockmap to GitHub
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe.blockmap
path: ./dist/Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe.blockmap
if-no-files-found: error
- name: Upload latest auto-update artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: latest.yml
path: ./dist/latest.yml
@@ -384,7 +384,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -411,14 +411,14 @@ jobs:
run: npm run dist:lin
- name: Upload AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-x86_64.AppImage
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-x86_64.AppImage
if-no-files-found: error
- name: Upload latest auto-update artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: latest-linux.yml
path: ./dist/latest-linux.yml
@@ -444,7 +444,7 @@ jobs:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -542,28 +542,28 @@ jobs:
CSC_FOR_PULL_REQUEST: true
- name: Upload .zip artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-mac.zip
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-mac.zip
if-no-files-found: error
- name: Upload .dmg artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg
if-no-files-found: error
- name: Upload .dmg Blockmap artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg.blockmap
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg.blockmap
if-no-files-found: error
- name: Upload latest auto-update artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: latest-mac.yml
path: ./dist/latest-mac.yml

View File

@@ -52,7 +52,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -129,7 +129,7 @@ jobs:
- name: Report test results
id: report
uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2.1.1
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
# PRs from the repository and all other events are OK.
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
@@ -140,7 +140,7 @@ jobs:
fail-on-error: true
- name: Upload coverage to codecov.io
uses: codecov/codecov-action@5a605bd92782ce0810fa3b8acc235c921b497052 # v5.2.0
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- name: Upload results to codecov.io
uses: codecov/test-results-action@4e79e65778be1cecd5df25e14af1eafb6df80ea9 # v1.0.2
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1

View File

@@ -75,7 +75,7 @@ jobs:
- name: Create release
if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0
env:
PKG_VERSION: ${{ needs.setup.outputs.release_version }}
with:

View File

@@ -2,7 +2,7 @@ name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened]
permissions: {}

View File

@@ -34,7 +34,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -53,7 +53,7 @@ jobs:
run: npm run test --coverage
- name: Report test results
uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2.1.1
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
# PRs from the repository and all other events are OK.
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
@@ -64,7 +64,7 @@ jobs:
fail-on-error: true
- name: Upload coverage to codecov.io
uses: codecov/codecov-action@5a605bd92782ce0810fa3b8acc235c921b497052 # v5.2.0
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- name: Upload results to codecov.io
uses: codecov/test-results-action@4e79e65778be1cecd5df25e14af1eafb6df80ea9 # v1.0.2
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1

View File

@@ -42,7 +42,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}

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

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

View File

@@ -4,7 +4,7 @@
},
"productName": "Bitwarden Directory Connector",
"appId": "com.bitwarden.directory-connector",
"copyright": "Copyright © 2015-2022 Bitwarden Inc.",
"copyright": "Copyright © 2015-2026 Bitwarden Inc.",
"directories": {
"buildResources": "resources",
"output": "dist",

149
eslint.config.mjs Normal file
View File

@@ -0,0 +1,149 @@
// @ts-check
import eslint from "@eslint/js";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import prettierConfig from "eslint-config-prettier";
import importPlugin from "eslint-plugin-import";
import rxjsX from "eslint-plugin-rxjs-x";
import rxjsAngularX from "eslint-plugin-rxjs-angular-x";
import angularEslint from "@angular-eslint/eslint-plugin-template";
import angularParser from "@angular-eslint/template-parser";
import globals from "globals";
export default [
// Global ignores (replaces .eslintignore)
{
ignores: [
"dist/**",
"dist-cli/**",
"build/**",
"build-cli/**",
"coverage/**",
"**/*.cjs",
"eslint.config.mjs",
"scripts/**/*.js",
"**/node_modules/**",
],
},
// Base config for all JavaScript/TypeScript files
{
files: ["**/*.ts", "**/*.js"],
languageOptions: {
ecmaVersion: 2020,
sourceType: "module",
parser: tsParser,
parserOptions: {
project: ["./tsconfig.eslint.json"],
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
"@typescript-eslint": tsPlugin,
import: importPlugin,
"rxjs-x": rxjsX,
"rxjs-angular-x": rxjsAngularX,
},
settings: {
"import/parsers": {
"@typescript-eslint/parser": [".ts"],
},
"import/resolver": {
typescript: {
alwaysTryTypes: true,
},
},
},
rules: {
// ESLint recommended rules
...eslint.configs.recommended.rules,
// TypeScript ESLint recommended rules
...tsPlugin.configs.recommended.rules,
// Import plugin recommended rules
...importPlugin.flatConfigs.recommended.rules,
// RxJS recommended rules
...rxjsX.configs.recommended.rules,
// Custom project rules
"@typescript-eslint/explicit-member-accessibility": ["error", { accessibility: "no-public" }],
"@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
"@typescript-eslint/no-this-alias": ["error", { allowedNames: ["self"] }],
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
"no-console": "error",
"import/no-unresolved": "off", // TODO: Look into turning on once each package is an actual package.
"import/order": [
"error",
{
alphabetize: {
order: "asc",
},
"newlines-between": "always",
pathGroups: [
{
pattern: "@/jslib/**/*",
group: "external",
position: "after",
},
{
pattern: "@/src/**/*",
group: "parent",
position: "before",
},
],
pathGroupsExcludedImportTypes: ["builtin"],
},
],
"rxjs-angular-x/prefer-takeuntil": "error",
"rxjs-x/no-exposed-subjects": ["error", { allowProtected: true }],
"no-restricted-syntax": [
"error",
{
message: "Calling `svgIcon` directly is not allowed",
selector: "CallExpression[callee.name='svgIcon']",
},
{
message: "Accessing FormGroup using `get` is not allowed, use `.value` instead",
selector:
"ChainExpression[expression.object.callee.property.name='get'][expression.property.name='value']",
},
],
curly: ["error", "all"],
"import/namespace": ["off"], // This doesn't resolve namespace imports correctly, but TS will throw for this anyway
"no-restricted-imports": ["error", { patterns: ["src/**/*"] }],
},
},
// Jest test files (includes any test-related files)
{
files: ["**/*.spec.ts", "**/test.setup.ts", "**/spec/**/*.ts", "**/utils/**/*fixtures*.ts"],
languageOptions: {
globals: {
...globals.jest,
},
},
},
// Angular HTML templates
{
files: ["**/*.html"],
languageOptions: {
parser: angularParser,
},
plugins: {
"@angular-eslint/template": angularEslint,
},
rules: {
"@angular-eslint/template/button-has-type": "error",
},
},
// Prettier config (must be last to override other configs)
prettierConfig,
];

View File

@@ -26,7 +26,6 @@ module.exports = {
modulePaths: [compilerOptions.baseUrl],
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

View File

@@ -1,5 +1,4 @@
import { Observable, Subject } from "rxjs";
import { first } from "rxjs/operators";
import { lastValueFrom, Observable, Subject } from "rxjs";
export class ModalRef {
onCreated: Observable<HTMLElement>; // Modal added to the DOM.
@@ -45,6 +44,6 @@ export class ModalRef {
}
onClosedPromise(): Promise<any> {
return this.onClosed.pipe(first()).toPromise();
return lastValueFrom(this.onClosed);
}
}

View File

@@ -1,7 +1,7 @@
import { Directive, ElementRef, Input, NgZone } from "@angular/core";
import { take } from "rxjs/operators";
import { take } from "rxjs";
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
@Directive({
selector: "[appAutofocus]",

View File

@@ -9,7 +9,7 @@ import {
Type,
ViewContainerRef,
} from "@angular/core";
import { first } from "rxjs/operators";
import { first, firstValueFrom } from "rxjs";
import { DynamicModalComponent } from "../components/modal/dynamic-modal.component";
import { ModalInjector } from "../components/modal/modal-injector";
@@ -58,7 +58,7 @@ export class ModalService {
viewContainerRef.insert(modalComponentRef.hostView);
await modalRef.onCreated.pipe(first()).toPromise();
await firstValueFrom(modalRef.onCreated);
return [modalRef, modalComponentRef.instance.componentRef.instance];
}

View File

@@ -17,48 +17,45 @@ describe("SymmetricCryptoKey", () => {
const key = makeStaticByteArray(32);
const cryptoKey = new SymmetricCryptoKey(key);
expect(cryptoKey.encType).toBe(0);
expect(cryptoKey.keyB64).toBe("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=");
expect(cryptoKey.encKeyB64).toBe("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=");
expect(cryptoKey.macKey).toBeNull();
expect(cryptoKey.key).toBeInstanceOf(ArrayBuffer);
expect(cryptoKey.encKey).toBeInstanceOf(ArrayBuffer);
expect(cryptoKey.key.byteLength).toBe(32);
expect(cryptoKey.encKey.byteLength).toBe(32);
expect(cryptoKey).toEqual({
encKey: key,
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
encType: 0,
key: key,
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
macKey: null,
});
});
it("AesCbc128_HmacSha256_B64", () => {
const key = makeStaticByteArray(32);
const cryptoKey = new SymmetricCryptoKey(key, EncryptionType.AesCbc128_HmacSha256_B64);
// After TS 5.9 upgrade, properties are ArrayBuffer not Uint8Array
expect(cryptoKey.encType).toBe(1);
expect(cryptoKey.keyB64).toBe("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=");
expect(cryptoKey.encKeyB64).toBe("AAECAwQFBgcICQoLDA0ODw==");
expect(cryptoKey.macKeyB64).toBe("EBESExQVFhcYGRobHB0eHw==");
expect(cryptoKey.key).toBeInstanceOf(ArrayBuffer);
expect(cryptoKey.encKey).toBeInstanceOf(ArrayBuffer);
expect(cryptoKey.macKey).toBeInstanceOf(ArrayBuffer);
expect(cryptoKey.key.byteLength).toBe(32);
expect(cryptoKey.encKey.byteLength).toBe(16);
expect(cryptoKey.macKey.byteLength).toBe(16);
expect(cryptoKey).toEqual({
encKey: key.slice(0, 16),
encKeyB64: "AAECAwQFBgcICQoLDA0ODw==",
encType: 1,
key: key,
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
macKey: key.slice(16, 32),
macKeyB64: "EBESExQVFhcYGRobHB0eHw==",
});
});
it("AesCbc256_HmacSha256_B64", () => {
const key = makeStaticByteArray(64);
const cryptoKey = new SymmetricCryptoKey(key);
// After TS 5.9 upgrade, properties are ArrayBuffer not Uint8Array
expect(cryptoKey.encType).toBe(2);
expect(cryptoKey.keyB64).toBe("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==");
expect(cryptoKey.encKeyB64).toBe("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=");
expect(cryptoKey.macKeyB64).toBe("ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=");
expect(cryptoKey.key).toBeInstanceOf(ArrayBuffer);
expect(cryptoKey.encKey).toBeInstanceOf(ArrayBuffer);
expect(cryptoKey.macKey).toBeInstanceOf(ArrayBuffer);
expect(cryptoKey.key.byteLength).toBe(64);
expect(cryptoKey.encKey.byteLength).toBe(32);
expect(cryptoKey.macKey.byteLength).toBe(32);
expect(cryptoKey).toEqual({
encKey: key.slice(0, 32),
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
encType: 2,
key: key,
keyB64:
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==",
macKey: key.slice(32, 64),
macKeyB64: "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=",
});
});
it("unknown length", () => {

View File

@@ -8,15 +8,12 @@ declare let console: any;
export function interceptConsole(interceptions: any): object {
console = {
log: function () {
// eslint-disable-next-line
interceptions.log = arguments;
},
warn: function () {
// eslint-disable-next-line
interceptions.warn = arguments;
},
error: function () {
// eslint-disable-next-line
interceptions.error = arguments;
},
};

View File

@@ -33,5 +33,5 @@ export function makeStaticByteArray(length: number, start = 0) {
for (let i = 0; i < length; i++) {
arr[i] = start + i;
}
return arr;
return arr.buffer;
}

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) as ArrayBuffer;
}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-useless-escape */
import url from "url";
import * as url from "url";
import { I18nService } from "../abstractions/i18n.service";
@@ -7,7 +7,7 @@ import * as tldjs from "tldjs";
const nodeURL = typeof window === "undefined" ? url : null;
class Utils {
export class Utils {
static inited = false;
static isNode = false;
static isBrowser = true;
@@ -38,9 +38,7 @@ class Utils {
static fromB64ToArray(str: string): Uint8Array<ArrayBuffer> {
if (Utils.isNode) {
const buffer = Buffer.from(str, "base64");
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) as Uint8Array<ArrayBuffer>;
return new Uint8Array(Buffer.from(str, "base64"));
} else {
const binaryString = window.atob(str);
const bytes = new Uint8Array(binaryString.length);
@@ -55,7 +53,7 @@ class Utils {
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 {
@@ -69,9 +67,7 @@ class Utils {
static fromUtf8ToArray(str: string): Uint8Array<ArrayBuffer> {
if (Utils.isNode) {
const buffer = Buffer.from(str, "utf8");
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) as Uint8Array<ArrayBuffer>;
return new Uint8Array(Buffer.from(str, "utf8"));
} else {
const strUtf8 = unescape(encodeURIComponent(str));
const arr = new Uint8Array(strUtf8.length);
@@ -82,7 +78,7 @@ 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);
@@ -90,16 +86,12 @@ class Utils {
return arr;
}
static fromBufferToB64(buffer: BufferSource): string {
static fromBufferToB64(buffer: ArrayBuffer): string {
if (Utils.isNode) {
if (ArrayBuffer.isView(buffer)) {
return Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength).toString("base64");
} else {
return Buffer.from(buffer).toString("base64");
}
return Buffer.from(buffer).toString("base64");
} else {
let binary = "";
const bytes = ArrayBuffer.isView(buffer) ? new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) : new Uint8Array(buffer);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
@@ -107,8 +99,8 @@ class Utils {
}
}
static fromBufferToUrlB64(buffer: BufferSource): string {
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
static fromBufferToUrlB64(buffer: Uint8Array<ArrayBuffer>): string {
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer.buffer));
}
static fromB64toUrlB64(b64Str: string) {
@@ -413,6 +405,4 @@ class Utils {
}
}
export default Utils;
Utils.init();

View File

@@ -1,6 +1,6 @@
import { CryptoService } from "../../abstractions/crypto.service";
import { EncryptionType } from "../../enums/encryptionType";
import Utils from "../../misc/utils";
import { Utils } from "../../misc/utils";
import { SymmetricCryptoKey } from "./symmetricCryptoKey";

View File

@@ -1,5 +1,5 @@
import { EncryptionType } from "../../enums/encryptionType";
import Utils from "../../misc/utils";
import { Utils } from "../../misc/utils";
export class SymmetricCryptoKey {
key: ArrayBuffer;
@@ -13,35 +13,33 @@ export class SymmetricCryptoKey {
meta: any;
constructor(key: BufferSource, encType?: EncryptionType) {
constructor(key: ArrayBuffer, encType?: EncryptionType) {
if (key == null) {
throw new Error("Must provide key");
}
const keyBuffer = ArrayBuffer.isView(key) ? key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) : key;
if (encType == null) {
if (keyBuffer.byteLength === 32) {
if (key.byteLength === 32) {
encType = EncryptionType.AesCbc256_B64;
} else if (keyBuffer.byteLength === 64) {
} else if (key.byteLength === 64) {
encType = EncryptionType.AesCbc256_HmacSha256_B64;
} else {
throw new Error("Unable to determine encType.");
}
}
this.key = keyBuffer;
this.key = key;
this.encType = encType;
if (encType === EncryptionType.AesCbc256_B64 && keyBuffer.byteLength === 32) {
this.encKey = keyBuffer;
if (encType === EncryptionType.AesCbc256_B64 && key.byteLength === 32) {
this.encKey = key;
this.macKey = null;
} else if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && keyBuffer.byteLength === 32) {
this.encKey = keyBuffer.slice(0, 16);
this.macKey = keyBuffer.slice(16, 32);
} else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && keyBuffer.byteLength === 64) {
this.encKey = keyBuffer.slice(0, 32);
this.macKey = keyBuffer.slice(32, 64);
} else if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && key.byteLength === 32) {
this.encKey = key.slice(0, 16);
this.macKey = key.slice(16, 32);
} else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && key.byteLength === 64) {
this.encKey = key.slice(0, 32);
this.macKey = key.slice(32, 64);
} else {
throw new Error("Unsupported encType/key length.");
}

View File

@@ -29,5 +29,4 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect
return obj;
}
}

View File

@@ -12,7 +12,6 @@ export abstract class TokenRequest {
this.device = device != null ? device : null;
}
// eslint-disable-next-line
alterIdentityTokenHeaders(headers: Headers) {
// Implemented in subclass if required
}

View File

@@ -1,4 +1,4 @@
import Utils from "../../misc/utils";
import { Utils } from "../../misc/utils";
import { BaseResponse } from "./baseResponse";

View File

@@ -1,4 +1,4 @@
import Utils from "../../misc/utils";
import { Utils } from "../../misc/utils";
import { BaseResponse } from "./baseResponse";

View File

@@ -7,7 +7,7 @@ import { EnvironmentService } from "../abstractions/environment.service";
import { PlatformUtilsService } from "../abstractions/platformUtils.service";
import { TokenService } from "../abstractions/token.service";
import { DeviceType } from "../enums/deviceType";
import Utils from "../misc/utils";
import { Utils } from "../misc/utils";
import { ApiTokenRequest } from "../models/request/identityToken/apiTokenRequest";
import { PasswordTokenRequest } from "../models/request/identityToken/passwordTokenRequest";
import { SsoTokenRequest } from "../models/request/identityToken/ssoTokenRequest";

View File

@@ -1,7 +1,7 @@
import { AppIdService as AppIdServiceAbstraction } from "../abstractions/appId.service";
import { StorageService } from "../abstractions/storage.service";
import { HtmlStorageLocation } from "../enums/htmlStorageLocation";
import Utils from "../misc/utils";
import { Utils } from "../misc/utils";
export class AppIdService implements AppIdServiceAbstraction {
constructor(private storageService: StorageService) {}

View File

@@ -10,7 +10,7 @@ import { HashPurpose } from "../enums/hashPurpose";
import { KdfType } from "../enums/kdfType";
import { KeySuffixOptions } from "../enums/keySuffixOptions";
import { sequentialize } from "../misc/sequentialize";
import Utils from "../misc/utils";
import { Utils } from "../misc/utils";
import { EEFLongWordList } from "../misc/wordlist";
import { EncArrayBuffer } from "../models/domain/encArrayBuffer";
import { EncString } from "../models/domain/encString";
@@ -109,7 +109,7 @@ export class CryptoService implements CryptoServiceAbstraction {
): Promise<SymmetricCryptoKey> {
const key = await this.retrieveKeyFromStorage(keySuffix, userId);
if (key != null) {
const symmetricKey = new SymmetricCryptoKey(Utils.fromB64ToArray(key));
const symmetricKey = new SymmetricCryptoKey(Utils.fromB64ToArray(key).buffer);
if (!(await this.validateKey(symmetricKey))) {
this.logService.warning("Wrong key, throwing away stored key");
@@ -510,9 +510,9 @@ export class CryptoService implements CryptoServiceAbstraction {
return Promise.resolve(null);
}
let plainBuf: BufferSource;
let plainBuf: ArrayBuffer;
if (typeof plainValue === "string") {
plainBuf = Utils.fromUtf8ToArray(plainValue);
plainBuf = Utils.fromUtf8ToArray(plainValue).buffer;
} else {
plainBuf = plainValue;
}
@@ -585,8 +585,7 @@ export class CryptoService implements CryptoServiceAbstraction {
throw new Error("encPieces unavailable.");
}
const dataArray = Utils.fromB64ToArray(encPieces[0]);
const data = dataArray.buffer as ArrayBuffer;
const data = Utils.fromB64ToArray(encPieces[0]).buffer;
const privateKey = privateKeyValue ?? (await this.getPrivateKey());
if (privateKey == null) {
throw new Error("No private key.");
@@ -609,12 +608,9 @@ export class CryptoService implements CryptoServiceAbstraction {
}
async decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise<ArrayBuffer> {
const ivArray = Utils.fromB64ToArray(encString.iv);
const iv = ivArray.buffer as ArrayBuffer;
const dataArray = Utils.fromB64ToArray(encString.data);
const data = dataArray.buffer as ArrayBuffer;
const macArray = encString.mac ? Utils.fromB64ToArray(encString.mac) : null;
const mac = macArray ? (macArray.buffer as ArrayBuffer) : null;
const iv = Utils.fromB64ToArray(encString.iv).buffer;
const data = Utils.fromB64ToArray(encString.data).buffer;
const mac = encString.mac ? Utils.fromB64ToArray(encString.mac).buffer : null;
const decipher = await this.aesDecryptToBytes(encString.encryptionType, data, iv, mac, key);
if (decipher == null) {
return null;
@@ -640,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:
@@ -671,9 +667,9 @@ export class CryptoService implements CryptoServiceAbstraction {
return await this.aesDecryptToBytes(
encType,
ctBytes.buffer as ArrayBuffer,
ivBytes.buffer as ArrayBuffer,
macBytes != null ? (macBytes.buffer as ArrayBuffer) : null,
ctBytes.buffer,
ivBytes.buffer,
macBytes != null ? macBytes.buffer : null,
key,
);
}
@@ -760,24 +756,17 @@ export class CryptoService implements CryptoServiceAbstraction {
: await this.stateService.getCryptoMasterKeyBiometric({ userId: userId });
}
private async aesEncrypt(data: BufferSource, key: SymmetricCryptoKey): Promise<EncryptedObject> {
private async aesEncrypt(data: ArrayBuffer, key: SymmetricCryptoKey): Promise<EncryptedObject> {
const obj = new EncryptedObject();
obj.key = await this.getKeyForEncryption(key);
obj.iv = await this.cryptoFunctionService.randomBytes(16);
const dataBuffer = ArrayBuffer.isView(data)
? (data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
? data.buffer as ArrayBuffer
: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer)
: data;
obj.data = await this.cryptoFunctionService.aesEncrypt(dataBuffer, obj.iv, obj.key.encKey);
obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, obj.key.encKey);
if (obj.key.macKey != null) {
const macData = new Uint8Array(obj.iv.byteLength + obj.data.byteLength);
macData.set(new Uint8Array(obj.iv), 0);
macData.set(new Uint8Array(obj.data), obj.iv.byteLength);
obj.mac = await this.cryptoFunctionService.hmac(macData.buffer as ArrayBuffer, obj.key.macKey, "sha256");
obj.mac = await this.cryptoFunctionService.hmac(macData.buffer, obj.key.macKey, "sha256");
}
return obj;
@@ -843,7 +832,7 @@ export class CryptoService implements CryptoServiceAbstraction {
macData.set(new Uint8Array(iv), 0);
macData.set(new Uint8Array(data), iv.byteLength);
const computedMac = await this.cryptoFunctionService.hmac(
macData.buffer as ArrayBuffer,
macData.buffer,
theKey.macKey,
"sha256",
);

View File

@@ -38,8 +38,7 @@ const partialKeys = {
export class StateService<
TGlobalState extends GlobalState = GlobalState,
TAccount extends Account = Account,
> implements StateServiceAbstraction<TAccount>
{
> implements StateServiceAbstraction<TAccount> {
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
accounts$ = this.accountsSubject.asObservable();

View File

@@ -1,6 +1,6 @@
import { StateService } from "../abstractions/state.service";
import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service";
import Utils from "../misc/utils";
import { Utils } from "../misc/utils";
import { IdentityTokenResponse } from "../models/response/identityTokenResponse";
export class TokenService implements TokenServiceAbstraction {

View File

@@ -1,6 +1,14 @@
import * as path from "path";
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
import {
app,
BrowserWindow,
Menu,
MenuItemConstructorOptions,
NativeImage,
nativeImage,
Tray,
} from "electron";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { StateService } from "@/jslib/common/src/abstractions/state.service";
@@ -12,8 +20,8 @@ export class TrayMain {
private appName: string;
private tray: Tray;
private icon: string | Electron.NativeImage;
private pressedIcon: Electron.NativeImage;
private icon: string | NativeImage;
private pressedIcon: NativeImage;
constructor(
private windowMain: WindowMain,

View File

@@ -1,7 +1,7 @@
import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, screen } from "electron";
import { app, BrowserWindow, Rectangle, screen } from "electron";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateService } from "@/jslib/common/src/abstractions/state.service";
@@ -14,7 +14,7 @@ export class WindowMain {
win: BrowserWindow;
isQuitting = false;
private windowStateChangeTimer: NodeJS.Timeout;
private windowStateChangeTimer: ReturnType<typeof setTimeout>;
private windowStates: { [key: string]: any } = {};
private enableAlwaysOnTop = false;
@@ -37,7 +37,6 @@ export class WindowMain {
app.quit();
return;
} else {
// eslint-disable-next-line
app.on("second-instance", (event, argv, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (this.win != null) {
@@ -241,7 +240,7 @@ export class WindowMain {
const state = await this.stateService.getWindow();
const isValid = state != null && (this.stateHasBounds(state) || state.isMaximized);
let displayBounds: Electron.Rectangle = null;
let displayBounds: Rectangle = null;
if (!isValid) {
state.width = defaultWidth;
state.height = defaultHeight;

View File

@@ -1,4 +1,4 @@
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
import { SymmetricCryptoKey } from "@/jslib/common/src/models/domain/symmetricCryptoKey";
import { NodeCryptoFunctionService } from "@/jslib/node/src/services/nodeCryptoFunction.service";
@@ -93,9 +93,8 @@ describe("NodeCrypto Function Service", () => {
it("should fail with prk too small", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const prk = Utils.fromB64ToArray(prk16Byte);
const f = cryptoFunctionService.hkdfExpand(
prk.buffer,
Utils.fromB64ToArray(prk16Byte).buffer,
"info",
32,
"sha256",
@@ -105,9 +104,8 @@ describe("NodeCrypto Function Service", () => {
it("should fail with outputByteSize is too large", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const prk = Utils.fromB64ToArray(prk32Byte);
const f = cryptoFunctionService.hkdfExpand(
prk.buffer,
Utils.fromB64ToArray(prk32Byte).buffer,
"info",
8161,
"sha256",
@@ -181,16 +179,16 @@ describe("NodeCrypto Function Service", () => {
it("should successfully encrypt and then decrypt data", async () => {
const nodeCryptoFunctionService = new NodeCryptoFunctionService();
const iv = makeStaticByteArray(16).buffer;
const key = makeStaticByteArray(32).buffer;
const iv = makeStaticByteArray(16);
const key = makeStaticByteArray(32);
const value = "EncryptMe!";
const data = Utils.fromUtf8ToArray(value).buffer;
const data = Utils.fromUtf8ToArray(value);
const encValue = await nodeCryptoFunctionService.aesEncrypt(
data,
iv,
key
data.buffer,
iv.buffer,
key.buffer,
);
const decValue = await nodeCryptoFunctionService.aesDecrypt(encValue, iv, key);
const decValue = await nodeCryptoFunctionService.aesDecrypt(encValue, iv.buffer, key.buffer);
expect(Utils.fromBufferToUtf8(decValue)).toBe(value);
});
});
@@ -198,9 +196,8 @@ describe("NodeCrypto Function Service", () => {
describe("aesDecryptFast", () => {
it("should successfully decrypt data", async () => {
const nodeCryptoFunctionService = new NodeCryptoFunctionService();
const ivArray = makeStaticByteArray(16);
const iv = Utils.fromBufferToB64(ivArray);
const symKey = new SymmetricCryptoKey(makeStaticByteArray(32));
const iv = Utils.fromBufferToB64(makeStaticByteArray(16).buffer);
const symKey = new SymmetricCryptoKey(makeStaticByteArray(32).buffer);
const data = "ByUF8vhyX4ddU9gcooznwA==";
const params = nodeCryptoFunctionService.aesDecryptFastParameters(data, iv, null, symKey);
const decValue = await nodeCryptoFunctionService.aesDecryptFast(params);
@@ -211,13 +208,13 @@ describe("NodeCrypto Function Service", () => {
describe("aesDecrypt", () => {
it("should successfully decrypt data", async () => {
const nodeCryptoFunctionService = new NodeCryptoFunctionService();
const iv = makeStaticByteArray(16).buffer;
const key = makeStaticByteArray(32).buffer;
const data = Utils.fromB64ToArray("ByUF8vhyX4ddU9gcooznwA==").buffer;
const iv = makeStaticByteArray(16);
const key = makeStaticByteArray(32);
const data = Utils.fromB64ToArray("ByUF8vhyX4ddU9gcooznwA==");
const decValue = await nodeCryptoFunctionService.aesDecrypt(
data,
iv,
key,
data.buffer,
iv.buffer,
key.buffer,
);
expect(Utils.fromBufferToUtf8(decValue)).toBe("EncryptMe!");
});
@@ -227,7 +224,7 @@ describe("NodeCrypto Function Service", () => {
it("should successfully encrypt and then decrypt data", async () => {
const nodeCryptoFunctionService = new NodeCryptoFunctionService();
const pubKey = Utils.fromB64ToArray(RsaPublicKey);
const privKey = Utils.fromB64ToArray(RsaPrivateKey).buffer;
const privKey = Utils.fromB64ToArray(RsaPrivateKey);
const value = "EncryptMe!";
const data = Utils.fromUtf8ToArray(value);
const encValue = await nodeCryptoFunctionService.rsaEncrypt(
@@ -235,7 +232,7 @@ describe("NodeCrypto Function Service", () => {
pubKey.buffer,
"sha1",
);
const decValue = await nodeCryptoFunctionService.rsaDecrypt(encValue, privKey, "sha1");
const decValue = await nodeCryptoFunctionService.rsaDecrypt(encValue, privKey.buffer, "sha1");
expect(Utils.fromBufferToUtf8(decValue)).toBe(value);
});
});
@@ -262,8 +259,8 @@ describe("NodeCrypto Function Service", () => {
describe("rsaExtractPublicKey", () => {
it("should successfully extract key", async () => {
const nodeCryptoFunctionService = new NodeCryptoFunctionService();
const privKey = Utils.fromB64ToArray(RsaPrivateKey).buffer;
const publicKey = await nodeCryptoFunctionService.rsaExtractPublicKey(privKey);
const privKey = Utils.fromB64ToArray(RsaPrivateKey);
const publicKey = await nodeCryptoFunctionService.rsaExtractPublicKey(privKey.buffer);
expect(Utils.fromBufferToB64(publicKey)).toBe(RsaPublicKey);
});
});
@@ -344,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";
@@ -356,26 +353,26 @@ function testHkdf(
it("should create valid " + algorithm + " key from regular input", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const key = await cryptoFunctionService.hkdf(ikm.buffer, regularSalt, regularInfo, 32, algorithm);
const key = await cryptoFunctionService.hkdf(ikm, regularSalt, regularInfo, 32, algorithm);
expect(Utils.fromBufferToB64(key)).toBe(regularKey);
});
it("should create valid " + algorithm + " key from utf8 input", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const key = await cryptoFunctionService.hkdf(ikm.buffer, utf8Salt, utf8Info, 32, algorithm);
const key = await cryptoFunctionService.hkdf(ikm, utf8Salt, utf8Info, 32, algorithm);
expect(Utils.fromBufferToB64(key)).toBe(utf8Key);
});
it("should create valid " + algorithm + " key from unicode input", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const key = await cryptoFunctionService.hkdf(ikm.buffer, unicodeSalt, unicodeInfo, 32, algorithm);
const key = await cryptoFunctionService.hkdf(ikm, unicodeSalt, unicodeInfo, 32, algorithm);
expect(Utils.fromBufferToB64(key)).toBe(unicodeKey);
});
it("should create valid " + algorithm + " key from array buffer input", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const key = await cryptoFunctionService.hkdf(
ikm.buffer,
ikm,
Utils.fromUtf8ToArray(regularSalt).buffer,
Utils.fromUtf8ToArray(regularInfo).buffer,
32,
@@ -395,9 +392,8 @@ function testHkdfExpand(
it("should create valid " + algorithm + " " + outputByteSize + " byte okm", async () => {
const cryptoFunctionService = new NodeCryptoFunctionService();
const prk = Utils.fromB64ToArray(b64prk);
const okm = await cryptoFunctionService.hkdfExpand(
prk.buffer,
Utils.fromB64ToArray(b64prk).buffer,
info,
outputByteSize,
algorithm,

View File

@@ -8,7 +8,7 @@ import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
import { NodeUtils } from "@/jslib/common/src/misc/nodeUtils";
import { sequentialize } from "@/jslib/common/src/misc/sequentialize";
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
export class LowdbStorageService implements StorageService {
protected dataFilePath: string;

View File

@@ -3,7 +3,7 @@ import * as crypto from "crypto";
import * as forge from "node-forge";
import { CryptoFunctionService } from "@/jslib/common/src/abstractions/cryptoFunction.service";
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
import { DecryptParameters } from "@/jslib/common/src/models/domain/decryptParameters";
import { SymmetricCryptoKey } from "@/jslib/common/src/models/domain/symmetricCryptoKey";
@@ -147,22 +147,19 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
): DecryptParameters<ArrayBuffer> {
const p = new DecryptParameters<ArrayBuffer>();
p.encKey = key.encKey;
const dataArr = Utils.fromB64ToArray(data);
p.data = dataArr.buffer.slice(dataArr.byteOffset, dataArr.byteOffset + dataArr.byteLength) as ArrayBuffer;
const ivArr = Utils.fromB64ToArray(iv);
p.iv = ivArr.buffer.slice(ivArr.byteOffset, ivArr.byteOffset + ivArr.byteLength) as ArrayBuffer;
p.data = Utils.fromB64ToArray(data).buffer;
p.iv = Utils.fromB64ToArray(iv).buffer;
const macData = new Uint8Array(p.iv.byteLength + p.data.byteLength);
macData.set(new Uint8Array(p.iv), 0);
macData.set(new Uint8Array(p.data), p.iv.byteLength);
p.macData = macData.buffer.slice(macData.byteOffset, macData.byteOffset + macData.byteLength) as ArrayBuffer;
p.macData = macData.buffer;
if (key.macKey != null) {
p.macKey = key.macKey;
}
if (mac != null) {
const macArr = Utils.fromB64ToArray(mac);
p.mac = macArr.buffer.slice(macArr.byteOffset, macArr.byteOffset + macArr.byteLength) as ArrayBuffer;
p.mac = Utils.fromB64ToArray(mac).buffer;
}
return p;
@@ -218,8 +215,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
const publicKeyAsn1 = forge.pki.publicKeyToAsn1(forgePublicKey);
const publicKeyByteString = forge.asn1.toDer(publicKeyAsn1).data;
const publicKeyArray = Utils.fromByteStringToArray(publicKeyByteString);
return Promise.resolve(publicKeyArray.buffer as ArrayBuffer);
return Promise.resolve(publicKeyArray.buffer);
}
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[ArrayBuffer, ArrayBuffer]> {
@@ -245,7 +241,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
const privateKeyByteString = forge.asn1.toDer(privateKeyPkcs8).getBytes();
const privateKey = Utils.fromByteStringToArray(privateKeyByteString);
resolve([publicKey.buffer as ArrayBuffer, privateKey.buffer as ArrayBuffer]);
resolve([publicKey.buffer, privateKey.buffer]);
},
);
});
@@ -280,12 +276,9 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
private toArrayBuffer(value: Buffer | string | ArrayBuffer): ArrayBuffer {
let buf: ArrayBuffer;
if (typeof value === "string") {
const arr = Utils.fromUtf8ToArray(value);
buf = arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength) as ArrayBuffer;
} else if (Buffer.isBuffer(value)) {
buf = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer;
buf = Utils.fromUtf8ToArray(value).buffer;
} else {
buf = value;
buf = new Uint8Array(value).buffer;
}
return buf;
}

1632
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "@bitwarden/directory-connector",
"productName": "Bitwarden Directory Connector",
"description": "Sync your user directory to your Bitwarden organization.",
"version": "2025.11.0",
"version": "2025.12.0",
"keywords": [
"bitwarden",
"password",
@@ -31,14 +31,14 @@
"lint": "eslint . && prettier --check .",
"lint:fix": "eslint . --fix",
"build": "concurrently -n Main,Rend -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "webpack --config webpack.main.js",
"build:renderer": "webpack --config webpack.renderer.js",
"build:renderer:watch": "webpack --config webpack.renderer.js --watch",
"build:main": "webpack --config webpack.main.cjs",
"build:renderer": "webpack --config webpack.renderer.cjs",
"build:renderer:watch": "webpack --config webpack.renderer.cjs --watch",
"build:dist": "npm run reset && npm run rebuild && npm run build",
"build:cli": "webpack --config webpack.cli.js",
"build:cli:watch": "webpack --config webpack.cli.js --watch",
"build:cli:prod": "cross-env NODE_ENV=production webpack --config webpack.cli.js",
"build:cli:prod:watch": "cross-env NODE_ENV=production webpack --config webpack.cli.js --watch",
"build:cli": "webpack --config webpack.cli.cjs",
"build:cli:watch": "webpack --config webpack.cli.cjs --watch",
"build:cli:prod": "cross-env NODE_ENV=production webpack --config webpack.cli.cjs",
"build:cli:prod:watch": "cross-env NODE_ENV=production webpack --config webpack.cli.cjs --watch",
"electron": "npm run build:main && concurrently -k -n Main,Rend -c yellow,cyan \"electron --inspect=5858 ./build --watch\" \"npm run build:renderer:watch\"",
"electron:ignore": "npm run build:main && concurrently -k -n Main,Rend -c yellow,cyan \"electron --inspect=5858 --ignore-certificate-errors ./build --watch\" \"npm run build:renderer:watch\"",
"clean:dist": "rimraf --glob ./dist/*",
@@ -74,8 +74,8 @@
},
"devDependencies": {
"@angular-devkit/build-angular": "20.3.3",
"@angular-eslint/eslint-plugin-template": "20.6.0",
"@angular-eslint/template-parser": "20.6.0",
"@angular-eslint/eslint-plugin-template": "20.7.0",
"@angular-eslint/template-parser": "20.7.0",
"@angular/compiler-cli": "20.3.15",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "4.0.1",
@@ -85,13 +85,14 @@
"@types/inquirer": "8.2.10",
"@types/jest": "29.5.14",
"@types/lowdb": "1.0.15",
"@types/node": "22.18.1",
"@types/node": "22.19.2",
"@types/node-fetch": "2.6.12",
"@types/node-forge": "1.3.11",
"@types/proper-lockfile": "4.1.4",
"@types/semver": "7.7.1",
"@types/tldjs": "2.3.4",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@yao-pkg/pkg": "5.16.1",
"clean-webpack-plugin": "4.0.0",
"concurrently": "9.2.0",
@@ -105,20 +106,20 @@
"electron-reload": "2.0.0-alpha.1",
"electron-store": "8.2.0",
"electron-updater": "6.6.2",
"eslint": "8.57.1",
"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": "5.0.3",
"eslint-plugin-rxjs-angular": "2.0.1",
"eslint-plugin-rxjs-angular-x": "0.1.0",
"eslint-plugin-rxjs-x": "0.8.3",
"form-data": "4.0.4",
"glob": "11.1.0",
"glob": "13.0.0",
"html-loader": "5.1.0",
"html-webpack-plugin": "5.6.3",
"husky": "9.1.7",
"jest": "29.7.0",
"jest-junit": "16.0.0",
"jest-mock-extended": "3.0.7",
"jest-mock-extended": "4.0.0",
"jest-preset-angular": "14.6.0",
"lint-staged": "16.2.6",
"mini-css-extract-plugin": "2.9.2",
@@ -128,14 +129,14 @@
"prettier": "3.7.4",
"rimraf": "6.1.0",
"rxjs": "7.8.2",
"sass": "1.94.2",
"sass": "1.97.1",
"sass-loader": "16.0.5",
"ts-jest": "29.4.1",
"ts-loader": "9.5.2",
"tsconfig-paths-webpack-plugin": "4.2.0",
"type-fest": "5.3.0",
"typescript": "5.9.3",
"webpack": "5.103.0",
"webpack": "5.104.1",
"webpack-cli": "6.0.1",
"webpack-merge": "6.0.1",
"webpack-node-externals": "3.0.0",

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { ModalService } from "@/jslib/angular/src/services/modal.service";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
import { AuthService } from "../../abstractions/auth.service";
import { StateService } from "../../abstractions/state.service";
@@ -23,7 +23,7 @@ import { EnvironmentComponent } from "./environment.component";
// The only subscription in this component is closed from a child component, confusing eslint.
// https://github.com/cartant/eslint-plugin-rxjs-angular/blob/main/docs/rules/prefer-takeuntil.md
//
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
// eslint-disable-next-line rxjs-angular-x/prefer-takeuntil
export class ApiKeyComponent {
@ViewChild("environment", { read: ViewContainerRef, static: true })
environmentModal: ViewContainerRef;
@@ -100,7 +100,7 @@ export class ApiKeyComponent {
this.environmentModal,
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
// eslint-disable-next-line rxjs-angular-x/prefer-takeuntil
childComponent.onSaved.pipe(takeUntil(modalRef.onClosed)).subscribe(() => {
modalRef.close();
});

View File

@@ -3,7 +3,7 @@ import * as inquirer from "inquirer";
import { Response } from "@/jslib/node/src/cli/models/response";
import { MessageResponse } from "@/jslib/node/src/cli/models/response/messageResponse";
import Utils from "../../jslib/common/src/misc/utils";
import { Utils } from "../../jslib/common/src/misc/utils";
import { AuthService } from "../abstractions/auth.service";
export class LoginCommand {

View File

@@ -9,7 +9,7 @@ import { MenuMain } from "./menu.main";
const SyncCheckInterval = 60 * 1000; // 1 minute
export class MessagingMain {
private syncTimeout: NodeJS.Timeout;
private syncTimeout: ReturnType<typeof setTimeout>;
constructor(
private windowMain: WindowMain,

View File

@@ -3,7 +3,7 @@ import * as path from "path";
import * as chalk from "chalk";
import { Command, OptionValues } from "commander";
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
import { BaseProgram } from "@/jslib/node/src/cli/baseProgram";
import { UpdateCommand } from "@/jslib/node/src/cli/commands/update.command";
import { Response } from "@/jslib/node/src/cli/models/response";

View File

@@ -3,7 +3,7 @@ import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { ApiService } from "@/jslib/common/src/abstractions/api.service";
import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
import {
AccountKeys,
AccountProfile,

View File

@@ -132,7 +132,7 @@ export class EntraIdDirectoryService extends BaseDirectoryService implements IDi
}
const setFilter = this.createCustomUserSet(this.syncConfig.userFilter);
// eslint-disable-next-line
while (true) {
const users: graphType.User[] = res.value;
if (users != null) {
@@ -211,7 +211,7 @@ export class EntraIdDirectoryService extends BaseDirectoryService implements IDi
let auMembers = await this.client
.api(`${this.getGraphApiEndpoint()}/v1.0/directory/administrativeUnits/${p}/members`)
.get();
// eslint-disable-next-line
while (true) {
for (const auMember of auMembers.value) {
const groupId = auMember.id;
@@ -328,7 +328,7 @@ export class EntraIdDirectoryService extends BaseDirectoryService implements IDi
const entries: GroupEntry[] = [];
const groupsReq = this.client.api("/groups");
let res = await groupsReq.get();
// eslint-disable-next-line
while (true) {
const groups: graphType.Group[] = res.value;
if (groups != null) {
@@ -421,7 +421,7 @@ export class EntraIdDirectoryService extends BaseDirectoryService implements IDi
const memReq = this.client.api("/groups/" + group.id + "/members");
let memRes = await memReq.get();
// eslint-disable-next-line
while (true) {
const members: any = memRes.value;
if (members != null) {

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import * as ldapts from "ldapts";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
import { StateService } from "../../abstractions/state.service";
import { DirectoryType } from "../../enums/directoryType";

View File

@@ -1,7 +1,7 @@
import * as lock from "proper-lockfile";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
import { LowdbStorageService as LowdbStorageServiceBase } from "@/jslib/node/src/services/lowdbStorage.service";
export class LowdbStorageService extends LowdbStorageServiceBase {

View File

@@ -116,6 +116,7 @@ describe("SyncService", () => {
stateService.getLastSyncHash.mockResolvedValue("unique hash");
// @ts-expect-error This is a workaround to make the batchsize smaller to trigger the batching logic since its a const.
// eslint-disable-next-line no-import-assign
constants.batchSize = 4;
const syncResult = await syncService.sync(false, false);
@@ -130,6 +131,7 @@ describe("SyncService", () => {
expect(apiService.postPublicImportDirectory).toHaveBeenCalledTimes(7);
// @ts-expect-error Reset batch size to original state.
// eslint-disable-next-line no-import-assign
constants.batchSize = originalBatchSize;
});
});

View File

@@ -97,6 +97,7 @@ describe("SyncService", () => {
stateService.getLastSyncHash.mockResolvedValue("unique hash");
// @ts-expect-error This is a workaround to make the batchsize smaller to trigger the batching logic since its a const.
// eslint-disable-next-line no-import-assign
constants.batchSize = 4;
const mockRequests = new Array(6).fill({
@@ -119,6 +120,7 @@ describe("SyncService", () => {
expect(apiService.postPublicImportDirectory).toHaveBeenCalledWith(mockRequests[5]);
// @ts-expect-error Reset batch size back to original value.
// eslint-disable-next-line no-import-assign
constants.batchSize = originalBatchSize;
});

View File

@@ -3,7 +3,7 @@ import { CryptoFunctionService } from "@/jslib/common/src/abstractions/cryptoFun
import { EnvironmentService } from "@/jslib/common/src/abstractions/environment.service";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
import Utils from "@/jslib/common/src/misc/utils";
import { Utils } from "@/jslib/common/src/misc/utils";
import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest";
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";