mirror of
https://github.com/bitwarden/directory-connector
synced 2026-02-24 16:43:06 +00:00
Compare commits
20 Commits
state-serv
...
ac/pm-1243
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3abd3f0496 | ||
|
|
af430157e0 | ||
|
|
db3e7aa685 | ||
|
|
9a2168c1d7 | ||
|
|
1fd8bf318f | ||
|
|
c472d5e199 | ||
|
|
1a42e76c79 | ||
|
|
1aad9e1cbe | ||
|
|
3059934d4c | ||
|
|
42cf13df08 | ||
|
|
1a9f0a2ca7 | ||
|
|
30b3595de3 | ||
|
|
28f0ff4b24 | ||
|
|
14fc69c810 | ||
|
|
1ad0aea61f | ||
|
|
f41156969c | ||
|
|
39b151b1e0 | ||
|
|
483f26fa6f | ||
|
|
8849385d1b | ||
|
|
a7aff97360 |
23
.github/PULL_REQUEST_TEMPLATE.md
vendored
23
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
|
||||
|
||||
100
.github/workflows/build.yml
vendored
100
.github/workflows/build.yml
vendored
@@ -62,31 +62,25 @@ jobs:
|
||||
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: |
|
||||
@@ -140,31 +134,20 @@ jobs:
|
||||
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: |
|
||||
@@ -215,36 +198,23 @@ jobs:
|
||||
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
|
||||
@@ -290,10 +260,10 @@ jobs:
|
||||
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: |
|
||||
@@ -390,15 +360,13 @@ jobs:
|
||||
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
|
||||
@@ -450,10 +418,8 @@ jobs:
|
||||
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: |
|
||||
|
||||
46
.github/workflows/lint.yml
vendored
Normal file
46
.github/workflows/lint.yml
vendored
Normal 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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -32,6 +32,10 @@ build
|
||||
build-cli
|
||||
.angular/cache
|
||||
|
||||
# Rust build artifacts
|
||||
native/target
|
||||
native/*.node
|
||||
|
||||
# Testing
|
||||
coverage*
|
||||
junit.xml*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -23,6 +23,7 @@ export default [
|
||||
"eslint.config.mjs",
|
||||
"scripts/**/*.js",
|
||||
"**/node_modules/**",
|
||||
"native/**",
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
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: `
|
||||
@if (options.closeButton) {
|
||||
@if (options().closeButton) {
|
||||
<button (click)="remove()" type="button" class="toast-close-button" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
@@ -22,59 +14,64 @@ import {
|
||||
<i></i>
|
||||
</div>
|
||||
<div>
|
||||
@if (title) {
|
||||
<div [class]="options.titleClass" [attr.aria-label]="title">
|
||||
{{ title }}
|
||||
@if (title()) {
|
||||
<div [class]="options().titleClass" [attr.aria-label]="title()">
|
||||
{{ title() }}
|
||||
@if (duplicatesCount) {
|
||||
[{{ duplicatesCount + 1 }}]
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (message && options.enableHtml) {
|
||||
@if (message() && options().enableHtml) {
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-live="polite"
|
||||
[class]="options.messageClass"
|
||||
[innerHTML]="message"
|
||||
[class]="options().messageClass"
|
||||
[innerHTML]="message()"
|
||||
></div>
|
||||
}
|
||||
@if (message && !options.enableHtml) {
|
||||
@if (message() && !options().enableHtml) {
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-live="polite"
|
||||
[class]="options.messageClass"
|
||||
[attr.aria-label]="message"
|
||||
[class]="options().messageClass"
|
||||
[attr.aria-label]="message()"
|
||||
>
|
||||
{{ message }}
|
||||
{{ message() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (options.progressBar) {
|
||||
@if (options().progressBar) {
|
||||
<div>
|
||||
<div class="toast-progress" [style.width]="width + '%'"></div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
animations: [
|
||||
trigger("flyInOut", [
|
||||
state("inactive", style({ opacity: 0 })),
|
||||
state("active", style({ opacity: 1 })),
|
||||
state("removed", style({ opacity: 0 })),
|
||||
transition("inactive => active", animate("{{ easeTime }}ms {{ easing }}")),
|
||||
transition("active => removed", animate("{{ easeTime }}ms {{ easing }}")),
|
||||
]),
|
||||
],
|
||||
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;
|
||||
}
|
||||
}
|
||||
`,
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,13 +17,6 @@ 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++) {
|
||||
|
||||
@@ -3,6 +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
|
||||
Five = 5, // New state service implementation
|
||||
Five = 5, // Migrate Windows keychain credentials from keytar (UTF-8) to desktop_core (UTF-16)
|
||||
Latest = Five,
|
||||
}
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
3498
native/Cargo.lock
generated
Normal file
3498
native/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
native/Cargo.toml
Normal file
27
native/Cargo.toml
Normal 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
5
native/build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
extern crate napi_build;
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
34
native/index.d.ts
vendored
Normal file
34
native/index.d.ts
vendored
Normal 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
67
native/index.js
Normal 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
15
native/package.json
Normal 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
70
native/src/lib.rs
Normal 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
67
native/src/migration.rs
Normal 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)
|
||||
}
|
||||
4800
package-lock.json
generated
4800
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
67
package.json
67
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/directory-connector",
|
||||
"productName": "Bitwarden Directory Connector",
|
||||
"description": "Sync your user directory to your Bitwarden organization.",
|
||||
"version": "2026.1.0",
|
||||
"version": "2026.2.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
@@ -26,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",
|
||||
@@ -75,13 +76,11 @@
|
||||
"devDependencies": {
|
||||
"@angular-eslint/eslint-plugin-template": "21.1.0",
|
||||
"@angular-eslint/template-parser": "21.1.0",
|
||||
"@angular/build": "21.0.5",
|
||||
"@angular/compiler-cli": "21.0.8",
|
||||
"@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": "21.0.5",
|
||||
"@ngtools/webpack": "21.1.2",
|
||||
"@types/inquirer": "8.2.10",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lowdb": "1.0.15",
|
||||
@@ -91,11 +90,10 @@
|
||||
"@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",
|
||||
"babel-loader": "9.2.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",
|
||||
@@ -113,9 +111,9 @@
|
||||
"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",
|
||||
@@ -124,11 +122,10 @@
|
||||
"jest-mock-extended": "4.0.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",
|
||||
@@ -136,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",
|
||||
"type-fest": "5.4.2",
|
||||
"typescript": "5.9.3",
|
||||
"webpack": "5.104.1",
|
||||
"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": "21.0.8",
|
||||
"@angular/cdk": "21.0.6",
|
||||
"@angular/cli": "21.0.5",
|
||||
"@angular/common": "21.0.8",
|
||||
"@angular/compiler": "21.0.8",
|
||||
"@angular/core": "21.0.8",
|
||||
"@angular/forms": "21.0.8",
|
||||
"@angular/platform-browser": "21.0.8",
|
||||
"@angular/platform-browser-dynamic": "21.0.8",
|
||||
"@angular/router": "21.0.8",
|
||||
"@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",
|
||||
@@ -165,20 +162,20 @@
|
||||
"googleapis": "149.0.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"inquirer": "8.2.6",
|
||||
"keytar": "7.9.0",
|
||||
"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": {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions";
|
||||
|
||||
import { DirectoryType } from "@/src/enums/directoryType";
|
||||
import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration";
|
||||
import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
|
||||
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
|
||||
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
|
||||
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
|
||||
import { SyncConfiguration } from "@/src/models/syncConfiguration";
|
||||
|
||||
export abstract class StateServiceVNext {
|
||||
getDirectory: <IConfiguration>(type: DirectoryType) => Promise<IConfiguration>;
|
||||
setDirectory: (
|
||||
type: DirectoryType,
|
||||
config:
|
||||
| LdapConfiguration
|
||||
| GSuiteConfiguration
|
||||
| EntraIdConfiguration
|
||||
| OktaConfiguration
|
||||
| OneLoginConfiguration,
|
||||
) => Promise<any>;
|
||||
getLdapConfiguration: (options?: StorageOptions) => Promise<LdapConfiguration>;
|
||||
setLdapConfiguration: (value: LdapConfiguration, options?: StorageOptions) => Promise<void>;
|
||||
getGsuiteConfiguration: (options?: StorageOptions) => Promise<GSuiteConfiguration>;
|
||||
setGsuiteConfiguration: (value: GSuiteConfiguration, options?: StorageOptions) => Promise<void>;
|
||||
getEntraConfiguration: (options?: StorageOptions) => Promise<EntraIdConfiguration>;
|
||||
setEntraConfiguration: (value: EntraIdConfiguration, options?: StorageOptions) => Promise<void>;
|
||||
getOktaConfiguration: (options?: StorageOptions) => Promise<OktaConfiguration>;
|
||||
setOktaConfiguration: (value: OktaConfiguration, options?: StorageOptions) => Promise<void>;
|
||||
getOneLoginConfiguration: (options?: StorageOptions) => Promise<OneLoginConfiguration>;
|
||||
setOneLoginConfiguration: (
|
||||
value: OneLoginConfiguration,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
getOrganizationId: (options?: StorageOptions) => Promise<string>;
|
||||
setOrganizationId: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getSync: (options?: StorageOptions) => Promise<SyncConfiguration>;
|
||||
setSync: (value: SyncConfiguration, options?: StorageOptions) => Promise<void>;
|
||||
getDirectoryType: (options?: StorageOptions) => Promise<DirectoryType>;
|
||||
setDirectoryType: (value: DirectoryType, options?: StorageOptions) => Promise<void>;
|
||||
getUserDelta: (options?: StorageOptions) => Promise<string>;
|
||||
setUserDelta: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getLastUserSync: (options?: StorageOptions) => Promise<Date>;
|
||||
setLastUserSync: (value: Date, options?: StorageOptions) => Promise<void>;
|
||||
getLastGroupSync: (options?: StorageOptions) => Promise<Date>;
|
||||
setLastGroupSync: (value: Date, options?: StorageOptions) => Promise<void>;
|
||||
getGroupDelta: (options?: StorageOptions) => Promise<string>;
|
||||
setGroupDelta: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getLastSyncHash: (options?: StorageOptions) => Promise<string>;
|
||||
setLastSyncHash: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getSyncingDir: (options?: StorageOptions) => Promise<boolean>;
|
||||
setSyncingDir: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
clearSyncSettings: (syncHashToo: boolean) => Promise<void>;
|
||||
}
|
||||
@@ -31,14 +31,12 @@ import { DefaultDirectoryFactoryService } from "@/src/services/directory-factory
|
||||
import { SingleRequestBuilder } from "@/src/services/single-request-builder";
|
||||
|
||||
import { AuthService as AuthServiceAbstraction } from "../../abstractions/auth.service";
|
||||
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
|
||||
import { StateService as StateServiceAbstraction } from "../../abstractions/state.service";
|
||||
import { Account } from "../../models/account";
|
||||
import { AuthService } from "../../services/auth.service";
|
||||
import { I18nService } from "../../services/i18n.service";
|
||||
import { StateServiceVNextImplementation } from "../../services/state-service/state-vNext.service";
|
||||
import { StateService } from "../../services/state-service/state.service";
|
||||
import { StateMigrationService } from "../../services/state-service/stateMigration.service";
|
||||
import { StateService } from "../../services/state.service";
|
||||
import { StateMigrationService } from "../../services/stateMigration.service";
|
||||
import { SyncService } from "../../services/sync.service";
|
||||
|
||||
import { AuthGuardService } from "./auth-guard.service";
|
||||
@@ -224,29 +222,6 @@ export function initFactory(
|
||||
StateMigrationServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
// Use new StateServiceVNext with flat key-value structure (new interface)
|
||||
safeProvider({
|
||||
provide: StateServiceVNext,
|
||||
useFactory: (
|
||||
storageService: StorageServiceAbstraction,
|
||||
secureStorageService: StorageServiceAbstraction,
|
||||
logService: LogServiceAbstraction,
|
||||
stateMigrationService: StateMigrationServiceAbstraction,
|
||||
) =>
|
||||
new StateServiceVNextImplementation(
|
||||
storageService,
|
||||
secureStorageService,
|
||||
logService,
|
||||
stateMigrationService,
|
||||
true,
|
||||
),
|
||||
deps: [
|
||||
StorageServiceAbstraction,
|
||||
SECURE_STORAGE,
|
||||
LogServiceAbstraction,
|
||||
StateMigrationServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SingleRequestBuilder,
|
||||
deps: [],
|
||||
@@ -258,12 +233,7 @@ export function initFactory(
|
||||
safeProvider({
|
||||
provide: DirectoryFactoryService,
|
||||
useClass: DefaultDirectoryFactoryService,
|
||||
deps: [
|
||||
LogServiceAbstraction,
|
||||
I18nServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
StateServiceVNext,
|
||||
],
|
||||
deps: [LogServiceAbstraction, I18nServiceAbstraction, StateServiceAbstraction],
|
||||
}),
|
||||
] satisfies SafeProvider[],
|
||||
})
|
||||
|
||||
20
src/bwdc.ts
20
src/bwdc.ts
@@ -18,19 +18,17 @@ import { NodeApiService } from "@/jslib/node/src/services/nodeApi.service";
|
||||
import { NodeCryptoFunctionService } from "@/jslib/node/src/services/nodeCryptoFunction.service";
|
||||
|
||||
import { DirectoryFactoryService } from "./abstractions/directory-factory.service";
|
||||
import { StateServiceVNext } from "./abstractions/state-vNext.service";
|
||||
import { Account } from "./models/account";
|
||||
import { Program } from "./program";
|
||||
import { AuthService } from "./services/auth.service";
|
||||
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 { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service";
|
||||
import { StateService } from "./services/state-service/state.service";
|
||||
import { StateMigrationService } from "./services/state-service/stateMigration.service";
|
||||
import { StateService } from "./services/state.service";
|
||||
import { StateMigrationService } from "./services/stateMigration.service";
|
||||
import { SyncService } from "./services/sync.service";
|
||||
|
||||
// eslint-disable-next-line
|
||||
@@ -55,7 +53,6 @@ export class Main {
|
||||
cryptoFunctionService: NodeCryptoFunctionService;
|
||||
authService: AuthService;
|
||||
syncService: SyncService;
|
||||
stateServiceVNext: StateServiceVNext;
|
||||
stateService: StateService;
|
||||
stateMigrationService: StateMigrationService;
|
||||
directoryFactoryService: DirectoryFactoryService;
|
||||
@@ -103,7 +100,7 @@ export class Main {
|
||||
);
|
||||
this.secureStorageService = plaintextSecrets
|
||||
? this.storageService
|
||||
: new KeytarSecureStorageService(applicationName);
|
||||
: new NativeSecureStorageService(applicationName);
|
||||
|
||||
this.stateMigrationService = new StateMigrationService(
|
||||
this.storageService,
|
||||
@@ -119,14 +116,6 @@ export class Main {
|
||||
process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true",
|
||||
new StateFactory(GlobalState, Account),
|
||||
);
|
||||
// Use new StateServiceVNext with flat key-value structure
|
||||
this.stateServiceVNext = new StateServiceVNextImplementation(
|
||||
this.storageService,
|
||||
this.secureStorageService,
|
||||
this.logService,
|
||||
this.stateMigrationService,
|
||||
process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true",
|
||||
);
|
||||
|
||||
this.cryptoService = new CryptoService(
|
||||
this.cryptoFunctionService,
|
||||
@@ -168,7 +157,6 @@ export class Main {
|
||||
this.logService,
|
||||
this.i18nService,
|
||||
this.stateService,
|
||||
this.stateServiceVNext,
|
||||
);
|
||||
|
||||
this.batchRequestBuilder = new BatchRequestBuilder();
|
||||
|
||||
13
src/main.ts
13
src/main.ts
@@ -11,14 +11,12 @@ import { TrayMain } from "@/jslib/electron/src/tray.main";
|
||||
import { UpdaterMain } from "@/jslib/electron/src/updater.main";
|
||||
import { WindowMain } from "@/jslib/electron/src/window.main";
|
||||
|
||||
import { StateServiceVNext } from "./abstractions/state-vNext.service";
|
||||
import { DCCredentialStorageListener } from "./main/credential-storage-listener";
|
||||
import { MenuMain } from "./main/menu.main";
|
||||
import { MessagingMain } from "./main/messaging.main";
|
||||
import { Account } from "./models/account";
|
||||
import { I18nService } from "./services/i18n.service";
|
||||
import { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service";
|
||||
import { StateService } from "./services/state-service/state.service";
|
||||
import { StateService } from "./services/state.service";
|
||||
|
||||
export class Main {
|
||||
logService: ElectronLogService;
|
||||
@@ -26,7 +24,6 @@ export class Main {
|
||||
storageService: ElectronStorageService;
|
||||
messagingService: ElectronMainMessagingService;
|
||||
credentialStorageListener: DCCredentialStorageListener;
|
||||
stateServiceVNext: StateServiceVNext;
|
||||
stateService: StateService;
|
||||
|
||||
windowMain: WindowMain;
|
||||
@@ -69,14 +66,6 @@ export class Main {
|
||||
true,
|
||||
new StateFactory(GlobalState, Account),
|
||||
);
|
||||
// Use new StateServiceVNext with flat key-value structure
|
||||
this.stateServiceVNext = new StateServiceVNextImplementation(
|
||||
this.storageService,
|
||||
null,
|
||||
this.logService,
|
||||
null,
|
||||
true,
|
||||
);
|
||||
|
||||
this.windowMain = new WindowMain(
|
||||
this.stateService,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
// ===================================================================
|
||||
// vNext Storage Keys (Flat key-value structure)
|
||||
// ===================================================================
|
||||
|
||||
export const StorageKeysVNext = {
|
||||
stateVersion: "stateVersion",
|
||||
directoryType: "directoryType",
|
||||
organizationId: "organizationId",
|
||||
directory_ldap: "directory_ldap",
|
||||
directory_gsuite: "directory_gsuite",
|
||||
directory_entra: "directory_entra",
|
||||
directory_okta: "directory_okta",
|
||||
directory_onelogin: "directory_onelogin",
|
||||
sync: "sync",
|
||||
syncingDir: "syncingDir",
|
||||
};
|
||||
|
||||
export const SecureStorageKeysVNext: { [key: string]: any } = {
|
||||
ldap: "secret_ldap",
|
||||
gsuite: "secret_gsuite",
|
||||
// Azure Active Directory was renamed to Entra ID, but we've kept the old property name
|
||||
// to be backwards compatible with existing configurations.
|
||||
azure: "secret_azure",
|
||||
entra: "secret_entra",
|
||||
okta: "secret_okta",
|
||||
oneLogin: "secret_oneLogin",
|
||||
userDelta: "userDeltaToken",
|
||||
groupDelta: "groupDeltaToken",
|
||||
lastUserSync: "lastUserSync",
|
||||
lastGroupSync: "lastGroupSync",
|
||||
lastSyncHash: "lastSyncHash",
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// Legacy Storage Keys (Account-based hierarchy)
|
||||
// ===================================================================
|
||||
|
||||
export const SecureStorageKeysLegacy = {
|
||||
ldap: "ldapPassword",
|
||||
gsuite: "gsuitePrivateKey",
|
||||
// Azure Active Directory was renamed to Entra ID, but we've kept the old property name
|
||||
// to be backwards compatible with existing configurations.
|
||||
azure: "azureKey",
|
||||
entra: "entraKey",
|
||||
okta: "oktaToken",
|
||||
oneLogin: "oneLoginClientSecret",
|
||||
userDelta: "userDeltaToken",
|
||||
groupDelta: "groupDeltaToken",
|
||||
lastUserSync: "lastUserSync",
|
||||
lastGroupSync: "lastGroupSync",
|
||||
lastSyncHash: "lastSyncHash",
|
||||
};
|
||||
|
||||
export const TempKeys = {
|
||||
tempAccountSettings: "tempAccountSettings",
|
||||
tempDirectoryConfigs: "tempDirectoryConfigs",
|
||||
tempDirectorySettings: "tempDirectorySettings",
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// Migration Storage Keys
|
||||
// ===================================================================
|
||||
|
||||
export const SecureStorageKeysMigration: { [key: string]: any } = {
|
||||
ldap: "ldapPassword",
|
||||
gsuite: "gsuitePrivateKey",
|
||||
azure: "azureKey",
|
||||
entra: "entraIdKey",
|
||||
okta: "oktaToken",
|
||||
oneLogin: "oneLoginClientSecret",
|
||||
directoryConfigPrefix: "directoryConfig_",
|
||||
sync: "syncConfig",
|
||||
directoryType: "directoryType",
|
||||
organizationId: "organizationId",
|
||||
};
|
||||
|
||||
export const MigrationKeys: { [key: string]: any } = {
|
||||
entityId: "entityId",
|
||||
directoryType: "directoryType",
|
||||
organizationId: "organizationId",
|
||||
lastUserSync: "lastUserSync",
|
||||
lastGroupSync: "lastGroupSync",
|
||||
lastSyncHash: "lastSyncHash",
|
||||
syncingDir: "syncingDir",
|
||||
syncConfig: "syncConfig",
|
||||
userDelta: "userDeltaToken",
|
||||
groupDelta: "groupDeltaToken",
|
||||
tempDirectoryConfigs: "tempDirectoryConfigs",
|
||||
tempDirectorySettings: "tempDirectorySettings",
|
||||
};
|
||||
|
||||
export const MigrationStateKeys = {
|
||||
global: "global",
|
||||
authenticatedAccounts: "authenticatedAccounts",
|
||||
};
|
||||
|
||||
export const MigrationClientKeys: { [key: string]: any } = {
|
||||
clientIdOld: "clientId",
|
||||
clientId: "apikey_clientId",
|
||||
clientSecretOld: "clientSecret",
|
||||
clientSecret: "apikey_clientSecret",
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// Shared Constants
|
||||
// ===================================================================
|
||||
|
||||
export const StoredSecurely = "[STORED SECURELY]";
|
||||
@@ -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,11 +12,10 @@ 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";
|
||||
import { StateService } from "./state-service/state.service";
|
||||
import { StateService } from "./state.service";
|
||||
|
||||
const clientId = "organization.CLIENT_ID";
|
||||
const clientSecret = "CLIENT_SECRET";
|
||||
@@ -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(),
|
||||
|
||||
@@ -2,7 +2,6 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "@/jslib/common/src/abstractions/log.service";
|
||||
|
||||
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
|
||||
import { StateServiceVNext } from "../abstractions/state-vNext.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
|
||||
@@ -17,18 +16,12 @@ export class DefaultDirectoryFactoryService implements DirectoryFactoryService {
|
||||
private logService: LogService,
|
||||
private i18nService: I18nService,
|
||||
private stateService: StateService,
|
||||
private stateServiceVNext: StateServiceVNext,
|
||||
) {}
|
||||
|
||||
createService(directoryType: DirectoryType) {
|
||||
switch (directoryType) {
|
||||
case DirectoryType.GSuite:
|
||||
return new GSuiteDirectoryService(
|
||||
this.logService,
|
||||
this.i18nService,
|
||||
this.stateService,
|
||||
this.stateServiceVNext,
|
||||
);
|
||||
return new GSuiteDirectoryService(this.logService, this.i18nService, this.stateService);
|
||||
case DirectoryType.EntraID:
|
||||
return new EntraIdDirectoryService(this.logService, this.i18nService, this.stateService);
|
||||
case DirectoryType.Ldap:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { StateServiceVNext } from "@/src/abstractions/state-vNext.service";
|
||||
|
||||
import { I18nService } from "../../../jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "../../../jslib/common/src/abstractions/log.service";
|
||||
import {
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
import { groupFixtures } from "../../../utils/google-workspace/group-fixtures";
|
||||
import { userFixtures } from "../../../utils/google-workspace/user-fixtures";
|
||||
import { DirectoryType } from "../../enums/directoryType";
|
||||
import { StateService } from "../state-service/state.service";
|
||||
import { StateService } from "../state.service";
|
||||
|
||||
import { GSuiteDirectoryService } from "./gsuite-directory.service";
|
||||
|
||||
@@ -37,7 +35,6 @@ describe("gsuiteDirectoryService", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let stateServiceVNext: MockProxy<StateServiceVNext>;
|
||||
|
||||
let directoryService: GSuiteDirectoryService;
|
||||
|
||||
@@ -45,31 +42,23 @@ describe("gsuiteDirectoryService", () => {
|
||||
logService = mock();
|
||||
i18nService = mock();
|
||||
stateService = mock();
|
||||
stateServiceVNext = mock();
|
||||
|
||||
stateServiceVNext.getDirectoryType.mockResolvedValue(DirectoryType.GSuite);
|
||||
stateService.getDirectoryType.mockResolvedValue(DirectoryType.GSuite);
|
||||
stateService.getLastUserSync.mockResolvedValue(null); // do not filter results by last modified date
|
||||
i18nService.t.mockImplementation((id) => id); // passthrough implementation for any error messages
|
||||
|
||||
directoryService = new GSuiteDirectoryService(
|
||||
logService,
|
||||
i18nService,
|
||||
stateService,
|
||||
stateServiceVNext,
|
||||
);
|
||||
directoryService = new GSuiteDirectoryService(logService, i18nService, stateService);
|
||||
});
|
||||
|
||||
it("syncs without using filters (includes test data)", async () => {
|
||||
const directoryConfig = getGSuiteConfiguration();
|
||||
stateServiceVNext.getDirectory
|
||||
.calledWith(DirectoryType.GSuite)
|
||||
.mockResolvedValue(directoryConfig);
|
||||
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||
|
||||
const syncConfig = getSyncConfiguration({
|
||||
groups: true,
|
||||
users: true,
|
||||
});
|
||||
stateServiceVNext.getSync.mockResolvedValue(syncConfig);
|
||||
stateService.getSync.mockResolvedValue(syncConfig);
|
||||
|
||||
const result = await directoryService.getEntries(true, true);
|
||||
|
||||
@@ -79,9 +68,7 @@ describe("gsuiteDirectoryService", () => {
|
||||
|
||||
it("syncs using user and group filters (exact match for test data)", async () => {
|
||||
const directoryConfig = getGSuiteConfiguration();
|
||||
stateServiceVNext.getDirectory
|
||||
.calledWith(DirectoryType.GSuite)
|
||||
.mockResolvedValue(directoryConfig);
|
||||
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||
|
||||
const syncConfig = getSyncConfiguration({
|
||||
groups: true,
|
||||
@@ -89,7 +76,7 @@ describe("gsuiteDirectoryService", () => {
|
||||
userFilter: INTEGRATION_USER_FILTER,
|
||||
groupFilter: INTEGRATION_GROUP_FILTER,
|
||||
});
|
||||
stateServiceVNext.getSync.mockResolvedValue(syncConfig);
|
||||
stateService.getSync.mockResolvedValue(syncConfig);
|
||||
|
||||
const result = await directoryService.getEntries(true, true);
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import { admin_directory_v1, google } from "googleapis";
|
||||
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "@/jslib/common/src/abstractions/log.service";
|
||||
|
||||
import { StateServiceVNext } from "@/src/abstractions/state-vNext.service";
|
||||
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { DirectoryType } from "../../enums/directoryType";
|
||||
import { GroupEntry } from "../../models/groupEntry";
|
||||
@@ -27,26 +25,25 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
||||
private logService: LogService,
|
||||
private i18nService: I18nService,
|
||||
private stateService: StateService,
|
||||
private stateServiceVNext: StateServiceVNext,
|
||||
) {
|
||||
super();
|
||||
this.service = google.admin("directory_v1");
|
||||
}
|
||||
|
||||
async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {
|
||||
const type = await this.stateServiceVNext.getDirectoryType();
|
||||
const type = await this.stateService.getDirectoryType();
|
||||
if (type !== DirectoryType.GSuite) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dirConfig = await this.stateServiceVNext.getDirectory<GSuiteConfiguration>(
|
||||
this.dirConfig = await this.stateService.getDirectory<GSuiteConfiguration>(
|
||||
DirectoryType.GSuite,
|
||||
);
|
||||
if (this.dirConfig == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncConfig = await this.stateServiceVNext.getSync();
|
||||
this.syncConfig = await this.stateService.getSync();
|
||||
if (this.syncConfig == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { groupFixtures } from "../../../utils/openldap/group-fixtures";
|
||||
import { userFixtures } from "../../../utils/openldap/user-fixtures";
|
||||
import { DirectoryType } from "../../enums/directoryType";
|
||||
import { StateService } from "../state-service/state.service";
|
||||
import { StateService } from "../state.service";
|
||||
|
||||
import { LdapDirectoryService } from "./ldap-directory.service";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
28
src/services/nativeSecureStorage.service.ts
Normal file
28
src/services/nativeSecureStorage.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { LogService } from "@/jslib/common/src/abstractions/log.service";
|
||||
import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service";
|
||||
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
|
||||
|
||||
import { DirectoryType } from "@/src/enums/directoryType";
|
||||
import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration";
|
||||
import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
|
||||
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
|
||||
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
|
||||
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
|
||||
import { StorageKeysVNext as StorageKeys, StoredSecurely } from "@/src/models/state.model";
|
||||
import { SyncConfiguration } from "@/src/models/syncConfiguration";
|
||||
|
||||
import { StateServiceVNextImplementation } from "./state-vNext.service";
|
||||
|
||||
describe("StateServiceVNextImplementation", () => {
|
||||
let storageService: MockProxy<StorageService>;
|
||||
let secureStorageService: MockProxy<StorageService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let stateMigrationService: MockProxy<StateMigrationService>;
|
||||
let stateService: StateServiceVNextImplementation;
|
||||
|
||||
beforeEach(() => {
|
||||
storageService = mock<StorageService>();
|
||||
secureStorageService = mock<StorageService>();
|
||||
logService = mock<LogService>();
|
||||
stateMigrationService = mock<StateMigrationService>();
|
||||
|
||||
stateService = new StateServiceVNextImplementation(
|
||||
storageService,
|
||||
secureStorageService,
|
||||
logService,
|
||||
stateMigrationService,
|
||||
true, // useSecureStorageForSecrets
|
||||
);
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("should run migration if needed", async () => {
|
||||
stateMigrationService.needsMigration.mockResolvedValue(true);
|
||||
|
||||
await stateService.init();
|
||||
|
||||
expect(stateMigrationService.needsMigration).toHaveBeenCalled();
|
||||
expect(stateMigrationService.migrate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not run migration if not needed", async () => {
|
||||
stateMigrationService.needsMigration.mockResolvedValue(false);
|
||||
|
||||
await stateService.init();
|
||||
|
||||
expect(stateMigrationService.needsMigration).toHaveBeenCalled();
|
||||
expect(stateMigrationService.migrate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clean", () => {
|
||||
it("should clear all directory settings and configurations", async () => {
|
||||
await stateService.clean();
|
||||
|
||||
// Verify all directory types are cleared
|
||||
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.directoryType, null);
|
||||
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, null);
|
||||
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Directory Type", () => {
|
||||
it("should store and retrieve directory type", async () => {
|
||||
storageService.get.mockResolvedValue(DirectoryType.Ldap);
|
||||
|
||||
await stateService.setDirectoryType(DirectoryType.Ldap);
|
||||
const result = await stateService.getDirectoryType();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
StorageKeys.directoryType,
|
||||
DirectoryType.Ldap,
|
||||
);
|
||||
expect(result).toBe(DirectoryType.Ldap);
|
||||
});
|
||||
|
||||
it("should return null when directory type is not set", async () => {
|
||||
storageService.get.mockResolvedValue(null);
|
||||
|
||||
const result = await stateService.getDirectoryType();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Id", () => {
|
||||
it("should store and retrieve organization ID", async () => {
|
||||
const orgId = "test-org-123";
|
||||
|
||||
storageService.get.mockResolvedValue(orgId);
|
||||
|
||||
await stateService.setOrganizationId(orgId);
|
||||
const result = await stateService.getOrganizationId();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, orgId);
|
||||
expect(result).toBe(orgId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LDAP Configuration", () => {
|
||||
it("should store and retrieve LDAP configuration with secrets in secure storage", async () => {
|
||||
const config: LdapConfiguration = {
|
||||
ssl: true,
|
||||
startTls: false,
|
||||
tlsCaPath: null,
|
||||
sslAllowUnauthorized: false,
|
||||
sslCertPath: null,
|
||||
sslKeyPath: null,
|
||||
sslCaPath: null,
|
||||
hostname: "ldap.example.com",
|
||||
port: 636,
|
||||
domain: null,
|
||||
rootPath: null,
|
||||
ad: true,
|
||||
username: "admin",
|
||||
password: "secret-password",
|
||||
currentUser: false,
|
||||
pagedSearch: true,
|
||||
};
|
||||
|
||||
secureStorageService.get.mockResolvedValue("secret-password");
|
||||
storageService.get.mockResolvedValue({
|
||||
...config,
|
||||
password: StoredSecurely,
|
||||
});
|
||||
|
||||
await stateService.setDirectory(DirectoryType.Ldap, config);
|
||||
const result = await stateService.getDirectory<LdapConfiguration>(DirectoryType.Ldap);
|
||||
|
||||
// Verify password is stored in secure storage
|
||||
expect(secureStorageService.save).toHaveBeenCalled();
|
||||
|
||||
// Verify configuration is stored
|
||||
expect(storageService.save).toHaveBeenCalled();
|
||||
|
||||
// Verify retrieved config has real password from secure storage
|
||||
expect(result?.password).toBe("secret-password");
|
||||
});
|
||||
|
||||
it("should return null when LDAP configuration is not set", async () => {
|
||||
storageService.get.mockResolvedValue(null);
|
||||
|
||||
const result = await stateService.getLdapConfiguration();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle null password in LDAP configuration", async () => {
|
||||
const config: LdapConfiguration = {
|
||||
ssl: true,
|
||||
startTls: false,
|
||||
tlsCaPath: null,
|
||||
sslAllowUnauthorized: false,
|
||||
sslCertPath: null,
|
||||
sslKeyPath: null,
|
||||
sslCaPath: null,
|
||||
hostname: "ldap.example.com",
|
||||
port: 636,
|
||||
domain: null,
|
||||
rootPath: null,
|
||||
ad: true,
|
||||
username: "admin",
|
||||
password: null,
|
||||
currentUser: false,
|
||||
pagedSearch: true,
|
||||
};
|
||||
|
||||
await stateService.setDirectory(DirectoryType.Ldap, config);
|
||||
|
||||
// Null passwords should call remove on the secure storage secret key
|
||||
expect(secureStorageService.remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GSuite Configuration", () => {
|
||||
it("should store and retrieve GSuite configuration with privateKey in secure storage", async () => {
|
||||
const config: GSuiteConfiguration = {
|
||||
domain: "example.com",
|
||||
clientEmail: "service@example.com",
|
||||
adminUser: "admin@example.com",
|
||||
privateKey: "private-key-content",
|
||||
customer: "customer-id",
|
||||
};
|
||||
|
||||
secureStorageService.get.mockResolvedValue("private-key-content");
|
||||
storageService.get.mockResolvedValue({
|
||||
...config,
|
||||
privateKey: StoredSecurely,
|
||||
});
|
||||
|
||||
await stateService.setDirectory(DirectoryType.GSuite, config);
|
||||
const result = await stateService.getDirectory<GSuiteConfiguration>(DirectoryType.GSuite);
|
||||
|
||||
expect(secureStorageService.save).toHaveBeenCalled();
|
||||
expect(result?.privateKey).toBe("private-key-content");
|
||||
});
|
||||
|
||||
it("should handle null privateKey in GSuite configuration", async () => {
|
||||
const config: GSuiteConfiguration = {
|
||||
domain: "example.com",
|
||||
clientEmail: "service@example.com",
|
||||
adminUser: "admin@example.com",
|
||||
privateKey: null,
|
||||
customer: "customer-id",
|
||||
};
|
||||
|
||||
await stateService.setDirectory(DirectoryType.GSuite, config);
|
||||
|
||||
// Null privateKey should call remove on the secure storage secret key
|
||||
expect(secureStorageService.remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Entra ID Configuration", () => {
|
||||
it("should store and retrieve Entra ID configuration with key in secure storage", async () => {
|
||||
const config: EntraIdConfiguration = {
|
||||
identityAuthority: "https://login.microsoftonline.com",
|
||||
tenant: "tenant-id",
|
||||
applicationId: "app-id",
|
||||
key: "secret-key",
|
||||
};
|
||||
|
||||
secureStorageService.get.mockResolvedValue("secret-key");
|
||||
storageService.get.mockResolvedValue({
|
||||
...config,
|
||||
key: StoredSecurely,
|
||||
});
|
||||
|
||||
await stateService.setDirectory(DirectoryType.EntraID, config);
|
||||
const result = await stateService.getDirectory<EntraIdConfiguration>(DirectoryType.EntraID);
|
||||
|
||||
expect(secureStorageService.save).toHaveBeenCalled();
|
||||
expect(result?.key).toBe("secret-key");
|
||||
});
|
||||
|
||||
it("should maintain backwards compatibility with Azure key storage", async () => {
|
||||
const config: EntraIdConfiguration = {
|
||||
identityAuthority: "https://login.microsoftonline.com",
|
||||
tenant: "tenant-id",
|
||||
applicationId: "app-id",
|
||||
key: StoredSecurely,
|
||||
};
|
||||
|
||||
storageService.get.mockResolvedValue(config);
|
||||
secureStorageService.get.mockResolvedValueOnce(null); // entra key not found
|
||||
secureStorageService.get.mockResolvedValueOnce("azure-secret-key"); // fallback to azure key
|
||||
|
||||
const result = await stateService.getDirectory<EntraIdConfiguration>(DirectoryType.EntraID);
|
||||
|
||||
expect(secureStorageService.get).toHaveBeenCalled();
|
||||
expect(result?.key).toBe("azure-secret-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Okta Configuration", () => {
|
||||
it("should store and retrieve Okta configuration with token in secure storage", async () => {
|
||||
const config: OktaConfiguration = {
|
||||
orgUrl: "https://example.okta.com",
|
||||
token: "okta-token",
|
||||
};
|
||||
|
||||
secureStorageService.get.mockResolvedValue("okta-token");
|
||||
storageService.get.mockResolvedValue({
|
||||
...config,
|
||||
token: StoredSecurely,
|
||||
});
|
||||
|
||||
await stateService.setDirectory(DirectoryType.Okta, config);
|
||||
const result = await stateService.getDirectory<OktaConfiguration>(DirectoryType.Okta);
|
||||
|
||||
expect(secureStorageService.save).toHaveBeenCalled();
|
||||
expect(result?.token).toBe("okta-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OneLogin Configuration", () => {
|
||||
it("should store and retrieve OneLogin configuration with clientSecret in secure storage", async () => {
|
||||
const config: OneLoginConfiguration = {
|
||||
region: "us",
|
||||
clientId: "client-id",
|
||||
clientSecret: "client-secret",
|
||||
};
|
||||
|
||||
secureStorageService.get.mockResolvedValue("client-secret");
|
||||
storageService.get.mockResolvedValue({
|
||||
...config,
|
||||
clientSecret: StoredSecurely,
|
||||
});
|
||||
|
||||
await stateService.setDirectory(DirectoryType.OneLogin, config);
|
||||
const result = await stateService.getDirectory<OneLoginConfiguration>(DirectoryType.OneLogin);
|
||||
|
||||
expect(secureStorageService.save).toHaveBeenCalled();
|
||||
expect(result?.clientSecret).toBe("client-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sync Configuration", () => {
|
||||
it("should store and retrieve sync configuration", async () => {
|
||||
const syncConfig: SyncConfiguration = {
|
||||
users: true,
|
||||
groups: true,
|
||||
interval: 5,
|
||||
userFilter: null,
|
||||
groupFilter: null,
|
||||
removeDisabled: true,
|
||||
overwriteExisting: false,
|
||||
largeImport: false,
|
||||
groupObjectClass: null,
|
||||
userObjectClass: null,
|
||||
groupPath: null,
|
||||
userPath: null,
|
||||
groupNameAttribute: null,
|
||||
userEmailAttribute: null,
|
||||
memberAttribute: "member",
|
||||
creationDateAttribute: "whenCreated",
|
||||
revisionDateAttribute: "whenChanged",
|
||||
useEmailPrefixSuffix: false,
|
||||
emailPrefixAttribute: null,
|
||||
emailSuffix: null,
|
||||
};
|
||||
|
||||
storageService.get.mockResolvedValue(syncConfig);
|
||||
|
||||
await stateService.setSync(syncConfig);
|
||||
const result = await stateService.getSync();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, syncConfig);
|
||||
expect(result).toEqual(syncConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sync Settings", () => {
|
||||
it("should clear sync settings when clearSyncSettings is called", async () => {
|
||||
await stateService.clearSyncSettings(false);
|
||||
|
||||
// Should set delta and sync values to null
|
||||
expect(storageService.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should clear lastSyncHash when hashToo is true", async () => {
|
||||
await stateService.clearSyncSettings(true);
|
||||
|
||||
// Should set all values including lastSyncHash to null
|
||||
expect(storageService.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not clear lastSyncHash when hashToo is false", async () => {
|
||||
await stateService.clearSyncSettings(false);
|
||||
|
||||
// Should set delta and sync values but not lastSyncHash
|
||||
expect(storageService.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Last Sync Hash", () => {
|
||||
it("should store and retrieve last sync hash", async () => {
|
||||
const hash = "hash";
|
||||
|
||||
storageService.get.mockResolvedValue(hash);
|
||||
|
||||
await stateService.setLastSyncHash(hash);
|
||||
const result = await stateService.getLastSyncHash();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalled();
|
||||
expect(result).toBe(hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Delta Tokens", () => {
|
||||
it("should store and retrieve user delta token", async () => {
|
||||
const token = "user-delta-token";
|
||||
|
||||
storageService.get.mockResolvedValue(token);
|
||||
|
||||
await stateService.setUserDelta(token);
|
||||
const result = await stateService.getUserDelta();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalled();
|
||||
expect(result).toBe(token);
|
||||
});
|
||||
|
||||
it("should store and retrieve group delta token", async () => {
|
||||
const token = "group-delta-token";
|
||||
|
||||
storageService.get.mockResolvedValue(token);
|
||||
|
||||
await stateService.setGroupDelta(token);
|
||||
const result = await stateService.getGroupDelta();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalled();
|
||||
expect(result).toBe(token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Last Sync Timestamps", () => {
|
||||
it("should store and retrieve last user sync timestamp", async () => {
|
||||
const timestamp = new Date("2024-01-01T00:00:00Z");
|
||||
|
||||
storageService.get.mockResolvedValue(timestamp.toISOString());
|
||||
|
||||
await stateService.setLastUserSync(timestamp);
|
||||
const result = await stateService.getLastUserSync();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalled();
|
||||
expect(result?.toISOString()).toBe(timestamp.toISOString());
|
||||
});
|
||||
|
||||
it("should store and retrieve last group sync timestamp", async () => {
|
||||
const timestamp = new Date("2024-01-01T00:00:00Z");
|
||||
|
||||
storageService.get.mockResolvedValue(timestamp.toISOString());
|
||||
|
||||
await stateService.setLastGroupSync(timestamp);
|
||||
const result = await stateService.getLastGroupSync();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalled();
|
||||
expect(result?.toISOString()).toBe(timestamp.toISOString());
|
||||
});
|
||||
|
||||
it("should return null when last user sync timestamp is not set", async () => {
|
||||
storageService.get.mockResolvedValue(null);
|
||||
|
||||
const result = await stateService.getLastUserSync();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when last group sync timestamp is not set", async () => {
|
||||
storageService.get.mockResolvedValue(null);
|
||||
|
||||
const result = await stateService.getLastGroupSync();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Secure Storage Flag", () => {
|
||||
it("should not separate secrets when useSecureStorageForSecrets is false", async () => {
|
||||
const insecureStateService = new StateServiceVNextImplementation(
|
||||
storageService,
|
||||
secureStorageService,
|
||||
logService,
|
||||
stateMigrationService,
|
||||
false, // useSecureStorageForSecrets = false
|
||||
);
|
||||
|
||||
const config: LdapConfiguration = {
|
||||
ssl: true,
|
||||
startTls: false,
|
||||
tlsCaPath: null,
|
||||
sslAllowUnauthorized: false,
|
||||
sslCertPath: null,
|
||||
sslKeyPath: null,
|
||||
sslCaPath: null,
|
||||
hostname: "ldap.example.com",
|
||||
port: 636,
|
||||
domain: null,
|
||||
rootPath: null,
|
||||
ad: true,
|
||||
username: "admin",
|
||||
password: "secret-password",
|
||||
currentUser: false,
|
||||
pagedSearch: true,
|
||||
};
|
||||
|
||||
storageService.get.mockResolvedValue(config);
|
||||
|
||||
// When useSecureStorageForSecrets is false, setDirectory doesn't process secrets
|
||||
await insecureStateService.setDirectory(DirectoryType.Ldap, config);
|
||||
|
||||
// Retrieve config - should return password as-is from storage (not from secure storage)
|
||||
const result = await insecureStateService.getDirectory<LdapConfiguration>(DirectoryType.Ldap);
|
||||
|
||||
// Password should be retrieved directly from storage, not secure storage
|
||||
expect(result?.password).toBe("secret-password");
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,409 +0,0 @@
|
||||
import { LogService } from "@/jslib/common/src/abstractions/log.service";
|
||||
import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service";
|
||||
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
|
||||
import { EnvironmentUrls } from "@/jslib/common/src/models/domain/environmentUrls";
|
||||
import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions";
|
||||
|
||||
import { StateServiceVNext as StateServiceVNextAbstraction } from "@/src/abstractions/state-vNext.service";
|
||||
import { DirectoryType } from "@/src/enums/directoryType";
|
||||
import { IConfiguration } from "@/src/models/IConfiguration";
|
||||
import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration";
|
||||
import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
|
||||
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
|
||||
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
|
||||
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
|
||||
import {
|
||||
SecureStorageKeysVNext as SecureStorageKeys,
|
||||
StorageKeysVNext as StorageKeys,
|
||||
StoredSecurely,
|
||||
} from "@/src/models/state.model";
|
||||
import { SyncConfiguration } from "@/src/models/syncConfiguration";
|
||||
|
||||
export class StateServiceVNextImplementation implements StateServiceVNextAbstraction {
|
||||
constructor(
|
||||
protected storageService: StorageService,
|
||||
protected secureStorageService: StorageService,
|
||||
protected logService: LogService,
|
||||
protected stateMigrationService: StateMigrationService,
|
||||
private useSecureStorageForSecrets = true,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (await this.stateMigrationService.needsMigration()) {
|
||||
await this.stateMigrationService.migrate();
|
||||
}
|
||||
}
|
||||
|
||||
async clean(options?: StorageOptions): Promise<void> {
|
||||
// Clear all directory settings and configurations
|
||||
// but preserve version and environment settings
|
||||
await this.setDirectoryType(null);
|
||||
await this.setOrganizationId(null);
|
||||
await this.setSync(null);
|
||||
await this.setLdapConfiguration(null);
|
||||
await this.setGsuiteConfiguration(null);
|
||||
await this.setEntraConfiguration(null);
|
||||
await this.setOktaConfiguration(null);
|
||||
await this.setOneLoginConfiguration(null);
|
||||
await this.clearSyncSettings(true);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Directory Configuration Methods
|
||||
// ===================================================================
|
||||
|
||||
async getDirectory<T extends IConfiguration>(type: DirectoryType): Promise<T> {
|
||||
const config = await this.getConfiguration(type);
|
||||
if (config == null) {
|
||||
return config as T;
|
||||
}
|
||||
|
||||
if (this.useSecureStorageForSecrets) {
|
||||
// Create a copy to avoid modifying the cached config
|
||||
const configWithSecrets = Object.assign({}, config);
|
||||
|
||||
switch (type) {
|
||||
case DirectoryType.Ldap:
|
||||
(configWithSecrets as any).password = await this.getLdapSecret();
|
||||
break;
|
||||
case DirectoryType.EntraID:
|
||||
(configWithSecrets as any).key = await this.getEntraSecret();
|
||||
break;
|
||||
case DirectoryType.Okta:
|
||||
(configWithSecrets as any).token = await this.getOktaSecret();
|
||||
break;
|
||||
case DirectoryType.GSuite:
|
||||
(configWithSecrets as any).privateKey = await this.getGsuiteSecret();
|
||||
break;
|
||||
case DirectoryType.OneLogin:
|
||||
(configWithSecrets as any).clientSecret = await this.getOneLoginSecret();
|
||||
break;
|
||||
}
|
||||
|
||||
return configWithSecrets as T;
|
||||
}
|
||||
|
||||
return config as T;
|
||||
}
|
||||
|
||||
async setDirectory(
|
||||
type: DirectoryType,
|
||||
config:
|
||||
| LdapConfiguration
|
||||
| GSuiteConfiguration
|
||||
| EntraIdConfiguration
|
||||
| OktaConfiguration
|
||||
| OneLoginConfiguration,
|
||||
): Promise<any> {
|
||||
if (this.useSecureStorageForSecrets) {
|
||||
switch (type) {
|
||||
case DirectoryType.Ldap: {
|
||||
const ldapConfig = config as LdapConfiguration;
|
||||
await this.setLdapSecret(ldapConfig.password);
|
||||
ldapConfig.password = StoredSecurely;
|
||||
await this.setLdapConfiguration(ldapConfig);
|
||||
break;
|
||||
}
|
||||
case DirectoryType.EntraID: {
|
||||
const entraConfig = config as EntraIdConfiguration;
|
||||
await this.setEntraSecret(entraConfig.key);
|
||||
entraConfig.key = StoredSecurely;
|
||||
await this.setEntraConfiguration(entraConfig);
|
||||
break;
|
||||
}
|
||||
case DirectoryType.Okta: {
|
||||
const oktaConfig = config as OktaConfiguration;
|
||||
await this.setOktaSecret(oktaConfig.token);
|
||||
oktaConfig.token = StoredSecurely;
|
||||
await this.setOktaConfiguration(oktaConfig);
|
||||
break;
|
||||
}
|
||||
case DirectoryType.GSuite: {
|
||||
const gsuiteConfig = config as GSuiteConfiguration;
|
||||
if (gsuiteConfig.privateKey == null) {
|
||||
await this.setGsuiteSecret(null);
|
||||
} else {
|
||||
const normalizedPrivateKey = gsuiteConfig.privateKey.replace(/\\n/g, "\n");
|
||||
await this.setGsuiteSecret(normalizedPrivateKey);
|
||||
gsuiteConfig.privateKey = StoredSecurely;
|
||||
}
|
||||
await this.setGsuiteConfiguration(gsuiteConfig);
|
||||
break;
|
||||
}
|
||||
case DirectoryType.OneLogin: {
|
||||
const oneLoginConfig = config as OneLoginConfiguration;
|
||||
await this.setOneLoginSecret(oneLoginConfig.clientSecret);
|
||||
oneLoginConfig.clientSecret = StoredSecurely;
|
||||
await this.setOneLoginConfiguration(oneLoginConfig);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getConfiguration(type: DirectoryType): Promise<IConfiguration> {
|
||||
switch (type) {
|
||||
case DirectoryType.Ldap:
|
||||
return await this.getLdapConfiguration();
|
||||
case DirectoryType.GSuite:
|
||||
return await this.getGsuiteConfiguration();
|
||||
case DirectoryType.EntraID:
|
||||
return await this.getEntraConfiguration();
|
||||
case DirectoryType.Okta:
|
||||
return await this.getOktaConfiguration();
|
||||
case DirectoryType.OneLogin:
|
||||
return await this.getOneLoginConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Secret Storage Methods (Secure Storage)
|
||||
// ===================================================================
|
||||
|
||||
private async getLdapSecret(): Promise<string> {
|
||||
return await this.secureStorageService.get<string>(SecureStorageKeys.ldap);
|
||||
}
|
||||
|
||||
private async setLdapSecret(value: string): Promise<void> {
|
||||
if (value == null) {
|
||||
await this.secureStorageService.remove(SecureStorageKeys.ldap);
|
||||
} else {
|
||||
await this.secureStorageService.save(SecureStorageKeys.ldap, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async getGsuiteSecret(): Promise<string> {
|
||||
return await this.secureStorageService.get<string>(SecureStorageKeys.gsuite);
|
||||
}
|
||||
|
||||
private async setGsuiteSecret(value: string): Promise<void> {
|
||||
if (value == null) {
|
||||
await this.secureStorageService.remove(SecureStorageKeys.gsuite);
|
||||
} else {
|
||||
await this.secureStorageService.save(SecureStorageKeys.gsuite, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async getEntraSecret(): Promise<string> {
|
||||
// Try new key first, fall back to old azure key for backwards compatibility
|
||||
const entraKey = await this.secureStorageService.get<string>(SecureStorageKeys.entra);
|
||||
if (entraKey != null) {
|
||||
return entraKey;
|
||||
}
|
||||
return await this.secureStorageService.get<string>(SecureStorageKeys.azure);
|
||||
}
|
||||
|
||||
private async setEntraSecret(value: string): Promise<void> {
|
||||
if (value == null) {
|
||||
await this.secureStorageService.remove(SecureStorageKeys.entra);
|
||||
await this.secureStorageService.remove(SecureStorageKeys.azure);
|
||||
} else {
|
||||
await this.secureStorageService.save(SecureStorageKeys.entra, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOktaSecret(): Promise<string> {
|
||||
return await this.secureStorageService.get<string>(SecureStorageKeys.okta);
|
||||
}
|
||||
|
||||
private async setOktaSecret(value: string): Promise<void> {
|
||||
if (value == null) {
|
||||
await this.secureStorageService.remove(SecureStorageKeys.okta);
|
||||
} else {
|
||||
await this.secureStorageService.save(SecureStorageKeys.okta, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOneLoginSecret(): Promise<string> {
|
||||
return await this.secureStorageService.get<string>(SecureStorageKeys.oneLogin);
|
||||
}
|
||||
|
||||
private async setOneLoginSecret(value: string): Promise<void> {
|
||||
if (value == null) {
|
||||
await this.secureStorageService.remove(SecureStorageKeys.oneLogin);
|
||||
} else {
|
||||
await this.secureStorageService.save(SecureStorageKeys.oneLogin, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Directory-Specific Configuration Methods
|
||||
// ===================================================================
|
||||
|
||||
async getLdapConfiguration(options?: StorageOptions): Promise<LdapConfiguration> {
|
||||
return await this.storageService.get<LdapConfiguration>(StorageKeys.directory_ldap);
|
||||
}
|
||||
|
||||
async setLdapConfiguration(value: LdapConfiguration, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save(StorageKeys.directory_ldap, value);
|
||||
}
|
||||
|
||||
async getGsuiteConfiguration(options?: StorageOptions): Promise<GSuiteConfiguration> {
|
||||
return await this.storageService.get<GSuiteConfiguration>(StorageKeys.directory_gsuite);
|
||||
}
|
||||
|
||||
async setGsuiteConfiguration(
|
||||
value: GSuiteConfiguration,
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
await this.storageService.save(StorageKeys.directory_gsuite, value);
|
||||
}
|
||||
|
||||
async getEntraConfiguration(options?: StorageOptions): Promise<EntraIdConfiguration> {
|
||||
return await this.storageService.get<EntraIdConfiguration>(StorageKeys.directory_entra);
|
||||
}
|
||||
|
||||
async setEntraConfiguration(
|
||||
value: EntraIdConfiguration,
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
await this.storageService.save(StorageKeys.directory_entra, value);
|
||||
}
|
||||
|
||||
async getOktaConfiguration(options?: StorageOptions): Promise<OktaConfiguration> {
|
||||
return await this.storageService.get<OktaConfiguration>(StorageKeys.directory_okta);
|
||||
}
|
||||
|
||||
async setOktaConfiguration(value: OktaConfiguration, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save(StorageKeys.directory_okta, value);
|
||||
}
|
||||
|
||||
async getOneLoginConfiguration(options?: StorageOptions): Promise<OneLoginConfiguration> {
|
||||
return await this.storageService.get<OneLoginConfiguration>(StorageKeys.directory_onelogin);
|
||||
}
|
||||
|
||||
async setOneLoginConfiguration(
|
||||
value: OneLoginConfiguration,
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
await this.storageService.save(StorageKeys.directory_onelogin, value);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Directory Settings Methods
|
||||
// ===================================================================
|
||||
|
||||
async getOrganizationId(options?: StorageOptions): Promise<string> {
|
||||
return await this.storageService.get<string>(StorageKeys.organizationId);
|
||||
}
|
||||
|
||||
async setOrganizationId(value: string, options?: StorageOptions): Promise<void> {
|
||||
const currentId = await this.getOrganizationId();
|
||||
if (currentId !== value) {
|
||||
await this.clearSyncSettings();
|
||||
}
|
||||
await this.storageService.save(StorageKeys.organizationId, value);
|
||||
}
|
||||
|
||||
async getSync(options?: StorageOptions): Promise<SyncConfiguration> {
|
||||
return await this.storageService.get<SyncConfiguration>(StorageKeys.sync);
|
||||
}
|
||||
|
||||
async setSync(value: SyncConfiguration, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save(StorageKeys.sync, value);
|
||||
}
|
||||
|
||||
async getDirectoryType(options?: StorageOptions): Promise<DirectoryType> {
|
||||
return await this.storageService.get<DirectoryType>(StorageKeys.directoryType);
|
||||
}
|
||||
|
||||
async setDirectoryType(value: DirectoryType, options?: StorageOptions): Promise<void> {
|
||||
const currentType = await this.getDirectoryType();
|
||||
if (value !== currentType) {
|
||||
await this.clearSyncSettings();
|
||||
}
|
||||
await this.storageService.save(StorageKeys.directoryType, value);
|
||||
}
|
||||
|
||||
async getLastUserSync(options?: StorageOptions): Promise<Date> {
|
||||
const dateString = await this.storageService.get<string>(SecureStorageKeys.lastUserSync);
|
||||
return dateString ? new Date(dateString) : null;
|
||||
}
|
||||
|
||||
async setLastUserSync(value: Date, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save(SecureStorageKeys.lastUserSync, value);
|
||||
}
|
||||
|
||||
async getLastGroupSync(options?: StorageOptions): Promise<Date> {
|
||||
const dateString = await this.storageService.get<string>(SecureStorageKeys.lastGroupSync);
|
||||
return dateString ? new Date(dateString) : null;
|
||||
}
|
||||
|
||||
async setLastGroupSync(value: Date, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save(SecureStorageKeys.lastGroupSync, value);
|
||||
}
|
||||
|
||||
async getLastSyncHash(options?: StorageOptions): Promise<string> {
|
||||
return await this.storageService.get<string>(SecureStorageKeys.lastSyncHash);
|
||||
}
|
||||
|
||||
async setLastSyncHash(value: string, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save(SecureStorageKeys.lastSyncHash, value);
|
||||
}
|
||||
|
||||
async getSyncingDir(options?: StorageOptions): Promise<boolean> {
|
||||
return await this.storageService.get<boolean>(StorageKeys.syncingDir);
|
||||
}
|
||||
|
||||
async setSyncingDir(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save(StorageKeys.syncingDir, value);
|
||||
}
|
||||
|
||||
async getUserDelta(options?: StorageOptions): Promise<string> {
|
||||
return await this.storageService.get<string>(SecureStorageKeys.userDelta);
|
||||
}
|
||||
|
||||
async setUserDelta(value: string, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save(SecureStorageKeys.userDelta, value);
|
||||
}
|
||||
|
||||
async getGroupDelta(options?: StorageOptions): Promise<string> {
|
||||
return await this.storageService.get<string>(SecureStorageKeys.groupDelta);
|
||||
}
|
||||
|
||||
async setGroupDelta(value: string, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save(SecureStorageKeys.groupDelta, value);
|
||||
}
|
||||
|
||||
async clearSyncSettings(hashToo = false): Promise<void> {
|
||||
await this.setUserDelta(null);
|
||||
await this.setGroupDelta(null);
|
||||
await this.setLastGroupSync(null);
|
||||
await this.setLastUserSync(null);
|
||||
if (hashToo) {
|
||||
await this.setLastSyncHash(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Environment URLs (inherited from base, simplified implementation)
|
||||
// ===================================================================
|
||||
|
||||
async getEnvironmentUrls(options?: StorageOptions): Promise<EnvironmentUrls> {
|
||||
return await this.storageService.get<EnvironmentUrls>("environmentUrls");
|
||||
}
|
||||
|
||||
async setEnvironmentUrls(value: EnvironmentUrls): Promise<void> {
|
||||
await this.storageService.save("environmentUrls", value);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Additional State Methods
|
||||
// ===================================================================
|
||||
|
||||
async getLocale(options?: StorageOptions): Promise<string> {
|
||||
return await this.storageService.get<string>("locale");
|
||||
}
|
||||
|
||||
async setLocale(value: string, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save("locale", value);
|
||||
}
|
||||
|
||||
async getInstalledVersion(options?: StorageOptions): Promise<string> {
|
||||
return await this.storageService.get<string>("installedVersion");
|
||||
}
|
||||
|
||||
async setInstalledVersion(value: string, options?: StorageOptions): Promise<void> {
|
||||
await this.storageService.save("installedVersion", value);
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,32 @@ import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
|
||||
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
|
||||
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
|
||||
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
|
||||
import {
|
||||
SecureStorageKeysLegacy as SecureStorageKeys,
|
||||
StoredSecurely,
|
||||
TempKeys as keys,
|
||||
} from "@/src/models/state.model";
|
||||
import { SyncConfiguration } from "@/src/models/syncConfiguration";
|
||||
|
||||
const SecureStorageKeys = {
|
||||
ldap: "ldapPassword",
|
||||
gsuite: "gsuitePrivateKey",
|
||||
// Azure Active Directory was renamed to Entra ID, but we've kept the old property name
|
||||
// to be backwards compatible with existing configurations.
|
||||
azure: "azureKey",
|
||||
entra: "entraKey",
|
||||
okta: "oktaToken",
|
||||
oneLogin: "oneLoginClientSecret",
|
||||
userDelta: "userDeltaToken",
|
||||
groupDelta: "groupDeltaToken",
|
||||
lastUserSync: "lastUserSync",
|
||||
lastGroupSync: "lastGroupSync",
|
||||
lastSyncHash: "lastSyncHash",
|
||||
};
|
||||
|
||||
const keys = {
|
||||
tempAccountSettings: "tempAccountSettings",
|
||||
tempDirectoryConfigs: "tempDirectoryConfigs",
|
||||
tempDirectorySettings: "tempDirectorySettings",
|
||||
};
|
||||
|
||||
const StoredSecurely = "[STORED SECURELY]";
|
||||
|
||||
export class StateService
|
||||
extends BaseStateService<GlobalState, Account>
|
||||
implements StateServiceAbstraction
|
||||
@@ -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";
|
||||
|
||||
@@ -8,14 +10,48 @@ import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
|
||||
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
|
||||
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
|
||||
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
|
||||
import {
|
||||
MigrationClientKeys as ClientKeys,
|
||||
MigrationKeys as Keys,
|
||||
MigrationStateKeys as StateKeys,
|
||||
SecureStorageKeysMigration as SecureStorageKeys,
|
||||
} from "@/src/models/state.model";
|
||||
import { SyncConfiguration } from "@/src/models/syncConfiguration";
|
||||
|
||||
const SecureStorageKeys: { [key: string]: any } = {
|
||||
ldap: "ldapPassword",
|
||||
gsuite: "gsuitePrivateKey",
|
||||
azure: "azureKey",
|
||||
entra: "entraIdKey",
|
||||
okta: "oktaToken",
|
||||
oneLogin: "oneLoginClientSecret",
|
||||
directoryConfigPrefix: "directoryConfig_",
|
||||
sync: "syncConfig",
|
||||
directoryType: "directoryType",
|
||||
organizationId: "organizationId",
|
||||
};
|
||||
|
||||
const Keys: { [key: string]: any } = {
|
||||
entityId: "entityId",
|
||||
directoryType: "directoryType",
|
||||
organizationId: "organizationId",
|
||||
lastUserSync: "lastUserSync",
|
||||
lastGroupSync: "lastGroupSync",
|
||||
lastSyncHash: "lastSyncHash",
|
||||
syncingDir: "syncingDir",
|
||||
syncConfig: "syncConfig",
|
||||
userDelta: "userDeltaToken",
|
||||
groupDelta: "groupDeltaToken",
|
||||
tempDirectoryConfigs: "tempDirectoryConfigs",
|
||||
tempDirectorySettings: "tempDirectorySettings",
|
||||
};
|
||||
|
||||
const StateKeys = {
|
||||
global: "global",
|
||||
authenticatedAccounts: "authenticatedAccounts",
|
||||
};
|
||||
|
||||
const ClientKeys: { [key: string]: any } = {
|
||||
clientIdOld: "clientId",
|
||||
clientId: "apikey_clientId",
|
||||
clientSecretOld: "clientSecret",
|
||||
clientSecret: "apikey_clientSecret",
|
||||
};
|
||||
|
||||
export class StateMigrationService extends BaseStateMigrationService {
|
||||
async migrate(): Promise<void> {
|
||||
let currentStateVersion = await this.getCurrentStateVersion();
|
||||
@@ -141,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);
|
||||
@@ -171,124 +251,4 @@ export class StateMigrationService extends BaseStateMigrationService {
|
||||
globals.stateVersion = StateVersion.Three;
|
||||
await this.set(StateKeys.global, globals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from State v4 (Account-based hierarchy) to v5 (flat key-value structure)
|
||||
*
|
||||
* This is a clean break from the Account-based structure. Data is extracted from
|
||||
* the account and saved into flat keys for simpler access.
|
||||
*
|
||||
* Old structure: authenticatedAccounts -> userId -> account.directorySettings/directoryConfigurations
|
||||
* New structure: flat keys like "directoryType", "organizationId", "directory_ldap", etc.
|
||||
*
|
||||
* Secrets migrate from: {userId}_{secretKey} -> secret_{secretKey}
|
||||
*/
|
||||
protected async migrateStateFrom4To5(useSecureStorageForSecrets = true): Promise<void> {
|
||||
// Get the authenticated user IDs from v3 structure
|
||||
const authenticatedUserIds = await this.get<string[]>(StateKeys.authenticatedAccounts);
|
||||
|
||||
if (
|
||||
!authenticatedUserIds ||
|
||||
!Array.isArray(authenticatedUserIds) ||
|
||||
authenticatedUserIds.length === 0
|
||||
) {
|
||||
// No accounts to migrate, just update version
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Five;
|
||||
await this.set(StateKeys.global, globals);
|
||||
return;
|
||||
}
|
||||
|
||||
// DC is single-user, so we take the first (and likely only) account
|
||||
const userId = authenticatedUserIds[0];
|
||||
const account = await this.get<Account>(userId);
|
||||
|
||||
if (!account) {
|
||||
// No account data found, just update version
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Five;
|
||||
await this.set(StateKeys.global, globals);
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate directory configurations to flat structure
|
||||
if (account.directoryConfigurations) {
|
||||
if (account.directoryConfigurations.ldap) {
|
||||
await this.set("directory_ldap", account.directoryConfigurations.ldap);
|
||||
}
|
||||
if (account.directoryConfigurations.gsuite) {
|
||||
await this.set("directory_gsuite", account.directoryConfigurations.gsuite);
|
||||
}
|
||||
if (account.directoryConfigurations.entra) {
|
||||
await this.set("directory_entra", account.directoryConfigurations.entra);
|
||||
} else if (account.directoryConfigurations.azure) {
|
||||
// Backwards compatibility: migrate azure to entra
|
||||
await this.set("directory_entra", account.directoryConfigurations.azure);
|
||||
}
|
||||
if (account.directoryConfigurations.okta) {
|
||||
await this.set("directory_okta", account.directoryConfigurations.okta);
|
||||
}
|
||||
if (account.directoryConfigurations.oneLogin) {
|
||||
await this.set("directory_onelogin", account.directoryConfigurations.oneLogin);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate directory settings to flat structure
|
||||
if (account.directorySettings) {
|
||||
if (account.directorySettings.organizationId) {
|
||||
await this.set("organizationId", account.directorySettings.organizationId);
|
||||
}
|
||||
if (account.directorySettings.directoryType != null) {
|
||||
await this.set("directoryType", account.directorySettings.directoryType);
|
||||
}
|
||||
if (account.directorySettings.sync) {
|
||||
await this.set("sync", account.directorySettings.sync);
|
||||
}
|
||||
if (account.directorySettings.lastUserSync) {
|
||||
await this.set("lastUserSync", account.directorySettings.lastUserSync);
|
||||
}
|
||||
if (account.directorySettings.lastGroupSync) {
|
||||
await this.set("lastGroupSync", account.directorySettings.lastGroupSync);
|
||||
}
|
||||
if (account.directorySettings.lastSyncHash) {
|
||||
await this.set("lastSyncHash", account.directorySettings.lastSyncHash);
|
||||
}
|
||||
if (account.directorySettings.userDelta) {
|
||||
await this.set("userDelta", account.directorySettings.userDelta);
|
||||
}
|
||||
if (account.directorySettings.groupDelta) {
|
||||
await this.set("groupDelta", account.directorySettings.groupDelta);
|
||||
}
|
||||
if (account.directorySettings.syncingDir != null) {
|
||||
await this.set("syncingDir", account.directorySettings.syncingDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate secrets from {userId}_* to secret_* pattern
|
||||
if (useSecureStorageForSecrets) {
|
||||
const oldSecretKeys = [
|
||||
{ old: `${userId}_${SecureStorageKeys.ldap}`, new: "secret_ldap" },
|
||||
{ old: `${userId}_${SecureStorageKeys.gsuite}`, new: "secret_gsuite" },
|
||||
{ old: `${userId}_${SecureStorageKeys.azure}`, new: "secret_azure" },
|
||||
{ old: `${userId}_${SecureStorageKeys.entra}`, new: "secret_entra" },
|
||||
{ old: `${userId}_${SecureStorageKeys.okta}`, new: "secret_okta" },
|
||||
{ old: `${userId}_${SecureStorageKeys.oneLogin}`, new: "secret_onelogin" },
|
||||
];
|
||||
|
||||
for (const { old: oldKey, new: newKey } of oldSecretKeys) {
|
||||
if (await this.secureStorageService.has(oldKey)) {
|
||||
const value = await this.secureStorageService.get(oldKey);
|
||||
if (value) {
|
||||
await this.secureStorageService.save(newKey, value);
|
||||
}
|
||||
// @TODO Keep old key for now - will remove in future release
|
||||
// await this.secureStorageService.remove(oldKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Five;
|
||||
await this.set(StateKeys.global, globals);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { DirectoryType } from "../enums/directoryType";
|
||||
import { BatchRequestBuilder } from "./batch-request-builder";
|
||||
import { LdapDirectoryService } from "./directory-services/ldap-directory.service";
|
||||
import { SingleRequestBuilder } from "./single-request-builder";
|
||||
import { StateService } from "./state-service/state.service";
|
||||
import { StateService } from "./state.service";
|
||||
import { SyncService } from "./sync.service";
|
||||
import * as constants from "./sync.service";
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { BatchRequestBuilder } from "./batch-request-builder";
|
||||
import { LdapDirectoryService } from "./directory-services/ldap-directory.service";
|
||||
import { I18nService } from "./i18n.service";
|
||||
import { SingleRequestBuilder } from "./single-request-builder";
|
||||
import { StateService } from "./state-service/state.service";
|
||||
import { StateService } from "./state.service";
|
||||
import { SyncService } from "./sync.service";
|
||||
import * as constants from "./sync.service";
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -24,7 +23,6 @@ const moduleRules = [
|
||||
];
|
||||
|
||||
const plugins = [
|
||||
new CleanWebpackPlugin(),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [{ from: "./src/locales", to: "locales" }],
|
||||
}),
|
||||
@@ -64,6 +62,7 @@ const config = {
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "build-cli"),
|
||||
clean: true,
|
||||
},
|
||||
module: { rules: moduleRules },
|
||||
plugins: plugins,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -23,6 +22,7 @@ const common = {
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "build"),
|
||||
clean: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,7 +48,6 @@ const main = {
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin(),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
"./package.json",
|
||||
@@ -59,7 +58,7 @@ const main = {
|
||||
],
|
||||
externals: {
|
||||
"electron-reload": "commonjs2 electron-reload",
|
||||
keytar: "commonjs2 keytar",
|
||||
"dc-native": "commonjs2 dc-native",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ const renderer = {
|
||||
node: {
|
||||
__dirname: false,
|
||||
},
|
||||
externals: {
|
||||
"dc-native": "commonjs2 dc-native",
|
||||
},
|
||||
entry: {
|
||||
"app/main": "./src/app/main.ts",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user