1
0
mirror of https://github.com/bitwarden/directory-connector synced 2026-01-10 04:23:26 +00:00

Compare commits

..

3 Commits

Author SHA1 Message Date
Andy Pixley
57f6f1c873 [BRE-773] Fixing windows signing cert
(cherry picked from commit 6b23dd086d)
2025-04-09 23:40:18 -04:00
Thomas Rittson
2b825e340c Bump version to 2024.4.0 (#735)
Note that this is effectively a re-release of 2025.1.0
in order to rollback from defective release 2025.3.0.
rc will branch from 2025.1.0 and have this cherry-picked into it.

(cherry picked from commit 2fe980dea6)
2025-04-10 12:54:05 +10:00
Vince Grassia
4056457c10 BRE-549 - Add rc branch logic (#708)
(cherry picked from commit 1464d72b27)
2025-01-15 08:23:55 -05:00
228 changed files with 14003 additions and 354436 deletions

View File

@@ -1,203 +0,0 @@
# Bitwarden Directory Connector
## Project Overview
Directory Connector is a TypeScript application that synchronizes users and groups from directory services to Bitwarden organizations. It provides both a desktop GUI (built with Angular and Electron) and a CLI tool (bwdc).
**Supported Directory Services:**
- LDAP (Lightweight Directory Access Protocol) - includes Active Directory and general LDAP servers
- Microsoft Entra ID (formerly Azure Active Directory)
- Google Workspace
- Okta
- OneLogin
**Technologies:**
- TypeScript
- Angular (GUI)
- Electron (Desktop wrapper)
- Node
- Jest for testing
## Code Architecture & Structure
### Directory Organization
```
src/
├── abstractions/ # Interface definitions (e.g., IDirectoryService)
├── services/ # Business logic implementations for directory services, sync, auth
├── models/ # Data models (UserEntry, GroupEntry, etc.)
├── commands/ # CLI command implementations
├── app/ # Angular GUI components
└── utils/ # Test utilities and fixtures
src-cli/ # CLI-specific code (imports common code from src/)
jslib/ # Legacy folder structure (mix of deprecated/unused and current code - new code should not be added here)
```
### Key Architectural Patterns
1. **Abstractions = Interfaces**: All interfaces are defined in `/abstractions`
2. **Services = Business Logic**: Implementations live in `/services`
3. **Directory Service Pattern**: Each directory provider implements `IDirectoryService` interface
4. **Separation of Concerns**: GUI (Angular app) and CLI (commands) share the same service layer
## Development Conventions
### Code Organization
**File Naming:**
- kebab-case for files: `ldap-directory.service.ts`
- Descriptive names that reflect purpose
**Class/Function Naming:**
- PascalCase for classes and interfaces
- camelCase for functions and variables
- Descriptive names that indicate purpose
**File Structure:**
- Keep files focused on single responsibility
- Create new service files for distinct directory integrations
- Separate models into individual files when complex
### TypeScript Conventions
**Import Patterns:**
- Use path aliases (`@/`) for project imports
- `@/` - project root
- `@/jslib/` - jslib folder
- ESLint enforces alphabetized import ordering with newlines between groups
**Type Safety:**
- Avoid `any` types - use proper typing or `unknown` with type guards
- Prefer interfaces for contracts, types for unions/intersections
- Use strict null checks - handle `null` and `undefined` explicitly
- Leverage TypeScript's type inference where appropriate
**Configuration:**
- Use configuration files or environment variables
- Never hardcode URLs or configuration values
## Security Best Practices
**Credential Handling:**
- Never log directory service credentials, API keys, or tokens
- Use secure storage mechanisms for sensitive data
- Credentials should never be hardcoded
- Store credentials encrypted, never in plain text
**Sensitive Data:**
- User and group data from directories should be handled securely
- Avoid exposing sensitive information in error messages
- Sanitize data before logging
- Be cautious with data persistence
**Input Validation:**
- Validate and sanitize data from external directory services
- Check for injection vulnerabilities (LDAP injection, etc.)
- Validate configuration inputs from users
**API Security:**
- Ensure authentication flows are implemented correctly
- Verify SSL/TLS is used for all external connections
- Check for secure token storage and refresh mechanisms
## Error Handling
**Best Practices:**
1. **Try-catch for async operations** - Always wrap external API calls
2. **Meaningful error messages** - Provide context for debugging
3. **Error propagation** - Don't swallow errors silently
4. **User-facing errors** - Separate user messages from developer logs
## Performance Best Practices
**Large Dataset Handling:**
- Use pagination for large user/group lists
- Avoid loading entire datasets into memory at once
- Consider streaming or batch processing for large operations
**API Rate Limiting:**
- Respect rate limits for Microsoft Graph API, Google Admin SDK, etc.
- Consider batching large API calls where necessary
**Memory Management:**
- Close connections and clean up resources
- Remove event listeners when components are destroyed
- Be cautious with caching large datasets
## Testing
**Framework:**
- Jest with jest-preset-angular
- jest-mock-extended for type-safe mocks with `mock<Type>()`
**Test Organization:**
- Tests colocated with source files
- `*.spec.ts` - Unit tests for individual components/services
- `*.integration.spec.ts` - Integration tests against live directory services
- Test helpers located in `utils/` directory
**Test Naming:**
- Descriptive, human-readable test names
- Example: `'should return empty array when no users exist in directory'`
**Test Coverage:**
- New features must include tests
- Bug fixes should include regression tests
- Changes to core sync logic or directory specific logic require integration tests
**Testing Approach:**
- **Unit tests**: Mock external API calls using jest-mock-extended
- **Integration tests**: Use live directory services (Docker containers or configured cloud services)
- Focus on critical paths (authentication, sync, data transformation)
- Test error scenarios and edge cases (empty results, malformed data, connection failures), not just happy paths
## Directory Service Patterns
### IDirectoryService Interface
All directory services implement this core interface with methods:
- `getUsers()` - Retrieve users from directory and transform them into standard objects
- `getGroups()` - Retrieve groups from directory and transform them into standard objects
- Connection and authentication handling
### Service-Specific Implementations
Each directory service has unique authentication and query patterns:
- **LDAP**: Direct LDAP queries, bind authentication
- **Microsoft Entra ID**: Microsoft Graph API, OAuth tokens
- **Google Workspace**: Google Admin SDK, service account credentials
- **Okta/OneLogin**: REST APIs with API tokens
## References
- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/)
- [Contributing Guidelines](https://contributing.bitwarden.com/contributing/)
- [Code Style](https://contributing.bitwarden.com/contributing/code-style/)
- [Security Whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/)
- [Security Definitions](https://contributing.bitwarden.com/architecture/security/definitions)

10
.eslintignore Normal file
View File

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

95
.eslintrc.json Normal file
View File

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

11
.github/CODEOWNERS vendored
View File

@@ -6,14 +6,3 @@
# Default file owners.
* @bitwarden/team-admin-console-dev
# Docker-related files
**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
# Claude related files
.claude/ @bitwarden/team-ai-sme
.github/workflows/respond.yml @bitwarden/team-ai-sme
.github/workflows/review-code.yml @bitwarden/team-ai-sme

View File

@@ -1,14 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Feature Requests
url: https://community.bitwarden.com/c/feature-requests/
about: Request new features using the Community Forums. Please search existing feature requests before making a new one.
- name: Bitwarden Community Forums
url: https://community.bitwarden.com
about: Please visit the community forums for general community discussion, support and the development roadmap.
- name: Customer Support
url: https://bitwarden.com/contact/
about: Please contact our customer support for account issues and general customer support.
- name: Security Issues
url: https://hackerone.com/bitwarden
about: We use HackerOne to manage security disclosures.

View File

@@ -1,111 +0,0 @@
name: Directory Connector Bug Report
description: File a bug report
title: "[DC] "
labels: ["bug"]
type: bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Please do not submit feature requests. The [Community Forums](https://community.bitwarden.com) has a section for submitting, voting for, and discussing product feature requests.
- type: textarea
id: reproduce
attributes:
label: Steps To Reproduce
description: How can we reproduce the behavior.
value: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. Click on '...'
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Result
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Result
description: A clear and concise description of what is happening.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots or Videos
description: If applicable, add screenshots and/or a short video to help explain your problem.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context about the problem here.
- type: dropdown
id: os
attributes:
label: Operating System
description: What operating system(s) are you seeing the problem on?
multiple: true
options:
- Windows
- macOS
- Linux
- Other operating system (please specify in "Additional Context" section)
validations:
required: true
- type: input
id: os-version
attributes:
label: Operating System Version
description: What version of the operating system(s) are you seeing the problem on?
validations:
required: true
- type: dropdown
id: directories
attributes:
label: Directory Service
description: What directory service(s) are you seeing the problem on?
multiple: true
options:
- LDAP - Active Directory
- Another LDAP implementation (please specify in "Additional Context" section)
- Microsoft Entra ID
- Google Workspace
- Okta Universal Directory
- OneLogin
- Other directory service (please specify in "Additional Context" section)
validations:
required: true
- type: dropdown
id: application-type
attributes:
label: Application Type
description: Which Directory Connector application(s) are you seeing the problem on?
multiple: true
options:
- GUI (the desktop application)
- CLI (the bwdc command line application)
validations:
required: true
- type: input
id: version
attributes:
label: Build Version
description: What version of our software are you running?
validations:
required: true
- type: checkboxes
id: issue-tracking-info
attributes:
label: Issue Tracking Info
description: |
Make sure to acknowledge the following before submitting your report!
options:
- label: I understand that work is tracked outside of Github. A PR will be linked to this issue should one be opened to address it, but Bitwarden doesn't use fields like "assigned", "milestone", or "project" to track progress.
required: true

12
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>bitwarden/renovate-config"],
"enabledManagers": ["github-actions", "npm"],
"packageRules": [
{
"groupName": "gh minor",
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"]
}
]
}

View File

@@ -1,24 +0,0 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["github>bitwarden/renovate-config"],
enabledManagers: ["github-actions", "npm"],
packageRules: [
{
groupName: "gh minor",
matchManagers: ["github-actions"],
matchUpdateTypes: ["minor", "patch"],
},
],
ignoreDeps: [
// yao-pkg is used to create a single executable application bundle for the CLI.
// It is a third party build of node which carries a high supply chain risk.
// This must be manually vetted by our appsec team before upgrading.
// It is excluded from renovate to avoid accidentally upgrading to a non-vetted version.
"@yao-pkg/pkg",
// googleapis uses ESM after 149.0.0 so we are not upgrading it until we have ESM support.
// They release new versions every couple of weeks so ignoring it at the dependency dashboard
// level is not sufficient.
// FIXME: remove and upgrade when we have ESM support.
"googleapis",
],
}

View File

@@ -9,36 +9,38 @@ on:
- "hotfix-rc"
workflow_dispatch: {}
permissions:
contents: read
jobs:
cloc:
name: CLOC
runs-on: ubuntu-24.04
steps:
- name: Checkout repo
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up CLOC
run: |
sudo apt update
sudo apt -y install cloc
- name: Print lines of code
run: cloc --include-lang TypeScript,JavaScript,HTML,Sass,CSS --vcs git
setup:
name: Setup
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
package_version: ${{ steps.retrieve-version.outputs.package_version }}
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Get Package Version
id: retrieve-version
run: |
PKG_VERSION=$(jq -r .version package.json)
echo "package_version=$PKG_VERSION" >> "$GITHUB_OUTPUT"
echo "package_version=$PKG_VERSION" >> $GITHUB_OUTPUT
- 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"
linux-cli:
name: Build Linux CLI
@@ -46,26 +48,31 @@ jobs:
needs: setup
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
permissions:
contents: read
_PKG_FETCH_NODE_VERSION: 18.5.0
_PKG_FETCH_VERSION: 3.4
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
node-version: '18'
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install "$(node -v)"
node-gyp install $(node -v)
- name: Get pkg-fetch
run: |
cd $HOME
fetchedUrl="https://github.com/vercel/pkg-fetch/releases/download/v$_PKG_FETCH_VERSION/node-v$_PKG_FETCH_NODE_VERSION-linux-x64"
mkdir -p .pkg-cache/v$_PKG_FETCH_VERSION
wget $fetchedUrl -O "./.pkg-cache/v$_PKG_FETCH_VERSION/fetched-v$_PKG_FETCH_NODE_VERSION-linux-x64"
- name: Keytar
run: |
@@ -76,8 +83,8 @@ jobs:
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
wget $keytarUrl -O ./keytar/linux/$keytarTarGz
tar -xvf ./keytar/linux/$keytarTarGz -C ./keytar/linux
- name: Install
run: npm install
@@ -86,19 +93,24 @@ jobs:
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 keytar/linux/build/Release/keytar.node
- name: Create checksums
run: |
shasum -a 256 dist-cli/bwdc-linux-$_PACKAGE_VERSION.zip | \
cut -d " " -f 1 > dist-cli/bwdc-linux-sha256-$_PACKAGE_VERSION.txt
- name: Version Test
run: |
sudo apt-get update
sudo apt install libsecret-1-0 dbus-x11 gnome-keyring
eval "$(dbus-launch --sh-syntax)"
eval $(dbus-launch --sh-syntax)
eval "$(echo -n "" | /usr/bin/gnome-keyring-daemon --login)"
eval "$(/usr/bin/gnome-keyring-daemon --components=secrets --start)"
eval $(echo -n "" | /usr/bin/gnome-keyring-daemon --login)
eval $(/usr/bin/gnome-keyring-daemon --components=secrets --start)
mkdir -p test/linux
unzip "./dist-cli/bwdc-linux-$_PACKAGE_VERSION.zip" -d ./test/linux
unzip ./dist-cli/bwdc-linux-$_PACKAGE_VERSION.zip -d ./test/linux
testVersion=$(./test/linux/bwdc -v)
@@ -111,39 +123,51 @@ jobs:
fi
- name: Upload Linux Zip to GitHub
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bwdc-linux-${{ env._PACKAGE_VERSION }}.zip
path: ./dist-cli/bwdc-linux-${{ env._PACKAGE_VERSION }}.zip
if-no-files-found: error
- name: Upload Linux checksum to GitHub
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bwdc-linux-sha256-${{ env._PACKAGE_VERSION }}.txt
path: ./dist-cli/bwdc-linux-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
macos-cli:
name: Build Mac CLI
runs-on: macos-15-intel
runs-on: macos-13
needs: setup
permissions:
contents: read
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
_PKG_FETCH_NODE_VERSION: 18.5.0
_PKG_FETCH_VERSION: 3.4
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
node-version: '18'
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install "$(node -v)"
node-gyp install $(node -v)
- name: Get pkg-fetch
run: |
cd $HOME
fetchedUrl="https://github.com/vercel/pkg-fetch/releases/download/v$_PKG_FETCH_VERSION/node-v$_PKG_FETCH_NODE_VERSION-macos-x64"
mkdir -p .pkg-cache/v$_PKG_FETCH_VERSION
wget $fetchedUrl -O "./.pkg-cache/v$_PKG_FETCH_VERSION/fetched-v$_PKG_FETCH_NODE_VERSION-macos-x64"
- name: Keytar
run: |
@@ -154,8 +178,8 @@ jobs:
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
wget $keytarUrl -O ./keytar/macos/$keytarTarGz
tar -xvf ./keytar/macos/$keytarTarGz -C ./keytar/macos
- name: Install
run: npm install
@@ -164,12 +188,17 @@ jobs:
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 keytar/macos/build/Release/keytar.node
- name: Create checksums
run: |
shasum -a 256 dist-cli/bwdc-macos-$_PACKAGE_VERSION.zip | \
cut -d " " -f 1 > dist-cli/bwdc-macos-sha256-$_PACKAGE_VERSION.txt
- name: Version Test
run: |
mkdir -p test/macos
unzip "./dist-cli/bwdc-macos-$_PACKAGE_VERSION.zip" -d ./test/macos
unzip ./dist-cli/bwdc-macos-$_PACKAGE_VERSION.zip -d ./test/macos
testVersion=$(./test/macos/bwdc -v)
@@ -182,44 +211,59 @@ jobs:
fi
- name: Upload Mac Zip to GitHub
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bwdc-macos-${{ env._PACKAGE_VERSION }}.zip
path: ./dist-cli/bwdc-macos-${{ env._PACKAGE_VERSION }}.zip
if-no-files-found: error
- name: Upload Mac checksum to GitHub
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bwdc-macos-sha256-${{ env._PACKAGE_VERSION }}.txt
path: ./dist-cli/bwdc-macos-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
windows-cli:
name: Build Windows CLI
runs-on: windows-2022
needs: setup
permissions:
contents: read
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
_WIN_PKG_FETCH_VERSION: 18.5.0
_WIN_PKG_VERSION: 3.4
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Setup Windows builder
run: |
choco install checksum --no-progress
choco install reshack --no-progress
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
node-version: '18'
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install $(node -v)
- name: Get pkg-fetch
shell: pwsh
run: |
cd $HOME
$fetchedUrl = "https://github.com/vercel/pkg-fetch/releases/download/v$env:_WIN_PKG_VERSION/node-v$env:_WIN_PKG_FETCH_VERSION-win-x64"
New-Item -ItemType directory -Path ./.pkg-cache
New-Item -ItemType directory -Path ./.pkg-cache/v$env:_WIN_PKG_VERSION
Invoke-RestMethod -Uri $fetchedUrl `
-OutFile "./.pkg-cache/v$env:_WIN_PKG_VERSION/fetched-v$env:_WIN_PKG_FETCH_VERSION-win-x64"
- name: Keytar
shell: pwsh
run: |
@@ -236,6 +280,54 @@ jobs:
7z e "./keytar/windows/$($keytarTar -f "win32")" -o"./keytar/windows"
- name: Setup Version Info
shell: pwsh
run: |
$major, $minor, $patch = $env:_PACKAGE_VERSION.split('.')
$versionInfo = @"
1 VERSIONINFO
FILEVERSION $major,$minor,$patch,0
PRODUCTVERSION $major,$minor,$patch,0
FILEOS 0x40004
FILETYPE 0x1
{
BLOCK "StringFileInfo"
{
BLOCK "040904b0"
{
VALUE "CompanyName", "Bitwarden Inc."
VALUE "ProductName", "Bitwarden"
VALUE "FileDescription", "Bitwarden Directory Connector CLI"
VALUE "FileVersion", "$env:_PACKAGE_VERSION"
VALUE "ProductVersion", "$env:_PACKAGE_VERSION"
VALUE "OriginalFilename", "bwdc.exe"
VALUE "InternalName", "bwdc"
VALUE "LegalCopyright", "Copyright Bitwarden Inc."
}
}
BLOCK "VarFileInfo"
{
VALUE "Translation", 0x0409 0x04B0
}
}
"@
$versionInfo | Out-File ./version-info.rc
- name: Resource Hacker
shell: cmd
run: |
set PATH=%PATH%;C:\Program Files (x86)\Resource Hacker
set WIN_PKG=C:\Users\runneradmin\.pkg-cache\v%_WIN_PKG_VERSION%\fetched-v%_WIN_PKG_FETCH_VERSION%-win-x64
set WIN_PKG_BUILT=C:\Users\runneradmin\.pkg-cache\v%_WIN_PKG_VERSION%\built-v%_WIN_PKG_FETCH_VERSION%-win-x64
ResourceHacker -open %WIN_PKG% -save %WIN_PKG% -action delete -mask ICONGROUP,1,
ResourceHacker -open version-info.rc -save version-info.res -action compile
ResourceHacker -open %WIN_PKG% -save %WIN_PKG% -action addoverwrite -resource version-info.res
- name: Install
run: npm install
@@ -249,7 +341,7 @@ jobs:
- name: Version Test
shell: pwsh
run: |
Expand-Archive -Path "dist-cli\bwdc-windows-$env:_PACKAGE_VERSION.zip" -DestinationPath "test\windows"
Expand-Archive -Path "dist-cli\bwdc-windows-${{ env._PACKAGE_VERSION }}.zip" -DestinationPath "test\windows"
$testVersion = Invoke-Expression '& .\test\windows\bwdc.exe -v'
echo "version: ${env:_PACKAGE_VERSION}"
echo "testVersion: $testVersion"
@@ -257,38 +349,44 @@ jobs:
Throw "Version test failed."
}
- name: Create checksums
run: |
checksum -f="./dist-cli/bwdc-windows-${env:_PACKAGE_VERSION}.zip" `
-t sha256 | Out-File ./dist-cli/bwdc-windows-sha256-${env:_PACKAGE_VERSION}.txt
- name: Upload Windows Zip to GitHub
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bwdc-windows-${{ env._PACKAGE_VERSION }}.zip
path: ./dist-cli/bwdc-windows-${{ env._PACKAGE_VERSION }}.zip
if-no-files-found: error
- name: Upload Windows checksum to GitHub
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bwdc-windows-sha256-${{ env._PACKAGE_VERSION }}.txt
path: ./dist-cli/bwdc-windows-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
windows-gui:
name: Build Windows GUI
runs-on: windows-2022
needs: setup
permissions:
contents: read
id-token: write
env:
NODE_OPTIONS: --max_old_space_size=4096
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
node-version: '18'
- name: Update NPM
run: |
@@ -306,12 +404,10 @@ jobs:
- name: Install Node dependencies
run: npm install
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -324,9 +420,6 @@ jobs:
code-signing-client-secret,
code-signing-cert-name"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Build & Sign
run: npm run dist:win
env:
@@ -338,28 +431,28 @@ jobs:
SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }}
- name: Upload Portable Executable to GitHub
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: Bitwarden-Connector-Portable-${{ env._PACKAGE_VERSION }}.exe
path: ./dist/Bitwarden-Connector-Portable-${{ env._PACKAGE_VERSION }}.exe
if-no-files-found: error
- name: Upload Installer Executable to GitHub
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe
path: ./dist/Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe
if-no-files-found: error
- name: Upload Installer Executable Blockmap to GitHub
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe.blockmap
path: ./dist/Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe.blockmap
if-no-files-found: error
- name: Upload latest auto-update artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: latest.yml
path: ./dist/latest.yml
@@ -370,30 +463,25 @@ jobs:
name: Build Linux GUI
runs-on: ubuntu-24.04
needs: setup
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=4096
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
node-version: '18'
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install "$(node -v)"
node-gyp install $(node -v)
- name: Set up environment
run: |
@@ -411,14 +499,14 @@ jobs:
run: npm run dist:lin
- name: Upload AppImage
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-x86_64.AppImage
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-x86_64.AppImage
if-no-files-found: error
- name: Upload latest auto-update artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: latest-linux.yml
path: ./dist/latest-linux.yml
@@ -427,33 +515,27 @@ jobs:
macos-gui:
name: Build MacOS GUI
runs-on: macos-15-intel
runs-on: macos-13
needs: setup
permissions:
contents: read
id-token: write
env:
NODE_OPTIONS: --max_old_space_size=4096
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
node-version: '18'
- name: Update NPM
run: |
npm install -g node-gyp
node-gyp install "$(node -v)"
node-gyp install $(node -v)
- name: Print environment
run: |
@@ -462,43 +544,31 @@ jobs:
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-directory-connector
secrets: "KEYCHAIN-PASSWORD,APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER"
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Get certificates
run: |
mkdir -p "$HOME/certificates"
mkdir -p $HOME/certificates
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert |
jq -r .value | base64 -d > "$HOME/certificates/devid-app-cert.p12"
jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert |
jq -r .value | base64 -d > "$HOME/certificates/devid-installer-cert.p12"
jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert |
jq -r .value | base64 -d > "$HOME/certificates/macdev-cert.p12"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
- name: Set up keychain
env:
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain
security set-keychain-settings -lut 1200 build.keychain
security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \
@@ -510,12 +580,12 @@ jobs:
security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \
-T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain
- name: Load package version
run: |
$rootPath = $env:GITHUB_WORKSPACE;
$packageVersion = (Get-Content -Raw -Path "$rootPath\package.json" | ConvertFrom-Json).version;
$packageVersion = (Get-Content -Raw -Path $rootPath\package.json | ConvertFrom-Json).version;
Write-Output "Setting package version to $packageVersion";
Write-Output "PACKAGE_VERSION=$packageVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append;
@@ -525,45 +595,43 @@ jobs:
run: npm install
- name: Set up private auth key
env:
_APP_STORE_CONNECT_AUTH_KEY: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }}
run: |
mkdir ~/private_keys
cat << EOF > ~/private_keys/AuthKey_UFD296548T.p8
${_APP_STORE_CONNECT_AUTH_KEY}
${{ secrets.APP_STORE_CONNECT_AUTH_KEY }}
EOF
- name: Build application
run: npm run dist:mac
env:
APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }}
APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}
APP_STORE_CONNECT_AUTH_KEY: UFD296548T
APP_STORE_CONNECT_AUTH_KEY_PATH: ~/private_keys/AuthKey_UFD296548T.p8
CSC_FOR_PULL_REQUEST: true
- name: Upload .zip artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-mac.zip
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-mac.zip
if-no-files-found: error
- name: Upload .dmg artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg
if-no-files-found: error
- name: Upload .dmg Blockmap artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg.blockmap
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg.blockmap
if-no-files-found: error
- name: Upload latest auto-update artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: latest-mac.yml
path: ./dist/latest-mac.yml
@@ -574,6 +642,7 @@ jobs:
name: Check for failures
runs-on: ubuntu-24.04
needs:
- cloc
- setup
- linux-cli
- macos-cli
@@ -581,8 +650,6 @@ jobs:
- windows-gui
- linux-gui
- macos-gui
permissions:
id-token: write
steps:
- name: Check if any job failed
if: |
@@ -592,13 +659,11 @@ jobs:
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Log in to Azure
- name: Login to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
if: failure()
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -608,9 +673,6 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()

View File

@@ -3,9 +3,6 @@ name: Enforce PR labels
on:
pull_request:
types: [labeled, unlabeled, opened, edited, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
enforce-label:
name: EnforceLabel

View File

@@ -2,57 +2,63 @@ name: Integration Testing
on:
workflow_dispatch:
# Integration tests are slow, so only run them if relevant files have changed.
# This is done at the workflow level and at the job level.
# Make sure these triggers stay consistent with the 'changed-files' job.
push:
branches:
- 'main'
- 'rc'
- "main"
paths:
- ".github/workflows/integration-test.yml" # this file
- "docker-compose.yml" # any change to Docker configuration
- "package.json" # dependencies
- "utils/**" # any change to test fixtures
- "src/services/sync.service.ts" # core sync service used by all directory services
- "src/services/directory-services/ldap-directory.service*" # LDAP directory service
- "src/services/directory-services/gsuite-directory.service*" # Google Workspace directory service
# Add directory services here as we add test coverage
- "src/services/ldap-directory.service*" # we only have integration for LDAP testing at the moment
- "./openldap*" # any change to test fixtures
- "./docker-compose.yml" # any change to Docker configuration
pull_request:
paths:
- ".github/workflows/integration-test.yml" # this file
- "docker-compose.yml" # any change to Docker configuration
- "package.json" # dependencies
- "utils/**" # any change to test fixtures
- "src/services/sync.service.ts" # core sync service used by all directory services
- "src/services/directory-services/ldap-directory.service*" # LDAP directory service
- "src/services/directory-services/gsuite-directory.service*" # Google Workspace directory service
# Add directory services here as we add test coverage
permissions:
contents: read
checks: write # required by dorny/test-reporter to upload its results
id-token: write # required to use OIDC to login to Azure Key Vault
- "src/services/ldap-directory.service*" # we only have integration for LDAP testing at the moment
- "./openldap*" # any change to test fixtures
- "./docker-compose.yml" # any change to Docker configuration
jobs:
check-test-secrets:
name: Check for test secrets
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-test-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-test-secrets
run: |
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
testing:
name: Run tests
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }}
runs-on: ubuntu-22.04
needs: check-test-secrets
permissions:
checks: write
contents: read
pull-requests: write
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- 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"
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -61,86 +67,34 @@ jobs:
- name: Install Node dependencies
run: npm ci
# Get secrets from Azure Key Vault
- name: Azure Login
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get KV Secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-directory-connector
secrets: "GOOGLE-ADMIN-USER,GOOGLE-CLIENT-EMAIL,GOOGLE-DOMAIN,GOOGLE-PRIVATE-KEY"
- name: Azure Logout
uses: bitwarden/gh-actions/azure-logout@main
# Only run relevant tests depending on what files have changed.
# This should be kept consistent with the workflow level triggers.
# Note: docker-compose.yml is only used for ldap for now
- name: Get changed files
id: changed-files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
list-files: shell
token: ${{ secrets.GITHUB_TOKEN }}
# Add directory services here as we add test coverage
filters: |
common:
- '.github/workflows/integration-test.yml'
- 'utils/**'
- 'package.json'
- 'src/services/sync.service.ts'
ldap:
- 'docker-compose.yml'
- 'src/services/directory-services/ldap-directory.service*'
google:
- 'src/services/directory-services/gsuite-directory.service*'
# LDAP
- name: Setup LDAP integration tests
if: steps.changed-files.outputs.common == 'true' || steps.changed-files.outputs.ldap == 'true'
- name: Install mkcert
run: |
sudo apt-get update
sudo apt-get -y install mkcert
npm run test:integration:setup
- name: Run LDAP integration tests
if: steps.changed-files.outputs.common == 'true' || steps.changed-files.outputs.ldap == 'true'
env:
JEST_JUNIT_UNIQUE_OUTPUT_NAME: "true" # avoids junit outputs from clashing
run: npx jest ldap-directory.service.integration.spec.ts --coverage --coverageDirectory=coverage-ldap
- name: Setup integration tests
run: npm run test:integration:setup
# Google Workspace
- name: Run Google Workspace integration tests
if: steps.changed-files.outputs.common == 'true' || steps.changed-files.outputs.google == 'true'
env:
GOOGLE_DOMAIN: ${{ steps.get-kv-secrets.outputs.GOOGLE-DOMAIN }}
GOOGLE_ADMIN_USER: ${{ steps.get-kv-secrets.outputs.GOOGLE-ADMIN-USER }}
GOOGLE_CLIENT_EMAIL: ${{ steps.get-kv-secrets.outputs.GOOGLE-CLIENT-EMAIL }}
GOOGLE_PRIVATE_KEY: ${{ steps.get-kv-secrets.outputs.GOOGLE-PRIVATE-KEY }}
JEST_JUNIT_UNIQUE_OUTPUT_NAME: "true" # avoids junit outputs from clashing
run: |
npx jest gsuite-directory.service.integration.spec.ts --coverage --coverageDirectory=coverage-google
- name: Run integration tests
run: npm run test:integration --coverage
- name: Report test results
id: report
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
# PRs from the repository and all other events are OK.
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
with:
name: Test Results
path: "junit.xml*"
path: "junit.xml"
reporter: jest-junit
fail-on-error: true
- name: Upload coverage to codecov.io
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Upload results to codecov.io
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
uses: codecov/test-results-action@1b5b448b98e58ba90d1a1a1d9fcb72ca2263be46 # v1.0.0
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -13,22 +13,15 @@ on:
- Redeploy
- Dry Run
permissions:
contents: read
jobs:
setup:
name: Setup
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
release_version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Branch check
if: ${{ inputs.release_type != 'Dry Run' }}
@@ -52,10 +45,6 @@ jobs:
name: Release
runs-on: ubuntu-24.04
needs: setup
permissions:
actions: read
packages: read
contents: write
steps:
- name: Download all artifacts
if: ${{ inputs.release_type != 'Dry Run' }}
@@ -75,13 +64,16 @@ jobs:
- name: Create release
if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
env:
PKG_VERSION: ${{ needs.setup.outputs.release_version }}
with:
artifacts: "./bwdc-windows-${{ env.PKG_VERSION }}.zip,
./bwdc-macos-${{ env.PKG_VERSION }}.zip,
./bwdc-linux-${{ env.PKG_VERSION }}.zip,
./bwdc-windows-sha256-${{ env.PKG_VERSION }}.txt,
./bwdc-macos-sha256-${{ env.PKG_VERSION }}.txt,
./bwdc-linux-sha256-${{ env.PKG_VERSION }}.txt,
./Bitwarden-Connector-Portable-${{ env.PKG_VERSION }}.exe,
./Bitwarden-Connector-Installer-${{ env.PKG_VERSION }}.exe,
./Bitwarden-Connector-Installer-${{ env.PKG_VERSION }}.exe.blockmap,

View File

@@ -1,28 +0,0 @@
name: Respond
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions: {}
jobs:
respond:
name: Respond
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: write
id-token: write
issues: write
pull-requests: write

View File

@@ -1,21 +0,0 @@
name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened]
permissions: {}
jobs:
review:
name: Review
uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: read
id-token: write
pull-requests: write

View File

@@ -5,48 +5,73 @@ on:
push:
branches:
- "main"
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- "main"
pull_request_target:
types: [opened, synchronize, reopened]
branches:
- "main"
permissions: {}
types: [opened, synchronize]
jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
sast:
name: Checkmarx
uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
name: SAST scan
runs-on: ubuntu-24.04
needs: check-run
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
pull-requests: write
security-events: write
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@ed196cdaec9cd1bc5aacac4ca2010dd773b20893 # 2.0.35
env:
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with:
project_name: ${{ github.repository }}
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
base_uri: https://ast.checkmarx.net/
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
additional_params: |
--report-format sarif \
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@e2b3eafc8d227b0241d48be5f425d47c2d750a13 # v3.26.10
with:
sarif_file: cx_result.sarif
quality:
name: Sonar
uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
name: Quality scan
runs-on: ubuntu-24.04
needs: check-run
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with SonarCloud
uses: sonarsource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: >
-Dsonar.organization=${{ github.repository_owner }}
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
-Dsonar.tests=.
-Dsonar.sources=.
-Dsonar.test.inclusions=**/*.spec.ts
-Dsonar.exclusions=**/*.spec.ts

View File

@@ -9,32 +9,48 @@ on:
- "hotfix-rc"
pull_request:
permissions:
contents: read
checks: write # required by dorny/test-reporter to upload its results
jobs:
check-test-secrets:
name: Check for test secrets
runs-on: ubuntu-24.04
outputs:
available: ${{ steps.check-test-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-test-secrets
run: |
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
testing:
name: Run tests
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }}
runs-on: ubuntu-24.04
needs: check-test-secrets
permissions:
checks: write
contents: read
pull-requests: write
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- 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"
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -53,10 +69,8 @@ jobs:
run: npm run test --coverage
- name: Report test results
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
# PRs from the repository and all other events are OK.
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
with:
name: Test Results
path: "junit.xml"
@@ -64,7 +78,13 @@ jobs:
fail-on-error: true
- name: Upload coverage to codecov.io
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Upload results to codecov.io
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
uses: codecov/test-results-action@1b5b448b98e58ba90d1a1a1d9fcb72ca2263be46 # v1.0.0
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -8,15 +8,10 @@ on:
required: false
type: string
permissions: {}
jobs:
bump_version:
name: Bump Version
runs-on: ubuntu-24.04
permissions:
contents: write
id-token: write
steps:
- name: Validate version input
if: ${{ inputs.version_number_override != '' }}
@@ -24,36 +19,17 @@ jobs:
with:
version: ${{ inputs.version_number_override }}
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Checkout Branch
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with:
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
- name: Setup git
run: |
@@ -64,7 +40,7 @@ jobs:
id: current-version
run: |
CURRENT_VERSION=$(cat package.json | jq -r '.version')
echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
- name: Verify input version
if: ${{ inputs.version_number_override != '' }}
@@ -79,7 +55,8 @@ jobs:
fi
# Check if version is newer.
if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V
if [ $? -eq 0 ]; then
echo "Version check successful."
else
echo "Version check failed."
@@ -111,34 +88,26 @@ jobs:
- name: Set final version output
id: set-final-version-output
env:
_BUMP_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-version-override.outcome }}
_INPUT_VERSION_NUMBER_OVERRIDE: ${{ inputs.version_number_override }}
_BUMP_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-version-automatic.outcome }}
_CALCULATE_NEXT_VERSION: ${{ steps.calculate-next-version.outputs.version }}
run: |
if [[ "$_BUMP_VERSION_OVERRIDE_OUTCOME" == "success" ]]; then
echo "version=$_INPUT_VERSION_NUMBER_OVERRIDE" >> "$GITHUB_OUTPUT"
elif [[ "$_BUMP_VERSION_AUTOMATIC_OUTCOME" == "success" ]]; then
echo "version=$_CALCULATE_NEXT_VERSION" >> "$GITHUB_OUTPUT"
if [[ "${{ steps.bump-version-override.outcome }}" == "success" ]]; then
echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT
elif [[ "${{ steps.bump-version-automatic.outcome }}" == "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Check if version changed
id: version-changed
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "changes_to_commit=TRUE" >> "$GITHUB_OUTPUT"
echo "changes_to_commit=TRUE" >> $GITHUB_OUTPUT
else
echo "changes_to_commit=FALSE" >> "$GITHUB_OUTPUT"
echo "changes_to_commit=FALSE" >> $GITHUB_OUTPUT
echo "No changes to commit!";
fi
- name: Commit files
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
env:
_VERSION: ${{ steps.set-final-version-output.outputs.version }}
run: git commit -m "Bumped version to $_VERSION" -a
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- name: Push changes
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}

7
.gitignore vendored
View File

@@ -2,9 +2,6 @@
.DS_Store
Thumbs.db
# Environment variables used for tests
.env
# IDEs and editors
.idea/
.project
@@ -33,8 +30,8 @@ build-cli
.angular/cache
# Testing
coverage*
junit.xml*
coverage
junit.xml
# Misc
*.crx

2
.nvmrc
View File

@@ -1 +1 @@
v20
v18

View File

@@ -9,7 +9,7 @@ Supported directories:
- Active Directory
- Any other LDAP-based directory
- Microsoft Entra ID
- Azure Active Directory
- G Suite (Google)
- Okta

View File

@@ -1,6 +1,6 @@
services:
open-ldap:
image: bitnamilegacy/openldap:latest
image: bitnami/openldap:latest
hostname: openldap
environment:
- LDAP_ADMIN_USERNAME=admin
@@ -11,8 +11,8 @@ services:
- LDAP_TLS_KEY_FILE=/certs/openldap-key.pem
- LDAP_TLS_CA_FILE=/certs/rootCA.pem
volumes:
- "./utils/openldap/ldifs:/ldifs"
- "./utils/openldap/certs:/certs"
- "./openldap/ldifs:/ldifs"
- "./openldap/certs:/certs"
ports:
- "1389:1389"
- "1636:1636"

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ module.exports = {
modulePaths: [compilerOptions.baseUrl],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: "<rootDir>/" }),
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
// Workaround for a memory leak that crashes tests in CI:
// https://github.com/facebook/jest/issues/9430#issuecomment-1149882002
// Also anecdotally improves performance when run locally

View File

@@ -0,0 +1,35 @@
<div
#callout
class="callout callout-{{ calloutStyle }}"
[ngClass]="{ clickable: clickable }"
[attr.role]="useAlertRole ? 'alert' : null"
>
<h3 class="callout-heading" *ngIf="title">
<i class="bwi {{ icon }}" *ngIf="icon" aria-hidden="true"></i>
{{ title }}
</h3>
<div class="enforced-policy-options" *ngIf="enforcedPolicyOptions">
{{ enforcedPolicyMessage }}
<ul>
<li *ngIf="enforcedPolicyOptions?.minComplexity > 0">
{{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }}
</li>
<li *ngIf="enforcedPolicyOptions?.minLength > 0">
{{ "policyInEffectMinLength" | i18n: enforcedPolicyOptions?.minLength.toString() }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireUpper">
{{ "policyInEffectUppercase" | i18n }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireLower">
{{ "policyInEffectLowercase" | i18n }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireNumbers">
{{ "policyInEffectNumbers" | i18n }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireSpecial">
{{ "policyInEffectSpecial" | i18n: "!@#$%^&*" }}
</li>
</ul>
</div>
<ng-content></ng-content>
</div>

View File

@@ -0,0 +1,78 @@
import { Component, Input, OnInit } from "@angular/core";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { MasterPasswordPolicyOptions } from "@/jslib/common/src/models/domain/masterPasswordPolicyOptions";
@Component({
selector: "app-callout",
templateUrl: "callout.component.html",
})
export class CalloutComponent implements OnInit {
@Input() type = "info";
@Input() icon: string;
@Input() title: string;
@Input() clickable: boolean;
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
@Input() enforcedPolicyMessage: string;
@Input() useAlertRole = false;
calloutStyle: string;
constructor(private i18nService: I18nService) {}
ngOnInit() {
this.calloutStyle = this.type;
if (this.enforcedPolicyMessage === undefined) {
this.enforcedPolicyMessage = this.i18nService.t("masterPasswordPolicyInEffect");
}
if (this.type === "warning" || this.type === "danger") {
if (this.type === "danger") {
this.calloutStyle = "danger";
}
if (this.title === undefined) {
this.title = this.i18nService.t("warning");
}
if (this.icon === undefined) {
this.icon = "bwi-exclamation-triangle";
}
} else if (this.type === "error") {
this.calloutStyle = "danger";
if (this.title === undefined) {
this.title = this.i18nService.t("error");
}
if (this.icon === undefined) {
this.icon = "bwi-error";
}
} else if (this.type === "tip") {
this.calloutStyle = "success";
if (this.title === undefined) {
this.title = this.i18nService.t("tip");
}
if (this.icon === undefined) {
this.icon = "bwi-lightbulb";
}
}
}
getPasswordScoreAlertDisplay() {
if (this.enforcedPolicyOptions == null) {
return "";
}
let str: string;
switch (this.enforcedPolicyOptions.minComplexity) {
case 4:
str = this.i18nService.t("strong");
break;
case 3:
str = this.i18nService.t("good");
break;
default:
str = this.i18nService.t("weak");
break;
}
return str + " (" + this.enforcedPolicyOptions.minComplexity + ")";
}
}

View File

@@ -0,0 +1,11 @@
<div class="icon" aria-hidden="true">
<img
[src]="image"
appFallbackSrc="{{ fallbackImage }}"
*ngIf="imageEnabled && image"
alt=""
decoding="async"
loading="lazy"
/>
<i class="bwi bwi-fw bwi-lg {{ icon }}" *ngIf="!imageEnabled || !image"></i>
</div>

View File

@@ -0,0 +1,115 @@
import { Component, Input, OnChanges } from "@angular/core";
import { EnvironmentService } from "@/jslib/common/src/abstractions/environment.service";
import { StateService } from "@/jslib/common/src/abstractions/state.service";
import { CipherType } from "@/jslib/common/src/enums/cipherType";
import { Utils } from "@/jslib/common/src/misc/utils";
import { CipherView } from "@/jslib/common/src/models/view/cipherView";
/**
* Provides a mapping from supported card brands to
* the filenames of icon that should be present in images/cards folder of clients.
*/
const cardIcons: Record<string, string> = {
Visa: "card-visa",
Mastercard: "card-mastercard",
Amex: "card-amex",
Discover: "card-discover",
"Diners Club": "card-diners-club",
JCB: "card-jcb",
Maestro: "card-maestro",
UnionPay: "card-union-pay",
};
@Component({
selector: "app-vault-icon",
templateUrl: "icon.component.html",
})
export class IconComponent implements OnChanges {
@Input() cipher: CipherView;
icon: string;
image: string;
fallbackImage: string;
imageEnabled: boolean;
private iconsUrl: string;
constructor(
environmentService: EnvironmentService,
private stateService: StateService,
) {
this.iconsUrl = environmentService.getIconsUrl();
}
async ngOnChanges() {
// Components may be re-used when using cdk-virtual-scroll. Which puts the component in a weird state,
// to avoid this we reset all state variables.
this.image = null;
this.fallbackImage = null;
this.imageEnabled = !(await this.stateService.getDisableFavicon());
this.load();
}
protected load() {
switch (this.cipher.type) {
case CipherType.Login:
this.icon = "bwi-globe";
this.setLoginIcon();
break;
case CipherType.SecureNote:
this.icon = "bwi-sticky-note";
break;
case CipherType.Card:
this.icon = "bwi-credit-card";
this.setCardIcon();
break;
case CipherType.Identity:
this.icon = "bwi-id-card";
break;
default:
break;
}
}
private setLoginIcon() {
if (this.cipher.login.uri) {
let hostnameUri = this.cipher.login.uri;
let isWebsite = false;
if (hostnameUri.indexOf("androidapp://") === 0) {
this.icon = "bwi-android";
this.image = null;
} else if (hostnameUri.indexOf("iosapp://") === 0) {
this.icon = "bwi-apple";
this.image = null;
} else if (
this.imageEnabled &&
hostnameUri.indexOf("://") === -1 &&
hostnameUri.indexOf(".") > -1
) {
hostnameUri = "http://" + hostnameUri;
isWebsite = true;
} else if (this.imageEnabled) {
isWebsite = hostnameUri.indexOf("http") === 0 && hostnameUri.indexOf(".") > -1;
}
if (this.imageEnabled && isWebsite) {
try {
this.image = this.iconsUrl + "/" + Utils.getHostname(hostnameUri) + "/icon.png";
this.fallbackImage = "images/bwi-globe.png";
} catch (e) {
// Ignore error since the fallback icon will be shown if image is null.
}
}
} else {
this.image = null;
}
}
private setCardIcon() {
const brand = this.cipher.card.brand;
if (this.imageEnabled && brand in cardIcons) {
this.icon = "credit-card-icon " + cardIcons[brand];
}
}
}

View File

@@ -1,4 +1,4 @@
import { InjectOptions, Injector, ProviderToken } from "@angular/core";
import { InjectFlags, InjectOptions, Injector, ProviderToken } from "@angular/core";
export class ModalInjector implements Injector {
constructor(
@@ -12,7 +12,8 @@ export class ModalInjector implements Injector {
options: InjectOptions & { optional?: false },
): T;
get<T>(token: ProviderToken<T>, notFoundValue: null, options: InjectOptions): T;
get<T>(token: ProviderToken<T>, notFoundValue?: T, options?: InjectOptions): T;
get<T>(token: ProviderToken<T>, notFoundValue?: T, options?: InjectOptions | InjectFlags): T;
get<T>(token: ProviderToken<T>, notFoundValue?: T, flags?: InjectFlags): T;
get(token: any, notFoundValue?: any): any;
get(token: any, notFoundValue?: any, flags?: any): any {
return this._additionalTokens.get(token) ?? this._parentInjector.get<any>(token, notFoundValue);

View File

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

View File

@@ -0,0 +1,41 @@
import { Directive } from "@angular/core";
import { CryptoService } from "@/jslib/common/src/abstractions/crypto.service";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
import { ModalRef } from "./modal/modal.ref";
/**
* Used to verify the user's Master Password for the "Master Password Re-prompt" feature only.
* See UserVerificationComponent for any other situation where you need to verify the user's identity.
*/
@Directive()
export class PasswordRepromptComponent {
showPassword = false;
masterPassword = "";
constructor(
private modalRef: ModalRef,
private cryptoService: CryptoService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {}
togglePassword() {
this.showPassword = !this.showPassword;
}
async submit() {
if (!(await this.cryptoService.compareAndUpdateKeyHash(this.masterPassword, null))) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("invalidMasterPassword"),
);
return;
}
this.modalRef.close(true);
}
}

View File

@@ -60,7 +60,6 @@ import {
]),
],
preserveWhitespaces: false,
standalone: false,
})
export class BitwardenToast extends BaseToast {
constructor(

View File

@@ -2,7 +2,6 @@ import { Directive, ElementRef, Input, Renderer2 } from "@angular/core";
@Directive({
selector: "[appA11yTitle]",
standalone: false,
})
export class A11yTitleDirective {
@Input() set appA11yTitle(title: string) {

View File

@@ -13,7 +13,6 @@ import { ValidationService } from "../services/validation.service";
*/
@Directive({
selector: "[appApiAction]",
standalone: false,
})
export class ApiActionDirective implements OnChanges {
@Input() appApiAction: Promise<any>;

View File

@@ -1,11 +1,10 @@
import { Directive, ElementRef, Input, NgZone } from "@angular/core";
import { take } from "rxjs";
import { take } from "rxjs/operators";
import { Utils } from "@/jslib/common/src/misc/utils";
@Directive({
selector: "[appAutofocus]",
standalone: false,
})
export class AutofocusDirective {
@Input() set appAutofocus(condition: boolean | string) {

View File

@@ -2,7 +2,6 @@ import { Directive, ElementRef, HostListener } from "@angular/core";
@Directive({
selector: "[appBlurClick]",
standalone: false,
})
export class BlurClickDirective {
constructor(private el: ElementRef) {}

View File

@@ -2,7 +2,6 @@ import { Directive, ElementRef, HostListener, OnInit } from "@angular/core";
@Directive({
selector: "[appBoxRow]",
standalone: false,
})
export class BoxRowDirective implements OnInit {
el: HTMLElement = null;

View File

@@ -2,7 +2,6 @@ import { Directive, ElementRef, HostListener, Input } from "@angular/core";
@Directive({
selector: "[appFallbackSrc]",
standalone: false,
})
export class FallbackSrcDirective {
@Input("appFallbackSrc") appFallbackSrc: string;

View File

@@ -2,7 +2,6 @@ import { Directive, HostListener } from "@angular/core";
@Directive({
selector: "[appStopClick]",
standalone: false,
})
export class StopClickDirective {
@HostListener("click", ["$event"]) onClick($event: MouseEvent) {

View File

@@ -2,7 +2,6 @@ import { Directive, HostListener } from "@angular/core";
@Directive({
selector: "[appStopProp]",
standalone: false,
})
export class StopPropDirective {
@HostListener("click", ["$event"]) onClick($event: MouseEvent) {

View File

@@ -4,7 +4,6 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
@Pipe({
name: "i18n",
standalone: false,
})
export class I18nPipe implements PipeTransform {
constructor(private i18nService: I18nService) {}

View File

@@ -0,0 +1,41 @@
import { Pipe, PipeTransform } from "@angular/core";
import { CipherView } from "@/jslib/common/src/models/view/cipherView";
@Pipe({
name: "searchCiphers",
})
export class SearchCiphersPipe implements PipeTransform {
transform(ciphers: CipherView[], searchText: string, deleted = false): CipherView[] {
if (ciphers == null || ciphers.length === 0) {
return [];
}
if (searchText == null || searchText.length < 2) {
return ciphers.filter((c) => {
return deleted !== c.isDeleted;
});
}
searchText = searchText.trim().toLowerCase();
return ciphers.filter((c) => {
if (deleted !== c.isDeleted) {
return false;
}
if (c.name != null && c.name.toLowerCase().indexOf(searchText) > -1) {
return true;
}
if (searchText.length >= 8 && c.id.startsWith(searchText)) {
return true;
}
if (c.subTitle != null && c.subTitle.toLowerCase().indexOf(searchText) > -1) {
return true;
}
if (c.login && c.login.uri != null && c.login.uri.toLowerCase().indexOf(searchText) > -1) {
return true;
}
return false;
});
}
}

View File

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

View File

@@ -0,0 +1,83 @@
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { CryptoService } from "@/jslib/common/src/abstractions/crypto.service";
import { AttachmentData } from "@/jslib/common/src/models/data/attachmentData";
import { Attachment } from "@/jslib/common/src/models/domain/attachment";
import { SymmetricCryptoKey } from "@/jslib/common/src/models/domain/symmetricCryptoKey";
import { ContainerService } from "@/jslib/common/src/services/container.service";
import { makeStaticByteArray, mockEnc } from "../utils";
describe("Attachment", () => {
let data: AttachmentData;
beforeEach(() => {
data = {
id: "id",
url: "url",
fileName: "fileName",
key: "key",
size: "1100",
sizeName: "1.1 KB",
};
});
it("Convert from empty", () => {
const data = new AttachmentData();
const attachment = new Attachment(data);
expect(attachment).toEqual({
id: null,
url: null,
size: undefined,
sizeName: null,
key: null,
fileName: null,
});
});
it("Convert", () => {
const attachment = new Attachment(data);
expect(attachment).toEqual({
size: "1100",
id: "id",
url: "url",
sizeName: "1.1 KB",
fileName: { encryptedString: "fileName", encryptionType: 0 },
key: { encryptedString: "key", encryptionType: 0 },
});
});
it("toAttachmentData", () => {
const attachment = new Attachment(data);
expect(attachment.toAttachmentData()).toEqual(data);
});
it("Decrypt", async () => {
const attachment = new Attachment();
attachment.id = "id";
attachment.url = "url";
attachment.size = "1100";
attachment.sizeName = "1.1 KB";
attachment.key = mockEnc("key");
attachment.fileName = mockEnc("fileName");
const cryptoService = Substitute.for<CryptoService>();
cryptoService.getOrgKey(null).resolves(null);
cryptoService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(32));
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
const view = await attachment.decrypt(null);
expect(view).toEqual({
id: "id",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "fileName",
key: expect.any(SymmetricCryptoKey),
});
});
});

View File

@@ -0,0 +1,73 @@
import { CardData } from "@/jslib/common/src/models/data/cardData";
import { Card } from "@/jslib/common/src/models/domain/card";
import { mockEnc } from "../utils";
describe("Card", () => {
let data: CardData;
beforeEach(() => {
data = {
cardholderName: "encHolder",
brand: "encBrand",
number: "encNumber",
expMonth: "encMonth",
expYear: "encYear",
code: "encCode",
};
});
it("Convert from empty", () => {
const data = new CardData();
const card = new Card(data);
expect(card).toEqual({
cardholderName: null,
brand: null,
number: null,
expMonth: null,
expYear: null,
code: null,
});
});
it("Convert", () => {
const card = new Card(data);
expect(card).toEqual({
cardholderName: { encryptedString: "encHolder", encryptionType: 0 },
brand: { encryptedString: "encBrand", encryptionType: 0 },
number: { encryptedString: "encNumber", encryptionType: 0 },
expMonth: { encryptedString: "encMonth", encryptionType: 0 },
expYear: { encryptedString: "encYear", encryptionType: 0 },
code: { encryptedString: "encCode", encryptionType: 0 },
});
});
it("toCardData", () => {
const card = new Card(data);
expect(card.toCardData()).toEqual(data);
});
it("Decrypt", async () => {
const card = new Card();
card.cardholderName = mockEnc("cardHolder");
card.brand = mockEnc("brand");
card.number = mockEnc("number");
card.expMonth = mockEnc("expMonth");
card.expYear = mockEnc("expYear");
card.code = mockEnc("code");
const view = await card.decrypt(null);
expect(view).toEqual({
_brand: "brand",
_number: "number",
_subTitle: null,
cardholderName: "cardHolder",
code: "code",
expMonth: "expMonth",
expYear: "expYear",
});
});
});

View File

@@ -0,0 +1,599 @@
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { CipherRepromptType } from "@/jslib/common/src/enums/cipherRepromptType";
import { CipherType } from "@/jslib/common/src/enums/cipherType";
import { FieldType } from "@/jslib/common/src/enums/fieldType";
import { SecureNoteType } from "@/jslib/common/src/enums/secureNoteType";
import { UriMatchType } from "@/jslib/common/src/enums/uriMatchType";
import { CipherData } from "@/jslib/common/src/models/data/cipherData";
import { Card } from "@/jslib/common/src/models/domain/card";
import { Cipher } from "@/jslib/common/src/models/domain/cipher";
import { Identity } from "@/jslib/common/src/models/domain/identity";
import { Login } from "@/jslib/common/src/models/domain/login";
import { SecureNote } from "@/jslib/common/src/models/domain/secureNote";
import { CardView } from "@/jslib/common/src/models/view/cardView";
import { IdentityView } from "@/jslib/common/src/models/view/identityView";
import { LoginView } from "@/jslib/common/src/models/view/loginView";
import { mockEnc } from "../utils";
describe("Cipher DTO", () => {
it("Convert from empty CipherData", () => {
const data = new CipherData();
const cipher = new Cipher(data);
expect(cipher).toEqual({
id: null,
userId: null,
organizationId: null,
folderId: null,
name: null,
notes: null,
type: undefined,
favorite: undefined,
organizationUseTotp: undefined,
edit: undefined,
viewPassword: true,
revisionDate: null,
collectionIds: undefined,
localData: null,
deletedDate: null,
reprompt: undefined,
attachments: null,
fields: null,
passwordHistory: null,
});
});
describe("LoginCipher", () => {
let cipherData: CipherData;
beforeEach(() => {
cipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
userId: "userId",
edit: true,
viewPassword: true,
organizationUseTotp: true,
favorite: false,
revisionDate: "2022-01-31T12:00:00.000Z",
type: CipherType.Login,
name: "EncryptedString",
notes: "EncryptedString",
deletedDate: null,
reprompt: CipherRepromptType.None,
login: {
uris: [{ uri: "EncryptedString", match: UriMatchType.Domain }],
username: "EncryptedString",
password: "EncryptedString",
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
totp: "EncryptedString",
autofillOnPageLoad: false,
},
passwordHistory: [
{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" },
],
attachments: [
{
id: "a1",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "file",
key: "EncKey",
},
{
id: "a2",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "file",
key: "EncKey",
},
],
fields: [
{
name: "EncryptedString",
value: "EncryptedString",
type: FieldType.Text,
linkedId: null,
},
{
name: "EncryptedString",
value: "EncryptedString",
type: FieldType.Hidden,
linkedId: null,
},
],
};
});
it("Convert", () => {
const cipher = new Cipher(cipherData);
expect(cipher).toEqual({
id: "id",
userId: "userId",
organizationId: "orgId",
folderId: "folderId",
name: { encryptedString: "EncryptedString", encryptionType: 0 },
notes: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 1,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
collectionIds: undefined,
localData: null,
deletedDate: null,
reprompt: 0,
login: {
passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"),
autofillOnPageLoad: false,
username: { encryptedString: "EncryptedString", encryptionType: 0 },
password: { encryptedString: "EncryptedString", encryptionType: 0 },
totp: { encryptedString: "EncryptedString", encryptionType: 0 },
uris: [{ match: 0, uri: { encryptedString: "EncryptedString", encryptionType: 0 } }],
},
attachments: [
{
fileName: { encryptedString: "file", encryptionType: 0 },
id: "a1",
key: { encryptedString: "EncKey", encryptionType: 0 },
size: "1100",
sizeName: "1.1 KB",
url: "url",
},
{
fileName: { encryptedString: "file", encryptionType: 0 },
id: "a2",
key: { encryptedString: "EncKey", encryptionType: 0 },
size: "1100",
sizeName: "1.1 KB",
url: "url",
},
],
fields: [
{
linkedId: null,
name: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 0,
value: { encryptedString: "EncryptedString", encryptionType: 0 },
},
{
linkedId: null,
name: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 1,
value: { encryptedString: "EncryptedString", encryptionType: 0 },
},
],
passwordHistory: [
{
lastUsedDate: new Date("2022-01-31T12:00:00.000Z"),
password: { encryptedString: "EncryptedString", encryptionType: 0 },
},
],
});
});
it("toCipherData", () => {
const cipher = new Cipher(cipherData);
expect(cipher.toCipherData("userId")).toEqual(cipherData);
});
it("Decrypt", async () => {
const cipher = new Cipher();
cipher.id = "id";
cipher.organizationId = "orgId";
cipher.folderId = "folderId";
cipher.edit = true;
cipher.viewPassword = true;
cipher.organizationUseTotp = true;
cipher.favorite = false;
cipher.revisionDate = new Date("2022-01-31T12:00:00.000Z");
cipher.type = CipherType.Login;
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
const loginView = new LoginView();
loginView.username = "username";
loginView.password = "password";
const login = Substitute.for<Login>();
login.decrypt(Arg.any(), Arg.any()).resolves(loginView);
cipher.login = login;
const cipherView = await cipher.decrypt();
expect(cipherView).toMatchObject({
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: "EncryptedString",
notes: "EncryptedString",
type: 1,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
login: loginView,
attachments: null,
fields: null,
passwordHistory: null,
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
localData: undefined,
});
});
});
describe("SecureNoteCipher", () => {
let cipherData: CipherData;
beforeEach(() => {
cipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
userId: "userId",
edit: true,
viewPassword: true,
organizationUseTotp: true,
favorite: false,
revisionDate: "2022-01-31T12:00:00.000Z",
type: CipherType.SecureNote,
name: "EncryptedString",
notes: "EncryptedString",
deletedDate: null,
reprompt: CipherRepromptType.None,
secureNote: {
type: SecureNoteType.Generic,
},
};
});
it("Convert", () => {
const cipher = new Cipher(cipherData);
expect(cipher).toEqual({
id: "id",
userId: "userId",
organizationId: "orgId",
folderId: "folderId",
name: { encryptedString: "EncryptedString", encryptionType: 0 },
notes: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 2,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
collectionIds: undefined,
localData: null,
deletedDate: null,
reprompt: 0,
secureNote: { type: SecureNoteType.Generic },
attachments: null,
fields: null,
passwordHistory: null,
});
});
it("toCipherData", () => {
const cipher = new Cipher(cipherData);
expect(cipher.toCipherData("userId")).toEqual(cipherData);
});
it("Decrypt", async () => {
const cipher = new Cipher();
cipher.id = "id";
cipher.organizationId = "orgId";
cipher.folderId = "folderId";
cipher.edit = true;
cipher.viewPassword = true;
cipher.organizationUseTotp = true;
cipher.favorite = false;
cipher.revisionDate = new Date("2022-01-31T12:00:00.000Z");
cipher.type = CipherType.SecureNote;
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
cipher.secureNote = new SecureNote();
cipher.secureNote.type = SecureNoteType.Generic;
const cipherView = await cipher.decrypt();
expect(cipherView).toMatchObject({
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: "EncryptedString",
notes: "EncryptedString",
type: 2,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
secureNote: { type: 0 },
attachments: null,
fields: null,
passwordHistory: null,
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
localData: undefined,
});
});
});
describe("CardCipher", () => {
let cipherData: CipherData;
beforeEach(() => {
cipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
userId: "userId",
edit: true,
viewPassword: true,
organizationUseTotp: true,
favorite: false,
revisionDate: "2022-01-31T12:00:00.000Z",
type: CipherType.Card,
name: "EncryptedString",
notes: "EncryptedString",
deletedDate: null,
reprompt: CipherRepromptType.None,
card: {
cardholderName: "EncryptedString",
brand: "EncryptedString",
number: "EncryptedString",
expMonth: "EncryptedString",
expYear: "EncryptedString",
code: "EncryptedString",
},
};
});
it("Convert", () => {
const cipher = new Cipher(cipherData);
expect(cipher).toEqual({
id: "id",
userId: "userId",
organizationId: "orgId",
folderId: "folderId",
name: { encryptedString: "EncryptedString", encryptionType: 0 },
notes: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 3,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
collectionIds: undefined,
localData: null,
deletedDate: null,
reprompt: 0,
card: {
cardholderName: { encryptedString: "EncryptedString", encryptionType: 0 },
brand: { encryptedString: "EncryptedString", encryptionType: 0 },
number: { encryptedString: "EncryptedString", encryptionType: 0 },
expMonth: { encryptedString: "EncryptedString", encryptionType: 0 },
expYear: { encryptedString: "EncryptedString", encryptionType: 0 },
code: { encryptedString: "EncryptedString", encryptionType: 0 },
},
attachments: null,
fields: null,
passwordHistory: null,
});
});
it("toCipherData", () => {
const cipher = new Cipher(cipherData);
expect(cipher.toCipherData("userId")).toEqual(cipherData);
});
it("Decrypt", async () => {
const cipher = new Cipher();
cipher.id = "id";
cipher.organizationId = "orgId";
cipher.folderId = "folderId";
cipher.edit = true;
cipher.viewPassword = true;
cipher.organizationUseTotp = true;
cipher.favorite = false;
cipher.revisionDate = new Date("2022-01-31T12:00:00.000Z");
cipher.type = CipherType.Card;
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
const cardView = new CardView();
cardView.cardholderName = "cardholderName";
cardView.number = "4111111111111111";
const card = Substitute.for<Card>();
card.decrypt(Arg.any(), Arg.any()).resolves(cardView);
cipher.card = card;
const cipherView = await cipher.decrypt();
expect(cipherView).toMatchObject({
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: "EncryptedString",
notes: "EncryptedString",
type: 3,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
card: cardView,
attachments: null,
fields: null,
passwordHistory: null,
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
localData: undefined,
});
});
});
describe("IdentityCipher", () => {
let cipherData: CipherData;
beforeEach(() => {
cipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
userId: "userId",
edit: true,
viewPassword: true,
organizationUseTotp: true,
favorite: false,
revisionDate: "2022-01-31T12:00:00.000Z",
type: CipherType.Identity,
name: "EncryptedString",
notes: "EncryptedString",
deletedDate: null,
reprompt: CipherRepromptType.None,
identity: {
title: "EncryptedString",
firstName: "EncryptedString",
middleName: "EncryptedString",
lastName: "EncryptedString",
address1: "EncryptedString",
address2: "EncryptedString",
address3: "EncryptedString",
city: "EncryptedString",
state: "EncryptedString",
postalCode: "EncryptedString",
country: "EncryptedString",
company: "EncryptedString",
email: "EncryptedString",
phone: "EncryptedString",
ssn: "EncryptedString",
username: "EncryptedString",
passportNumber: "EncryptedString",
licenseNumber: "EncryptedString",
},
};
});
it("Convert", () => {
const cipher = new Cipher(cipherData);
expect(cipher).toEqual({
id: "id",
userId: "userId",
organizationId: "orgId",
folderId: "folderId",
name: { encryptedString: "EncryptedString", encryptionType: 0 },
notes: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 4,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
collectionIds: undefined,
localData: null,
deletedDate: null,
reprompt: 0,
identity: {
title: { encryptedString: "EncryptedString", encryptionType: 0 },
firstName: { encryptedString: "EncryptedString", encryptionType: 0 },
middleName: { encryptedString: "EncryptedString", encryptionType: 0 },
lastName: { encryptedString: "EncryptedString", encryptionType: 0 },
address1: { encryptedString: "EncryptedString", encryptionType: 0 },
address2: { encryptedString: "EncryptedString", encryptionType: 0 },
address3: { encryptedString: "EncryptedString", encryptionType: 0 },
city: { encryptedString: "EncryptedString", encryptionType: 0 },
state: { encryptedString: "EncryptedString", encryptionType: 0 },
postalCode: { encryptedString: "EncryptedString", encryptionType: 0 },
country: { encryptedString: "EncryptedString", encryptionType: 0 },
company: { encryptedString: "EncryptedString", encryptionType: 0 },
email: { encryptedString: "EncryptedString", encryptionType: 0 },
phone: { encryptedString: "EncryptedString", encryptionType: 0 },
ssn: { encryptedString: "EncryptedString", encryptionType: 0 },
username: { encryptedString: "EncryptedString", encryptionType: 0 },
passportNumber: { encryptedString: "EncryptedString", encryptionType: 0 },
licenseNumber: { encryptedString: "EncryptedString", encryptionType: 0 },
},
attachments: null,
fields: null,
passwordHistory: null,
});
});
it("toCipherData", () => {
const cipher = new Cipher(cipherData);
expect(cipher.toCipherData("userId")).toEqual(cipherData);
});
it("Decrypt", async () => {
const cipher = new Cipher();
cipher.id = "id";
cipher.organizationId = "orgId";
cipher.folderId = "folderId";
cipher.edit = true;
cipher.viewPassword = true;
cipher.organizationUseTotp = true;
cipher.favorite = false;
cipher.revisionDate = new Date("2022-01-31T12:00:00.000Z");
cipher.type = CipherType.Identity;
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
const identityView = new IdentityView();
identityView.firstName = "firstName";
identityView.lastName = "lastName";
const identity = Substitute.for<Identity>();
identity.decrypt(Arg.any(), Arg.any()).resolves(identityView);
cipher.identity = identity;
const cipherView = await cipher.decrypt();
expect(cipherView).toMatchObject({
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: "EncryptedString",
notes: "EncryptedString",
type: 4,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
identity: identityView,
attachments: null,
fields: null,
passwordHistory: null,
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
localData: undefined,
});
});
});
});

View File

@@ -0,0 +1,66 @@
import { CollectionData } from "@/jslib/common/src/models/data/collectionData";
import { Collection } from "@/jslib/common/src/models/domain/collection";
import { mockEnc } from "../utils";
describe("Collection", () => {
let data: CollectionData;
beforeEach(() => {
data = {
id: "id",
organizationId: "orgId",
name: "encName",
externalId: "extId",
readOnly: true,
};
});
it("Convert from empty", () => {
const data = new CollectionData({} as any);
const card = new Collection(data);
expect(card).toEqual({
externalId: null,
hidePasswords: null,
id: null,
name: null,
organizationId: null,
readOnly: null,
});
});
it("Convert", () => {
const collection = new Collection(data);
expect(collection).toEqual({
id: "id",
organizationId: "orgId",
name: { encryptedString: "encName", encryptionType: 0 },
externalId: "extId",
readOnly: true,
hidePasswords: null,
});
});
it("Decrypt", async () => {
const collection = new Collection();
collection.id = "id";
collection.organizationId = "orgId";
collection.name = mockEnc("encName");
collection.externalId = "extId";
collection.readOnly = false;
collection.hidePasswords = false;
const view = await collection.decrypt();
expect(view).toEqual({
externalId: "extId",
hidePasswords: false,
id: "id",
name: "encName",
organizationId: "orgId",
readOnly: false,
});
});
});

View File

@@ -0,0 +1,64 @@
import { FieldType } from "@/jslib/common/src/enums/fieldType";
import { FieldData } from "@/jslib/common/src/models/data/fieldData";
import { Field } from "@/jslib/common/src/models/domain/field";
import { mockEnc } from "../utils";
describe("Field", () => {
let data: FieldData;
beforeEach(() => {
data = {
type: FieldType.Text,
name: "encName",
value: "encValue",
linkedId: null,
};
});
it("Convert from empty", () => {
const data = new FieldData();
const field = new Field(data);
expect(field).toEqual({
type: undefined,
name: null,
value: null,
linkedId: undefined,
});
});
it("Convert", () => {
const field = new Field(data);
expect(field).toEqual({
type: FieldType.Text,
name: { encryptedString: "encName", encryptionType: 0 },
value: { encryptedString: "encValue", encryptionType: 0 },
linkedId: null,
});
});
it("toFieldData", () => {
const field = new Field(data);
expect(field.toFieldData()).toEqual(data);
});
it("Decrypt", async () => {
const field = new Field();
field.type = FieldType.Text;
field.name = mockEnc("encName");
field.value = mockEnc("encValue");
const view = await field.decrypt(null);
expect(view).toEqual({
type: 0,
name: "encName",
value: "encValue",
newField: false,
showCount: false,
showValue: false,
});
});
});

View File

@@ -0,0 +1,42 @@
import { FolderData } from "@/jslib/common/src/models/data/folderData";
import { Folder } from "@/jslib/common/src/models/domain/folder";
import { mockEnc } from "../utils";
describe("Folder", () => {
let data: FolderData;
beforeEach(() => {
data = {
id: "id",
userId: "userId",
name: "encName",
revisionDate: "2022-01-31T12:00:00.000Z",
};
});
it("Convert", () => {
const field = new Folder(data);
expect(field).toEqual({
id: "id",
name: { encryptedString: "encName", encryptionType: 0 },
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
});
});
it("Decrypt", async () => {
const folder = new Folder();
folder.id = "id";
folder.name = mockEnc("encName");
folder.revisionDate = new Date("2022-01-31T12:00:00.000Z");
const view = await folder.decrypt();
expect(view).toEqual({
id: "id",
name: "encName",
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
});
});
});

View File

@@ -0,0 +1,134 @@
import { IdentityData } from "@/jslib/common/src/models/data/identityData";
import { Identity } from "@/jslib/common/src/models/domain/identity";
import { mockEnc } from "../utils";
describe("Identity", () => {
let data: IdentityData;
beforeEach(() => {
data = {
title: "enctitle",
firstName: "encfirstName",
middleName: "encmiddleName",
lastName: "enclastName",
address1: "encaddress1",
address2: "encaddress2",
address3: "encaddress3",
city: "enccity",
state: "encstate",
postalCode: "encpostalCode",
country: "enccountry",
company: "enccompany",
email: "encemail",
phone: "encphone",
ssn: "encssn",
username: "encusername",
passportNumber: "encpassportNumber",
licenseNumber: "enclicenseNumber",
};
});
it("Convert from empty", () => {
const data = new IdentityData();
const identity = new Identity(data);
expect(identity).toEqual({
address1: null,
address2: null,
address3: null,
city: null,
company: null,
country: null,
email: null,
firstName: null,
lastName: null,
licenseNumber: null,
middleName: null,
passportNumber: null,
phone: null,
postalCode: null,
ssn: null,
state: null,
title: null,
username: null,
});
});
it("Convert", () => {
const identity = new Identity(data);
expect(identity).toEqual({
title: { encryptedString: "enctitle", encryptionType: 0 },
firstName: { encryptedString: "encfirstName", encryptionType: 0 },
middleName: { encryptedString: "encmiddleName", encryptionType: 0 },
lastName: { encryptedString: "enclastName", encryptionType: 0 },
address1: { encryptedString: "encaddress1", encryptionType: 0 },
address2: { encryptedString: "encaddress2", encryptionType: 0 },
address3: { encryptedString: "encaddress3", encryptionType: 0 },
city: { encryptedString: "enccity", encryptionType: 0 },
state: { encryptedString: "encstate", encryptionType: 0 },
postalCode: { encryptedString: "encpostalCode", encryptionType: 0 },
country: { encryptedString: "enccountry", encryptionType: 0 },
company: { encryptedString: "enccompany", encryptionType: 0 },
email: { encryptedString: "encemail", encryptionType: 0 },
phone: { encryptedString: "encphone", encryptionType: 0 },
ssn: { encryptedString: "encssn", encryptionType: 0 },
username: { encryptedString: "encusername", encryptionType: 0 },
passportNumber: { encryptedString: "encpassportNumber", encryptionType: 0 },
licenseNumber: { encryptedString: "enclicenseNumber", encryptionType: 0 },
});
});
it("toIdentityData", () => {
const identity = new Identity(data);
expect(identity.toIdentityData()).toEqual(data);
});
it("Decrypt", async () => {
const identity = new Identity();
identity.title = mockEnc("mockTitle");
identity.firstName = mockEnc("mockFirstName");
identity.middleName = mockEnc("mockMiddleName");
identity.lastName = mockEnc("mockLastName");
identity.address1 = mockEnc("mockAddress1");
identity.address2 = mockEnc("mockAddress2");
identity.address3 = mockEnc("mockAddress3");
identity.city = mockEnc("mockCity");
identity.state = mockEnc("mockState");
identity.postalCode = mockEnc("mockPostalCode");
identity.country = mockEnc("mockCountry");
identity.company = mockEnc("mockCompany");
identity.email = mockEnc("mockEmail");
identity.phone = mockEnc("mockPhone");
identity.ssn = mockEnc("mockSsn");
identity.username = mockEnc("mockUsername");
identity.passportNumber = mockEnc("mockPassportNumber");
identity.licenseNumber = mockEnc("mockLicenseNumber");
const view = await identity.decrypt(null);
expect(view).toEqual({
_firstName: "mockFirstName",
_lastName: "mockLastName",
_subTitle: null,
address1: "mockAddress1",
address2: "mockAddress2",
address3: "mockAddress3",
city: "mockCity",
company: "mockCompany",
country: "mockCountry",
email: "mockEmail",
licenseNumber: "mockLicenseNumber",
middleName: "mockMiddleName",
passportNumber: "mockPassportNumber",
phone: "mockPhone",
postalCode: "mockPostalCode",
ssn: "mockSsn",
state: "mockState",
title: "mockTitle",
username: "mockUsername",
});
});
});

View File

@@ -0,0 +1,101 @@
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { UriMatchType } from "@/jslib/common/src/enums/uriMatchType";
import { LoginData } from "@/jslib/common/src/models/data/loginData";
import { Login } from "@/jslib/common/src/models/domain/login";
import { LoginUri } from "@/jslib/common/src/models/domain/loginUri";
import { LoginUriView } from "@/jslib/common/src/models/view/loginUriView";
import { mockEnc } from "../utils";
describe("Login DTO", () => {
it("Convert from empty LoginData", () => {
const data = new LoginData();
const login = new Login(data);
expect(login).toEqual({
passwordRevisionDate: null,
autofillOnPageLoad: undefined,
username: null,
password: null,
totp: null,
});
});
it("Convert from full LoginData", () => {
const data: LoginData = {
uris: [{ uri: "uri", match: UriMatchType.Domain }],
username: "username",
password: "password",
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
totp: "123",
autofillOnPageLoad: false,
};
const login = new Login(data);
expect(login).toEqual({
passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"),
autofillOnPageLoad: false,
username: { encryptedString: "username", encryptionType: 0 },
password: { encryptedString: "password", encryptionType: 0 },
totp: { encryptedString: "123", encryptionType: 0 },
uris: [{ match: 0, uri: { encryptedString: "uri", encryptionType: 0 } }],
});
});
it("Initialize without LoginData", () => {
const login = new Login();
expect(login).toEqual({});
});
it("Decrypts correctly", async () => {
const loginUri = Substitute.for<LoginUri>();
const loginUriView = new LoginUriView();
loginUriView.uri = "decrypted uri";
loginUri.decrypt(Arg.any()).resolves(loginUriView);
const login = new Login();
login.uris = [loginUri];
login.username = mockEnc("encrypted username");
login.password = mockEnc("encrypted password");
login.passwordRevisionDate = new Date("2022-01-31T12:00:00.000Z");
login.totp = mockEnc("encrypted totp");
login.autofillOnPageLoad = true;
const loginView = await login.decrypt(null);
expect(loginView).toEqual({
username: "encrypted username",
password: "encrypted password",
passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"),
totp: "encrypted totp",
uris: [
{
match: null,
_uri: "decrypted uri",
_domain: null,
_hostname: null,
_host: null,
_canLaunch: null,
},
],
autofillOnPageLoad: true,
});
});
it("Converts from LoginData and back", () => {
const data: LoginData = {
uris: [{ uri: "uri", match: UriMatchType.Domain }],
username: "username",
password: "password",
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
totp: "123",
autofillOnPageLoad: false,
};
const login = new Login(data);
const loginData = login.toLoginData();
expect(loginData).toEqual(data);
});
});

View File

@@ -0,0 +1,57 @@
import { UriMatchType } from "@/jslib/common/src/enums/uriMatchType";
import { LoginUriData } from "@/jslib/common/src/models/data/loginUriData";
import { LoginUri } from "@/jslib/common/src/models/domain/loginUri";
import { mockEnc } from "../utils";
describe("LoginUri", () => {
let data: LoginUriData;
beforeEach(() => {
data = {
uri: "encUri",
match: UriMatchType.Domain,
};
});
it("Convert from empty", () => {
const data = new LoginUriData();
const loginUri = new LoginUri(data);
expect(loginUri).toEqual({
match: null,
uri: null,
});
});
it("Convert", () => {
const loginUri = new LoginUri(data);
expect(loginUri).toEqual({
match: 0,
uri: { encryptedString: "encUri", encryptionType: 0 },
});
});
it("toLoginUriData", () => {
const loginUri = new LoginUri(data);
expect(loginUri.toLoginUriData()).toEqual(data);
});
it("Decrypt", async () => {
const loginUri = new LoginUri();
loginUri.match = UriMatchType.Exact;
loginUri.uri = mockEnc("uri");
const view = await loginUri.decrypt(null);
expect(view).toEqual({
_canLaunch: null,
_domain: null,
_host: null,
_hostname: null,
_uri: "uri",
match: 3,
});
});
});

View File

@@ -0,0 +1,51 @@
import { PasswordHistoryData } from "@/jslib/common/src/models/data/passwordHistoryData";
import { Password } from "@/jslib/common/src/models/domain/password";
import { mockEnc } from "../utils";
describe("Password", () => {
let data: PasswordHistoryData;
beforeEach(() => {
data = {
password: "encPassword",
lastUsedDate: "2022-01-31T12:00:00.000Z",
};
});
it("Convert from empty", () => {
const data = new PasswordHistoryData();
const password = new Password(data);
expect(password).toMatchObject({
password: null,
});
});
it("Convert", () => {
const password = new Password(data);
expect(password).toEqual({
password: { encryptedString: "encPassword", encryptionType: 0 },
lastUsedDate: new Date("2022-01-31T12:00:00.000Z"),
});
});
it("toPasswordHistoryData", () => {
const password = new Password(data);
expect(password.toPasswordHistoryData()).toEqual(data);
});
it("Decrypt", async () => {
const password = new Password();
password.password = mockEnc("password");
password.lastUsedDate = new Date("2022-01-31T12:00:00.000Z");
const view = await password.decrypt(null);
expect(view).toEqual({
password: "password",
lastUsedDate: new Date("2022-01-31T12:00:00.000Z"),
});
});
});

View File

@@ -0,0 +1,46 @@
import { SecureNoteType } from "@/jslib/common/src/enums/secureNoteType";
import { SecureNoteData } from "@/jslib/common/src/models/data/secureNoteData";
import { SecureNote } from "@/jslib/common/src/models/domain/secureNote";
describe("SecureNote", () => {
let data: SecureNoteData;
beforeEach(() => {
data = {
type: SecureNoteType.Generic,
};
});
it("Convert from empty", () => {
const data = new SecureNoteData();
const secureNote = new SecureNote(data);
expect(secureNote).toEqual({
type: undefined,
});
});
it("Convert", () => {
const secureNote = new SecureNote(data);
expect(secureNote).toEqual({
type: 0,
});
});
it("toSecureNoteData", () => {
const secureNote = new SecureNote(data);
expect(secureNote.toSecureNoteData()).toEqual(data);
});
it("Decrypt", async () => {
const secureNote = new SecureNote();
secureNote.type = SecureNoteType.Generic;
const view = await secureNote.decrypt(null);
expect(view).toEqual({
type: 0,
});
});
});

View File

@@ -0,0 +1,144 @@
import { Substitute, Arg, SubstituteOf } from "@fluffy-spoon/substitute";
import { CryptoService } from "@/jslib/common/src/abstractions/crypto.service";
import { SendType } from "@/jslib/common/src/enums/sendType";
import { SendData } from "@/jslib/common/src/models/data/sendData";
import { EncString } from "@/jslib/common/src/models/domain/encString";
import { Send } from "@/jslib/common/src/models/domain/send";
import { SendText } from "@/jslib/common/src/models/domain/sendText";
import { ContainerService } from "@/jslib/common/src/services/container.service";
import { makeStaticByteArray, mockEnc } from "../utils";
describe("Send", () => {
let data: SendData;
beforeEach(() => {
data = {
id: "id",
accessId: "accessId",
userId: "userId",
type: SendType.Text,
name: "encName",
notes: "encNotes",
text: {
text: "encText",
hidden: true,
},
file: null,
key: "encKey",
maxAccessCount: null,
accessCount: 10,
revisionDate: "2022-01-31T12:00:00.000Z",
expirationDate: "2022-01-31T12:00:00.000Z",
deletionDate: "2022-01-31T12:00:00.000Z",
password: "password",
disabled: false,
hideEmail: true,
};
});
it("Convert from empty", () => {
const data = new SendData();
const send = new Send(data);
expect(send).toEqual({
id: null,
accessId: null,
userId: null,
type: undefined,
name: null,
notes: null,
text: undefined,
file: undefined,
key: null,
maxAccessCount: undefined,
accessCount: undefined,
revisionDate: null,
expirationDate: null,
deletionDate: null,
password: undefined,
disabled: undefined,
hideEmail: undefined,
});
});
it("Convert", () => {
const send = new Send(data);
expect(send).toEqual({
id: "id",
accessId: "accessId",
userId: "userId",
type: SendType.Text,
name: { encryptedString: "encName", encryptionType: 0 },
notes: { encryptedString: "encNotes", encryptionType: 0 },
text: {
text: { encryptedString: "encText", encryptionType: 0 },
hidden: true,
},
key: { encryptedString: "encKey", encryptionType: 0 },
maxAccessCount: null,
accessCount: 10,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
password: "password",
disabled: false,
hideEmail: true,
});
});
it("Decrypt", async () => {
const text = Substitute.for<SendText>();
text.decrypt(Arg.any()).resolves("textView" as any);
const send = new Send();
send.id = "id";
send.accessId = "accessId";
send.userId = "userId";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.text = text;
send.key = mockEnc("key");
send.accessCount = 10;
send.revisionDate = new Date("2022-01-31T12:00:00.000Z");
send.expirationDate = new Date("2022-01-31T12:00:00.000Z");
send.deletionDate = new Date("2022-01-31T12:00:00.000Z");
send.password = "password";
send.disabled = false;
send.hideEmail = true;
const cryptoService = Substitute.for<CryptoService>();
cryptoService.decryptToBytes(send.key, null).resolves(makeStaticByteArray(32));
cryptoService.makeSendKey(Arg.any()).resolves("cryptoKey" as any);
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
const view = await send.decrypt();
text.received(1).decrypt("cryptoKey" as any);
(send.name as SubstituteOf<EncString>).received(1).decrypt(null, "cryptoKey" as any);
expect(view).toMatchObject({
id: "id",
accessId: "accessId",
name: "name",
notes: "notes",
type: 0,
key: expect.anything(),
cryptoKey: "cryptoKey",
file: expect.anything(),
text: "textView",
maxAccessCount: undefined,
accessCount: 10,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
password: "password",
disabled: false,
hideEmail: true,
});
});
});

View File

@@ -0,0 +1,84 @@
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { SendType } from "@/jslib/common/src/enums/sendType";
import { SendAccess } from "@/jslib/common/src/models/domain/sendAccess";
import { SendText } from "@/jslib/common/src/models/domain/sendText";
import { SendAccessResponse } from "@/jslib/common/src/models/response/sendAccessResponse";
import { mockEnc } from "../utils";
describe("SendAccess", () => {
let request: SendAccessResponse;
beforeEach(() => {
request = {
id: "id",
type: SendType.Text,
name: "encName",
file: null,
text: {
text: "encText",
hidden: true,
},
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
creatorIdentifier: "creatorIdentifier",
} as SendAccessResponse;
});
it("Convert from empty", () => {
const request = new SendAccessResponse({});
const sendAccess = new SendAccess(request);
expect(sendAccess).toEqual({
id: null,
type: undefined,
name: null,
creatorIdentifier: null,
expirationDate: null,
});
});
it("Convert", () => {
const sendAccess = new SendAccess(request);
expect(sendAccess).toEqual({
id: "id",
type: 0,
name: { encryptedString: "encName", encryptionType: 0 },
text: {
hidden: true,
text: { encryptedString: "encText", encryptionType: 0 },
},
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
creatorIdentifier: "creatorIdentifier",
});
});
it("Decrypt", async () => {
const sendAccess = new SendAccess();
sendAccess.id = "id";
sendAccess.type = SendType.Text;
sendAccess.name = mockEnc("name");
const text = Substitute.for<SendText>();
text.decrypt(Arg.any()).resolves({} as any);
sendAccess.text = text;
sendAccess.expirationDate = new Date("2022-01-31T12:00:00.000Z");
sendAccess.creatorIdentifier = "creatorIdentifier";
const view = await sendAccess.decrypt(null);
text.received(1).decrypt(Arg.any());
expect(view).toEqual({
id: "id",
type: 0,
name: "name",
text: {},
file: expect.anything(),
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
creatorIdentifier: "creatorIdentifier",
});
});
});

View File

@@ -0,0 +1,57 @@
import { SendFileData } from "@/jslib/common/src/models/data/sendFileData";
import { SendFile } from "@/jslib/common/src/models/domain/sendFile";
import { mockEnc } from "../utils";
describe("SendFile", () => {
let data: SendFileData;
beforeEach(() => {
data = {
id: "id",
size: "1100",
sizeName: "1.1 KB",
fileName: "encFileName",
};
});
it("Convert from empty", () => {
const data = new SendFileData();
const sendFile = new SendFile(data);
expect(sendFile).toEqual({
fileName: null,
id: null,
size: undefined,
sizeName: null,
});
});
it("Convert", () => {
const sendFile = new SendFile(data);
expect(sendFile).toEqual({
id: "id",
size: "1100",
sizeName: "1.1 KB",
fileName: { encryptedString: "encFileName", encryptionType: 0 },
});
});
it("Decrypt", async () => {
const sendFile = new SendFile();
sendFile.id = "id";
sendFile.size = "1100";
sendFile.sizeName = "1.1 KB";
sendFile.fileName = mockEnc("fileName");
const view = await sendFile.decrypt(null);
expect(view).toEqual({
fileName: "fileName",
id: "id",
size: "1100",
sizeName: "1.1 KB",
});
});
});

View File

@@ -0,0 +1,47 @@
import { SendTextData } from "@/jslib/common/src/models/data/sendTextData";
import { SendText } from "@/jslib/common/src/models/domain/sendText";
import { mockEnc } from "../utils";
describe("SendText", () => {
let data: SendTextData;
beforeEach(() => {
data = {
text: "encText",
hidden: false,
};
});
it("Convert from empty", () => {
const data = new SendTextData();
const secureNote = new SendText(data);
expect(secureNote).toEqual({
hidden: undefined,
text: null,
});
});
it("Convert", () => {
const secureNote = new SendText(data);
expect(secureNote).toEqual({
hidden: false,
text: { encryptedString: "encText", encryptionType: 0 },
});
});
it("Decrypt", async () => {
const secureNote = new SendText();
secureNote.text = mockEnc("text");
secureNote.hidden = true;
const view = await secureNote.decrypt(null);
expect(view).toEqual({
text: "text",
hidden: true,
});
});
});

View File

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

View File

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

View File

@@ -2,9 +2,9 @@ import { ApiTokenRequest } from "../models/request/identityToken/apiTokenRequest
import { PasswordTokenRequest } from "../models/request/identityToken/passwordTokenRequest";
import { SsoTokenRequest } from "../models/request/identityToken/ssoTokenRequest";
import { OrganizationImportRequest } from "../models/request/organizationImportRequest";
import { IdentityCaptchaResponse } from "../models/response/identityCaptchaResponse";
import { IdentityTokenResponse } from "../models/response/identityTokenResponse";
import { IdentityTwoFactorResponse } from "../models/response/identityTwoFactorResponse";
import { IdentityCaptchaResponse } from '../models/response/identityCaptchaResponse';
import { IdentityTokenResponse } from '../models/response/identityTokenResponse';
import { IdentityTwoFactorResponse } from '../models/response/identityTwoFactorResponse';
export abstract class ApiService {
postIdentityToken: (

View File

@@ -3,14 +3,26 @@ import { Observable } from "rxjs";
import { KdfType } from "../enums/kdfType";
import { ThemeType } from "../enums/themeType";
import { UriMatchType } from "../enums/uriMatchType";
import { CipherData } from "../models/data/cipherData";
import { CollectionData } from "../models/data/collectionData";
import { EventData } from "../models/data/eventData";
import { FolderData } from "../models/data/folderData";
import { OrganizationData } from "../models/data/organizationData";
import { PolicyData } from "../models/data/policyData";
import { ProviderData } from "../models/data/providerData";
import { SendData } from "../models/data/sendData";
import { Account } from "../models/domain/account";
import { EncString } from "../models/domain/encString";
import { EnvironmentUrls } from "../models/domain/environmentUrls";
import { GeneratedPasswordHistory } from "../models/domain/generatedPasswordHistory";
import { Policy } from "../models/domain/policy";
import { StorageOptions } from "../models/domain/storageOptions";
import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
import { WindowState } from "../models/domain/windowState";
import { CipherView } from "../models/view/cipherView";
import { CollectionView } from "../models/view/collectionView";
import { FolderView } from "../models/view/folderView";
import { SendView } from "../models/view/sendView";
export abstract class StateService<T extends Account = Account> {
accounts$: Observable<{ [userId: string]: T }>;
@@ -33,6 +45,8 @@ export abstract class StateService<T extends Account = Account> {
setApiKeyClientSecret: (value: string, options?: StorageOptions) => Promise<void>;
getAutoConfirmFingerPrints: (options?: StorageOptions) => Promise<boolean>;
setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise<void>;
getAutoFillOnPageLoadDefault: (options?: StorageOptions) => Promise<boolean>;
setAutoFillOnPageLoadDefault: (value: boolean, options?: StorageOptions) => Promise<void>;
getBiometricAwaitingAcceptance: (options?: StorageOptions) => Promise<boolean>;
setBiometricAwaitingAcceptance: (value: boolean, options?: StorageOptions) => Promise<void>;
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
@@ -61,11 +75,17 @@ export abstract class StateService<T extends Account = Account> {
setCryptoMasterKeyBiometric: (value: string, options?: StorageOptions) => Promise<void>;
getDecodedToken: (options?: StorageOptions) => Promise<any>;
setDecodedToken: (value: any, options?: StorageOptions) => Promise<void>;
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
getDecryptedCollections: (options?: StorageOptions) => Promise<CollectionView[]>;
setDecryptedCollections: (value: CollectionView[], options?: StorageOptions) => Promise<void>;
getDecryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<SymmetricCryptoKey>;
setDecryptedCryptoSymmetricKey: (
value: SymmetricCryptoKey,
options?: StorageOptions,
) => Promise<void>;
getDecryptedFolders: (options?: StorageOptions) => Promise<FolderView[]>;
setDecryptedFolders: (value: FolderView[], options?: StorageOptions) => Promise<void>;
getDecryptedOrganizationKeys: (
options?: StorageOptions,
) => Promise<Map<string, SymmetricCryptoKey>>;
@@ -73,8 +93,17 @@ export abstract class StateService<T extends Account = Account> {
value: Map<string, SymmetricCryptoKey>,
options?: StorageOptions,
) => Promise<void>;
getDecryptedPasswordGenerationHistory: (
options?: StorageOptions,
) => Promise<GeneratedPasswordHistory[]>;
setDecryptedPasswordGenerationHistory: (
value: GeneratedPasswordHistory[],
options?: StorageOptions,
) => Promise<void>;
getDecryptedPinProtected: (options?: StorageOptions) => Promise<EncString>;
setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise<void>;
getDecryptedPolicies: (options?: StorageOptions) => Promise<Policy[]>;
setDecryptedPolicies: (value: Policy[], options?: StorageOptions) => Promise<void>;
getDecryptedPrivateKey: (options?: StorageOptions) => Promise<ArrayBuffer>;
setDecryptedPrivateKey: (value: ArrayBuffer, options?: StorageOptions) => Promise<void>;
getDecryptedProviderKeys: (options?: StorageOptions) => Promise<Map<string, SymmetricCryptoKey>>;
@@ -82,54 +111,111 @@ export abstract class StateService<T extends Account = Account> {
value: Map<string, SymmetricCryptoKey>,
options?: StorageOptions,
) => Promise<void>;
getDecryptedSends: (options?: StorageOptions) => Promise<SendView[]>;
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
getDefaultUriMatch: (options?: StorageOptions) => Promise<UriMatchType>;
setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise<void>;
getDisableAddLoginNotification: (options?: StorageOptions) => Promise<boolean>;
setDisableAddLoginNotification: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableAutoBiometricsPrompt: (options?: StorageOptions) => Promise<boolean>;
setDisableAutoBiometricsPrompt: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableAutoTotpCopy: (options?: StorageOptions) => Promise<boolean>;
setDisableAutoTotpCopy: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableBadgeCounter: (options?: StorageOptions) => Promise<boolean>;
setDisableBadgeCounter: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableChangedPasswordNotification: (options?: StorageOptions) => Promise<boolean>;
setDisableChangedPasswordNotification: (
value: boolean,
options?: StorageOptions,
) => Promise<void>;
getDisableContextMenuItem: (options?: StorageOptions) => Promise<boolean>;
setDisableContextMenuItem: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableFavicon: (options?: StorageOptions) => Promise<boolean>;
setDisableFavicon: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableGa: (options?: StorageOptions) => Promise<boolean>;
setDisableGa: (value: boolean, options?: StorageOptions) => Promise<void>;
getDontShowCardsCurrentTab: (options?: StorageOptions) => Promise<boolean>;
setDontShowCardsCurrentTab: (value: boolean, options?: StorageOptions) => Promise<void>;
getDontShowIdentitiesCurrentTab: (options?: StorageOptions) => Promise<boolean>;
setDontShowIdentitiesCurrentTab: (value: boolean, options?: StorageOptions) => Promise<void>;
getEmail: (options?: StorageOptions) => Promise<string>;
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableAlwaysOnTop: (options?: StorageOptions) => Promise<boolean>;
setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableAutoFillOnPageLoad: (options?: StorageOptions) => Promise<boolean>;
setEnableAutoFillOnPageLoad: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBiometric: (options?: StorageOptions) => Promise<boolean>;
setEnableBiometric: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
setEnableBrowserIntegrationFingerprint: (
value: boolean,
options?: StorageOptions,
) => Promise<void>;
getEnableCloseToTray: (options?: StorageOptions) => Promise<boolean>;
setEnableCloseToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableFullWidth: (options?: StorageOptions) => Promise<boolean>;
setEnableFullWidth: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableGravitars: (options?: StorageOptions) => Promise<boolean>;
setEnableGravitars: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableMinimizeToTray: (options?: StorageOptions) => Promise<boolean>;
setEnableMinimizeToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableStartToTray: (options?: StorageOptions) => Promise<boolean>;
setEnableStartToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableTray: (options?: StorageOptions) => Promise<boolean>;
setEnableTray: (value: boolean, options?: StorageOptions) => Promise<void>;
getEncryptedCiphers: (options?: StorageOptions) => Promise<{ [id: string]: CipherData }>;
setEncryptedCiphers: (
value: { [id: string]: CipherData },
options?: StorageOptions,
) => Promise<void>;
getEncryptedCollections: (options?: StorageOptions) => Promise<{ [id: string]: CollectionData }>;
setEncryptedCollections: (
value: { [id: string]: CollectionData },
options?: StorageOptions,
) => Promise<void>;
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>;
setEncryptedCryptoSymmetricKey: (value: string, options?: StorageOptions) => Promise<void>;
getEncryptedFolders: (options?: StorageOptions) => Promise<{ [id: string]: FolderData }>;
setEncryptedFolders: (
value: { [id: string]: FolderData },
options?: StorageOptions,
) => Promise<void>;
getEncryptedOrganizationKeys: (options?: StorageOptions) => Promise<any>;
setEncryptedOrganizationKeys: (
value: Map<string, SymmetricCryptoKey>,
options?: StorageOptions,
) => Promise<void>;
getEncryptedPasswordGenerationHistory: (
options?: StorageOptions,
) => Promise<GeneratedPasswordHistory[]>;
setEncryptedPasswordGenerationHistory: (
value: GeneratedPasswordHistory[],
options?: StorageOptions,
) => Promise<void>;
getEncryptedPinProtected: (options?: StorageOptions) => Promise<string>;
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
getEncryptedPolicies: (options?: StorageOptions) => Promise<{ [id: string]: PolicyData }>;
setEncryptedPolicies: (
value: { [id: string]: PolicyData },
options?: StorageOptions,
) => Promise<void>;
getEncryptedPrivateKey: (options?: StorageOptions) => Promise<string>;
setEncryptedPrivateKey: (value: string, options?: StorageOptions) => Promise<void>;
getEncryptedProviderKeys: (options?: StorageOptions) => Promise<any>;
setEncryptedProviderKeys: (value: any, options?: StorageOptions) => Promise<void>;
getEncryptedSends: (options?: StorageOptions) => Promise<{ [id: string]: SendData }>;
setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise<void>;
getEntityId: (options?: StorageOptions) => Promise<string>;
getEnvironmentUrls: (options?: StorageOptions) => Promise<EnvironmentUrls>;
setEnvironmentUrls: (value: EnvironmentUrls, options?: StorageOptions) => Promise<void>;
getEquivalentDomains: (options?: StorageOptions) => Promise<any>;
setEquivalentDomains: (value: string, options?: StorageOptions) => Promise<void>;
getEventCollection: (options?: StorageOptions) => Promise<EventData[]>;
setEventCollection: (value: EventData[], options?: StorageOptions) => Promise<void>;
getEverBeenUnlocked: (options?: StorageOptions) => Promise<boolean>;
setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise<void>;
getForcePasswordReset: (options?: StorageOptions) => Promise<boolean>;

View File

@@ -0,0 +1,72 @@
export enum EventType {
User_LoggedIn = 1000,
User_ChangedPassword = 1001,
User_Updated2fa = 1002,
User_Disabled2fa = 1003,
User_Recovered2fa = 1004,
User_FailedLogIn = 1005,
User_FailedLogIn2fa = 1006,
User_ClientExportedVault = 1007,
User_UpdatedTempPassword = 1008,
User_MigratedKeyToKeyConnector = 1009,
Cipher_Created = 1100,
Cipher_Updated = 1101,
Cipher_Deleted = 1102,
Cipher_AttachmentCreated = 1103,
Cipher_AttachmentDeleted = 1104,
Cipher_Shared = 1105,
Cipher_UpdatedCollections = 1106,
Cipher_ClientViewed = 1107,
Cipher_ClientToggledPasswordVisible = 1108,
Cipher_ClientToggledHiddenFieldVisible = 1109,
Cipher_ClientToggledCardCodeVisible = 1110,
Cipher_ClientCopiedPassword = 1111,
Cipher_ClientCopiedHiddenField = 1112,
Cipher_ClientCopiedCardCode = 1113,
Cipher_ClientAutofilled = 1114,
Cipher_SoftDeleted = 1115,
Cipher_Restored = 1116,
Cipher_ClientToggledCardNumberVisible = 1117,
Collection_Created = 1300,
Collection_Updated = 1301,
Collection_Deleted = 1302,
Group_Created = 1400,
Group_Updated = 1401,
Group_Deleted = 1402,
OrganizationUser_Invited = 1500,
OrganizationUser_Confirmed = 1501,
OrganizationUser_Updated = 1502,
OrganizationUser_Removed = 1503,
OrganizationUser_UpdatedGroups = 1504,
OrganizationUser_UnlinkedSso = 1505,
OrganizationUser_ResetPassword_Enroll = 1506,
OrganizationUser_ResetPassword_Withdraw = 1507,
OrganizationUser_AdminResetPassword = 1508,
OrganizationUser_ResetSsoLink = 1509,
OrganizationUser_FirstSsoLogin = 1510,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,
// Organization_ClientExportedVault = 1602,
Organization_VaultAccessed = 1603,
Organization_EnabledSso = 1604,
Organization_DisabledSso = 1605,
Organization_EnabledKeyConnector = 1606,
Organization_DisabledKeyConnector = 1607,
Policy_Updated = 1700,
ProviderUser_Invited = 1800,
ProviderUser_Confirmed = 1801,
ProviderUser_Updated = 1802,
ProviderUser_Removed = 1803,
ProviderOrganization_Created = 1900,
ProviderOrganization_Added = 1901,
ProviderOrganization_Removed = 1902,
ProviderOrganization_VaultAccessed = 1903,
}

View File

@@ -0,0 +1,40 @@
export type LinkedIdType = LoginLinkedId | CardLinkedId | IdentityLinkedId;
// LoginView
export enum LoginLinkedId {
Username = 100,
Password = 101,
}
// CardView
export enum CardLinkedId {
CardholderName = 300,
ExpMonth = 301,
ExpYear = 302,
Code = 303,
Brand = 304,
Number = 305,
}
// IdentityView
export enum IdentityLinkedId {
Title = 400,
MiddleName = 401,
Address1 = 402,
Address2 = 403,
Address3 = 404,
City = 405,
State = 406,
PostalCode = 407,
Country = 408,
Company = 409,
Email = 410,
Phone = 411,
Ssn = 412,
Username = 413,
PassportNumber = 414,
LicenseNumber = 415,
FirstName = 416,
LastName = 417,
FullName = 418,
}

View File

@@ -0,0 +1,13 @@
export enum PolicyType {
TwoFactorAuthentication = 0, // Requires users to have 2fa enabled
MasterPassword = 1, // Sets minimum requirements for master password complexity
PasswordGenerator = 2, // Sets minimum requirements/default type for generated passwords/passphrases
SingleOrg = 3, // Allows users to only be apart of one organization
RequireSso = 4, // Requires users to authenticate with SSO
PersonalOwnership = 5, // Disables personal vault ownership for adding/cloning items
DisableSend = 6, // Disables the ability to create and edit Bitwarden Sends
SendOptions = 7, // Sets restrictions or defaults for Bitwarden Sends
ResetPassword = 8, // Allows orgs to use reset password : also can enable auto-enrollment during invite flow
MaximumVaultTimeout = 9, // Sets the maximum allowed vault timeout
DisablePersonalVaultExport = 10, // Disable personal vault export
}

View File

@@ -0,0 +1,3 @@
export enum SecureNoteType {
Generic = 0,
}

View File

@@ -0,0 +1,4 @@
export enum SendType {
Text = 0,
File = 1,
}

View File

@@ -0,0 +1,38 @@
import { I18nService } from "../abstractions/i18n.service";
import { IFrameComponent } from "./iframe_component";
export class CaptchaIFrame extends IFrameComponent {
constructor(
win: Window,
webVaultUrl: string,
private i18nService: I18nService,
successCallback: (message: string) => any,
errorCallback: (message: string) => any,
infoCallback: (message: string) => any,
) {
super(
win,
webVaultUrl,
"captcha-connector.html",
"hcaptcha_iframe",
successCallback,
errorCallback,
(message: string) => {
const parsedMessage = JSON.parse(message);
if (typeof parsedMessage !== "string") {
this.iframe.height = parsedMessage.height.toString();
this.iframe.width = parsedMessage.width.toString();
} else {
infoCallback(parsedMessage);
}
},
);
}
init(siteKey: string): void {
super.initComponent(
this.createParams({ siteKey: siteKey, locale: this.i18nService.translationLocale }, 1),
);
}
}

View File

@@ -0,0 +1,94 @@
export abstract class IFrameComponent {
iframe: HTMLIFrameElement;
private connectorLink: HTMLAnchorElement;
private parseFunction = this.parseMessage.bind(this);
constructor(
private win: Window,
protected webVaultUrl: string,
private path: string,
private iframeId: string,
public successCallback?: (message: string) => any,
public errorCallback?: (message: string) => any,
public infoCallback?: (message: string) => any,
) {
this.connectorLink = win.document.createElement("a");
}
stop() {
this.sendMessage("stop");
}
start() {
this.sendMessage("start");
}
sendMessage(message: any) {
if (!this.iframe || !this.iframe.src || !this.iframe.contentWindow) {
return;
}
this.iframe.contentWindow.postMessage(message, this.iframe.src);
}
base64Encode(str: string): string {
return btoa(
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => {
return String.fromCharCode(("0x" + p1) as any);
}),
);
}
cleanup() {
this.win.removeEventListener("message", this.parseFunction, false);
}
protected createParams(data: any, version: number) {
return new URLSearchParams({
data: this.base64Encode(JSON.stringify(data)),
parent: encodeURIComponent(this.win.document.location.href),
v: version.toString(),
});
}
protected initComponent(params: URLSearchParams): void {
this.connectorLink.href = `${this.webVaultUrl}/${this.path}?${params}`;
this.iframe = this.win.document.getElementById(this.iframeId) as HTMLIFrameElement;
this.iframe.src = this.connectorLink.href;
this.win.addEventListener("message", this.parseFunction, false);
}
private parseMessage(event: MessageEvent) {
if (!this.validMessage(event)) {
return;
}
const parts: string[] = event.data.split("|");
if (parts[0] === "success" && this.successCallback) {
this.successCallback(parts[1]);
} else if (parts[0] === "error" && this.errorCallback) {
this.errorCallback(parts[1]);
} else if (parts[0] === "info" && this.infoCallback) {
this.infoCallback(parts[1]);
}
}
private validMessage(event: MessageEvent) {
if (
event.origin == null ||
event.origin === "" ||
event.origin !== (this.connectorLink as any).origin ||
event.data == null ||
typeof event.data !== "string"
) {
return false;
}
return (
event.data.indexOf("success|") === 0 ||
event.data.indexOf("error|") === 0 ||
event.data.indexOf("info|") === 0
);
}
}

View File

@@ -0,0 +1,30 @@
import { LinkedIdType } from "../enums/linkedIdType";
import { ItemView } from "../models/view/itemView";
export class LinkedMetadata {
constructor(
readonly propertyKey: string,
private readonly _i18nKey?: string,
) {}
get i18nKey() {
return this._i18nKey ?? this.propertyKey;
}
}
/**
* A decorator used to set metadata used by Linked custom fields. Apply it to a class property or getter to make it
* available as a Linked custom field option.
* @param id - A unique value that is saved in the Field model. It is used to look up the decorated class property.
* @param i18nKey - The i18n key used to describe the decorated class property in the UI. If it is null, then the name
* of the class property will be used as the i18n key.
*/
export function linkedFieldOption(id: LinkedIdType, i18nKey?: string) {
return (prototype: ItemView, propertyKey: string) => {
if (prototype.linkedFieldOptions == null) {
prototype.linkedFieldOptions = new Map<LinkedIdType, LinkedMetadata>();
}
prototype.linkedFieldOptions.set(id, new LinkedMetadata(propertyKey, i18nKey));
};
}

View File

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

View File

@@ -1,11 +1,9 @@
/* eslint-disable no-useless-escape */
import * as url from "url";
import { I18nService } from "../abstractions/i18n.service";
import * as tldjs from "tldjs";
const nodeURL = typeof window === "undefined" ? url : null;
const nodeURL = typeof window === "undefined" ? require("url") : null;
export class Utils {
static inited = false;
@@ -36,7 +34,7 @@ export class Utils {
Utils.global = Utils.isNode && !Utils.isBrowser ? global : window;
}
static fromB64ToArray(str: string): Uint8Array<ArrayBuffer> {
static fromB64ToArray(str: string): Uint8Array {
if (Utils.isNode) {
return new Uint8Array(Buffer.from(str, "base64"));
} else {
@@ -49,11 +47,11 @@ export class Utils {
}
}
static fromUrlB64ToArray(str: string): Uint8Array<ArrayBuffer> {
static fromUrlB64ToArray(str: string): Uint8Array {
return Utils.fromB64ToArray(Utils.fromUrlB64ToB64(str));
}
static fromHexToArray(str: string): Uint8Array<ArrayBuffer> {
static fromHexToArray(str: string): Uint8Array {
if (Utils.isNode) {
return new Uint8Array(Buffer.from(str, "hex"));
} else {
@@ -65,7 +63,7 @@ export class Utils {
}
}
static fromUtf8ToArray(str: string): Uint8Array<ArrayBuffer> {
static fromUtf8ToArray(str: string): Uint8Array {
if (Utils.isNode) {
return new Uint8Array(Buffer.from(str, "utf8"));
} else {
@@ -78,7 +76,7 @@ export class Utils {
}
}
static fromByteStringToArray(str: string): Uint8Array<ArrayBuffer> {
static fromByteStringToArray(str: string): Uint8Array {
const arr = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
arr[i] = str.charCodeAt(i);
@@ -99,8 +97,8 @@ export class Utils {
}
}
static fromBufferToUrlB64(buffer: Uint8Array<ArrayBuffer>): string {
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer.buffer));
static fromBufferToUrlB64(buffer: ArrayBuffer): string {
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
}
static fromB64toUrlB64(b64Str: string) {
@@ -249,7 +247,7 @@ export class Utils {
const urlDomain =
tldjs != null && tldjs.getDomain != null ? tldjs.getDomain(url.hostname) : null;
return urlDomain != null ? urlDomain : url.hostname;
} catch {
} catch (e) {
// Invalid domain, try another approach below.
}
}
@@ -397,7 +395,7 @@ export class Utils {
anchor.href = uriString;
return anchor as any;
}
} catch {
} catch (e) {
// Ignore error
}

View File

@@ -0,0 +1,106 @@
import { I18nService } from "../abstractions/i18n.service";
import { PlatformUtilsService } from "../abstractions/platformUtils.service";
export class WebAuthnIFrame {
private iframe: HTMLIFrameElement = null;
private connectorLink: HTMLAnchorElement;
private parseFunction = this.parseMessage.bind(this);
constructor(
private win: Window,
private webVaultUrl: string,
private webAuthnNewTab: boolean,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private successCallback: Function, // eslint-disable-line
private errorCallback: Function, // eslint-disable-line
private infoCallback: Function, // eslint-disable-line
) {
this.connectorLink = win.document.createElement("a");
}
init(data: any): void {
const params = new URLSearchParams({
data: this.base64Encode(JSON.stringify(data)),
parent: encodeURIComponent(this.win.document.location.href),
btnText: encodeURIComponent(this.i18nService.t("webAuthnAuthenticate")),
v: "1",
});
if (this.webAuthnNewTab) {
// Firefox fallback which opens the webauthn page in a new tab
params.append("locale", this.i18nService.translationLocale);
this.platformUtilsService.launchUri(
`${this.webVaultUrl}/webauthn-fallback-connector.html?${params}`,
);
} else {
this.connectorLink.href = `${this.webVaultUrl}/webauthn-connector.html?${params}`;
this.iframe = this.win.document.getElementById("webauthn_iframe") as HTMLIFrameElement;
this.iframe.allow = "publickey-credentials-get " + new URL(this.webVaultUrl).origin;
this.iframe.src = this.connectorLink.href;
this.win.addEventListener("message", this.parseFunction, false);
}
}
stop() {
this.sendMessage("stop");
}
start() {
this.sendMessage("start");
}
sendMessage(message: any) {
if (!this.iframe || !this.iframe.src || !this.iframe.contentWindow) {
return;
}
this.iframe.contentWindow.postMessage(message, this.iframe.src);
}
base64Encode(str: string): string {
return btoa(
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => {
return String.fromCharCode(("0x" + p1) as any);
}),
);
}
cleanup() {
this.win.removeEventListener("message", this.parseFunction, false);
}
private parseMessage(event: MessageEvent) {
if (!this.validMessage(event)) {
return;
}
const parts: string[] = event.data.split("|");
if (parts[0] === "success" && this.successCallback) {
this.successCallback(parts[1]);
} else if (parts[0] === "error" && this.errorCallback) {
this.errorCallback(parts[1]);
} else if (parts[0] === "info" && this.infoCallback) {
this.infoCallback(parts[1]);
}
}
private validMessage(event: MessageEvent) {
if (
event.origin == null ||
event.origin === "" ||
event.origin !== (this.connectorLink as any).origin ||
event.data == null ||
typeof event.data !== "string"
) {
return false;
}
return (
event.data.indexOf("success|") === 0 ||
event.data.indexOf("error|") === 0 ||
event.data.indexOf("info|") === 0
);
}
}

View File

@@ -0,0 +1,23 @@
import { BaseResponse } from "../response/baseResponse";
export class CardApi extends BaseResponse {
cardholderName: string;
brand: string;
number: string;
expMonth: string;
expYear: string;
code: string;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.cardholderName = this.getResponseProperty("CardholderName");
this.brand = this.getResponseProperty("Brand");
this.number = this.getResponseProperty("Number");
this.expMonth = this.getResponseProperty("ExpMonth");
this.expYear = this.getResponseProperty("ExpYear");
this.code = this.getResponseProperty("Code");
}
}

View File

@@ -0,0 +1,21 @@
import { FieldType } from "../../enums/fieldType";
import { LinkedIdType } from "../../enums/linkedIdType";
import { BaseResponse } from "../response/baseResponse";
export class FieldApi extends BaseResponse {
name: string;
value: string;
type: FieldType;
linkedId: LinkedIdType;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.type = this.getResponseProperty("Type");
this.name = this.getResponseProperty("Name");
this.value = this.getResponseProperty("Value");
this.linkedId = this.getResponseProperty("linkedId");
}
}

View File

@@ -0,0 +1,47 @@
import { BaseResponse } from "../response/baseResponse";
export class IdentityApi extends BaseResponse {
title: string;
firstName: string;
middleName: string;
lastName: string;
address1: string;
address2: string;
address3: string;
city: string;
state: string;
postalCode: string;
country: string;
company: string;
email: string;
phone: string;
ssn: string;
username: string;
passportNumber: string;
licenseNumber: string;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.title = this.getResponseProperty("Title");
this.firstName = this.getResponseProperty("FirstName");
this.middleName = this.getResponseProperty("MiddleName");
this.lastName = this.getResponseProperty("LastName");
this.address1 = this.getResponseProperty("Address1");
this.address2 = this.getResponseProperty("Address2");
this.address3 = this.getResponseProperty("Address3");
this.city = this.getResponseProperty("City");
this.state = this.getResponseProperty("State");
this.postalCode = this.getResponseProperty("PostalCode");
this.country = this.getResponseProperty("Country");
this.company = this.getResponseProperty("Company");
this.email = this.getResponseProperty("Email");
this.phone = this.getResponseProperty("Phone");
this.ssn = this.getResponseProperty("SSN");
this.username = this.getResponseProperty("Username");
this.passportNumber = this.getResponseProperty("PassportNumber");
this.licenseNumber = this.getResponseProperty("LicenseNumber");
}
}

View File

@@ -0,0 +1,29 @@
import { BaseResponse } from "../response/baseResponse";
import { LoginUriApi } from "./loginUriApi";
export class LoginApi extends BaseResponse {
uris: LoginUriApi[];
username: string;
password: string;
passwordRevisionDate: string;
totp: string;
autofillOnPageLoad: boolean;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.username = this.getResponseProperty("Username");
this.password = this.getResponseProperty("Password");
this.passwordRevisionDate = this.getResponseProperty("PasswordRevisionDate");
this.totp = this.getResponseProperty("Totp");
this.autofillOnPageLoad = this.getResponseProperty("AutofillOnPageLoad");
const uris = this.getResponseProperty("Uris");
if (uris != null) {
this.uris = uris.map((u: any) => new LoginUriApi(u));
}
}
}

View File

@@ -0,0 +1,17 @@
import { UriMatchType } from "../../enums/uriMatchType";
import { BaseResponse } from "../response/baseResponse";
export class LoginUriApi extends BaseResponse {
uri: string;
match: UriMatchType = null;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.uri = this.getResponseProperty("Uri");
const match = this.getResponseProperty("Match");
this.match = match != null ? match : null;
}
}

View File

@@ -0,0 +1,14 @@
import { SecureNoteType } from "../../enums/secureNoteType";
import { BaseResponse } from "../response/baseResponse";
export class SecureNoteApi extends BaseResponse {
type: SecureNoteType;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.type = this.getResponseProperty("Type");
}
}

View File

@@ -0,0 +1,19 @@
import { BaseResponse } from "../response/baseResponse";
export class SendFileApi extends BaseResponse {
id: string;
fileName: string;
size: string;
sizeName: string;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.id = this.getResponseProperty("Id");
this.fileName = this.getResponseProperty("FileName");
this.size = this.getResponseProperty("Size");
this.sizeName = this.getResponseProperty("SizeName");
}
}

View File

@@ -0,0 +1,15 @@
import { BaseResponse } from "../response/baseResponse";
export class SendTextApi extends BaseResponse {
text: string;
hidden: boolean;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.text = this.getResponseProperty("Text");
this.hidden = this.getResponseProperty("Hidden") || false;
}
}

View File

@@ -0,0 +1,22 @@
import { AttachmentResponse } from "../response/attachmentResponse";
export class AttachmentData {
id: string;
url: string;
fileName: string;
key: string;
size: string;
sizeName: string;
constructor(response?: AttachmentResponse) {
if (response == null) {
return;
}
this.id = response.id;
this.url = response.url;
this.fileName = response.fileName;
this.key = response.key;
this.size = response.size;
this.sizeName = response.sizeName;
}
}

View File

@@ -0,0 +1,23 @@
import { CardApi } from "../api/cardApi";
export class CardData {
cardholderName: string;
brand: string;
number: string;
expMonth: string;
expYear: string;
code: string;
constructor(data?: CardApi) {
if (data == null) {
return;
}
this.cardholderName = data.cardholderName;
this.brand = data.brand;
this.number = data.number;
this.expMonth = data.expMonth;
this.expYear = data.expYear;
this.code = data.code;
}
}

View File

@@ -0,0 +1,85 @@
import { CipherRepromptType } from "../../enums/cipherRepromptType";
import { CipherType } from "../../enums/cipherType";
import { CipherResponse } from "../response/cipherResponse";
import { AttachmentData } from "./attachmentData";
import { CardData } from "./cardData";
import { FieldData } from "./fieldData";
import { IdentityData } from "./identityData";
import { LoginData } from "./loginData";
import { PasswordHistoryData } from "./passwordHistoryData";
import { SecureNoteData } from "./secureNoteData";
export class CipherData {
id: string;
organizationId: string;
folderId: string;
userId: string;
edit: boolean;
viewPassword: boolean;
organizationUseTotp: boolean;
favorite: boolean;
revisionDate: string;
type: CipherType;
name: string;
notes: string;
login?: LoginData;
secureNote?: SecureNoteData;
card?: CardData;
identity?: IdentityData;
fields?: FieldData[];
attachments?: AttachmentData[];
passwordHistory?: PasswordHistoryData[];
collectionIds?: string[];
deletedDate: string;
reprompt: CipherRepromptType;
constructor(response?: CipherResponse, userId?: string, collectionIds?: string[]) {
if (response == null) {
return;
}
this.id = response.id;
this.organizationId = response.organizationId;
this.folderId = response.folderId;
this.userId = userId;
this.edit = response.edit;
this.viewPassword = response.viewPassword;
this.organizationUseTotp = response.organizationUseTotp;
this.favorite = response.favorite;
this.revisionDate = response.revisionDate;
this.type = response.type;
this.name = response.name;
this.notes = response.notes;
this.collectionIds = collectionIds != null ? collectionIds : response.collectionIds;
this.deletedDate = response.deletedDate;
this.reprompt = response.reprompt;
switch (this.type) {
case CipherType.Login:
this.login = new LoginData(response.login);
break;
case CipherType.SecureNote:
this.secureNote = new SecureNoteData(response.secureNote);
break;
case CipherType.Card:
this.card = new CardData(response.card);
break;
case CipherType.Identity:
this.identity = new IdentityData(response.identity);
break;
default:
break;
}
if (response.fields != null) {
this.fields = response.fields.map((f) => new FieldData(f));
}
if (response.attachments != null) {
this.attachments = response.attachments.map((a) => new AttachmentData(a));
}
if (response.passwordHistory != null) {
this.passwordHistory = response.passwordHistory.map((ph) => new PasswordHistoryData(ph));
}
}
}

View File

@@ -0,0 +1,17 @@
import { CollectionDetailsResponse } from "../response/collectionResponse";
export class CollectionData {
id: string;
organizationId: string;
name: string;
externalId: string;
readOnly: boolean;
constructor(response: CollectionDetailsResponse) {
this.id = response.id;
this.organizationId = response.organizationId;
this.name = response.name;
this.externalId = response.externalId;
this.readOnly = response.readOnly;
}
}

View File

@@ -0,0 +1,7 @@
import { EventType } from "../../enums/eventType";
export class EventData {
type: EventType;
cipherId: string;
date: string;
}

View File

@@ -0,0 +1,20 @@
import { FieldType } from "../../enums/fieldType";
import { LinkedIdType } from "../../enums/linkedIdType";
import { FieldApi } from "../api/fieldApi";
export class FieldData {
type: FieldType;
name: string;
value: string;
linkedId: LinkedIdType;
constructor(response?: FieldApi) {
if (response == null) {
return;
}
this.type = response.type;
this.name = response.name;
this.value = response.value;
this.linkedId = response.linkedId;
}
}

View File

@@ -0,0 +1,15 @@
import { FolderResponse } from "../response/folderResponse";
export class FolderData {
id: string;
userId: string;
name: string;
revisionDate: string;
constructor(response: FolderResponse, userId: string) {
this.userId = userId;
this.name = response.name;
this.id = response.id;
this.revisionDate = response.revisionDate;
}
}

View File

@@ -0,0 +1,47 @@
import { IdentityApi } from "../api/identityApi";
export class IdentityData {
title: string;
firstName: string;
middleName: string;
lastName: string;
address1: string;
address2: string;
address3: string;
city: string;
state: string;
postalCode: string;
country: string;
company: string;
email: string;
phone: string;
ssn: string;
username: string;
passportNumber: string;
licenseNumber: string;
constructor(data?: IdentityApi) {
if (data == null) {
return;
}
this.title = data.title;
this.firstName = data.firstName;
this.middleName = data.middleName;
this.lastName = data.lastName;
this.address1 = data.address1;
this.address2 = data.address2;
this.address3 = data.address3;
this.city = data.city;
this.state = data.state;
this.postalCode = data.postalCode;
this.country = data.country;
this.company = data.company;
this.email = data.email;
this.phone = data.phone;
this.ssn = data.ssn;
this.username = data.username;
this.passportNumber = data.passportNumber;
this.licenseNumber = data.licenseNumber;
}
}

View File

@@ -0,0 +1,28 @@
import { LoginApi } from "../api/loginApi";
import { LoginUriData } from "./loginUriData";
export class LoginData {
uris: LoginUriData[];
username: string;
password: string;
passwordRevisionDate: string;
totp: string;
autofillOnPageLoad: boolean;
constructor(data?: LoginApi) {
if (data == null) {
return;
}
this.username = data.username;
this.password = data.password;
this.passwordRevisionDate = data.passwordRevisionDate;
this.totp = data.totp;
this.autofillOnPageLoad = data.autofillOnPageLoad;
if (data.uris) {
this.uris = data.uris.map((u) => new LoginUriData(u));
}
}
}

View File

@@ -0,0 +1,15 @@
import { UriMatchType } from "../../enums/uriMatchType";
import { LoginUriApi } from "../api/loginUriApi";
export class LoginUriData {
uri: string;
match: UriMatchType = null;
constructor(data?: LoginUriApi) {
if (data == null) {
return;
}
this.uri = data.uri;
this.match = data.match;
}
}

View File

@@ -0,0 +1,15 @@
import { PasswordHistoryResponse } from "../response/passwordHistoryResponse";
export class PasswordHistoryData {
password: string;
lastUsedDate: string;
constructor(response?: PasswordHistoryResponse) {
if (response == null) {
return;
}
this.password = response.password;
this.lastUsedDate = response.lastUsedDate;
}
}

View File

@@ -0,0 +1,18 @@
import { PolicyType } from "../../enums/policyType";
import { PolicyResponse } from "../response/policyResponse";
export class PolicyData {
id: string;
organizationId: string;
type: PolicyType;
data: any;
enabled: boolean;
constructor(response: PolicyResponse) {
this.id = response.id;
this.organizationId = response.organizationId;
this.type = response.type;
this.data = response.data;
this.enabled = response.enabled;
}
}

View File

@@ -0,0 +1,14 @@
import { SecureNoteType } from "../../enums/secureNoteType";
import { SecureNoteApi } from "../api/secureNoteApi";
export class SecureNoteData {
type: SecureNoteType;
constructor(data?: SecureNoteApi) {
if (data == null) {
return;
}
this.type = data.type;
}
}

View File

@@ -0,0 +1,58 @@
import { SendType } from "../../enums/sendType";
import { SendResponse } from "../response/sendResponse";
import { SendFileData } from "./sendFileData";
import { SendTextData } from "./sendTextData";
export class SendData {
id: string;
accessId: string;
userId: string;
type: SendType;
name: string;
notes: string;
file: SendFileData;
text: SendTextData;
key: string;
maxAccessCount?: number;
accessCount: number;
revisionDate: string;
expirationDate: string;
deletionDate: string;
password: string;
disabled: boolean;
hideEmail: boolean;
constructor(response?: SendResponse, userId?: string) {
if (response == null) {
return;
}
this.id = response.id;
this.accessId = response.accessId;
this.userId = userId;
this.type = response.type;
this.name = response.name;
this.notes = response.notes;
this.key = response.key;
this.maxAccessCount = response.maxAccessCount;
this.accessCount = response.accessCount;
this.revisionDate = response.revisionDate;
this.expirationDate = response.expirationDate;
this.deletionDate = response.deletionDate;
this.password = response.password;
this.disabled = response.disable;
this.hideEmail = response.hideEmail;
switch (this.type) {
case SendType.Text:
this.text = new SendTextData(response.text);
break;
case SendType.File:
this.file = new SendFileData(response.file);
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,19 @@
import { SendFileApi } from "../api/sendFileApi";
export class SendFileData {
id: string;
fileName: string;
size: string;
sizeName: string;
constructor(data?: SendFileApi) {
if (data == null) {
return;
}
this.id = data.id;
this.fileName = data.fileName;
this.size = data.size;
this.sizeName = data.sizeName;
}
}

View File

@@ -0,0 +1,15 @@
import { SendTextApi } from "../api/sendTextApi";
export class SendTextData {
text: string;
hidden: boolean;
constructor(data?: SendTextApi) {
if (data == null) {
return;
}
this.text = data.text;
this.hidden = data.hidden;
}
}

View File

@@ -1,11 +1,23 @@
import { AuthenticationStatus } from "../../enums/authenticationStatus";
import { KdfType } from "../../enums/kdfType";
import { UriMatchType } from "../../enums/uriMatchType";
import { CipherData } from "../data/cipherData";
import { CollectionData } from "../data/collectionData";
import { EventData } from "../data/eventData";
import { FolderData } from "../data/folderData";
import { OrganizationData } from "../data/organizationData";
import { PolicyData } from "../data/policyData";
import { ProviderData } from "../data/providerData";
import { SendData } from "../data/sendData";
import { CipherView } from "../view/cipherView";
import { CollectionView } from "../view/collectionView";
import { FolderView } from "../view/folderView";
import { SendView } from "../view/sendView";
import { EncString } from "./encString";
import { EnvironmentUrls } from "./environmentUrls";
import { GeneratedPasswordHistory } from "./generatedPasswordHistory";
import { Policy } from "./policy";
import { SymmetricCryptoKey } from "./symmetricCryptoKey";
export class EncryptionPair<TEncrypted, TDecrypted> {
@@ -19,15 +31,27 @@ export class DataEncryptionPair<TEncrypted, TDecrypted> {
}
export class AccountData {
ciphers?: any = new DataEncryptionPair<any, any>();
folders?: DataEncryptionPair<any, any> = new DataEncryptionPair<any, any>();
ciphers?: DataEncryptionPair<CipherData, CipherView> = new DataEncryptionPair<
CipherData,
CipherView
>();
folders?: DataEncryptionPair<FolderData, FolderView> = new DataEncryptionPair<
FolderData,
FolderView
>();
localData?: any;
sends?: any = new DataEncryptionPair<any, any>();
collections?: DataEncryptionPair<any, any> = new DataEncryptionPair<any, any>();
policies?: DataEncryptionPair<any, any> = new DataEncryptionPair<any, any>();
passwordGenerationHistory?: EncryptionPair<any[], any[]> = new EncryptionPair<any[], any[]>();
sends?: DataEncryptionPair<SendData, SendView> = new DataEncryptionPair<SendData, SendView>();
collections?: DataEncryptionPair<CollectionData, CollectionView> = new DataEncryptionPair<
CollectionData,
CollectionView
>();
policies?: DataEncryptionPair<PolicyData, Policy> = new DataEncryptionPair<PolicyData, Policy>();
passwordGenerationHistory?: EncryptionPair<
GeneratedPasswordHistory[],
GeneratedPasswordHistory[]
> = new EncryptionPair<GeneratedPasswordHistory[], GeneratedPasswordHistory[]>();
addEditCipherInfo?: any;
eventCollection?: any[];
eventCollection?: EventData[];
organizations?: { [id: string]: OrganizationData };
providers?: { [id: string]: ProviderData };
}

View File

@@ -0,0 +1,87 @@
import { CryptoService } from "../../abstractions/crypto.service";
import { Utils } from "../../misc/utils";
import { AttachmentData } from "../data/attachmentData";
import { AttachmentView } from "../view/attachmentView";
import Domain from "./domainBase";
import { EncString } from "./encString";
import { SymmetricCryptoKey } from "./symmetricCryptoKey";
export class Attachment extends Domain {
id: string;
url: string;
size: string;
sizeName: string; // Readable size, ex: "4.2 KB" or "1.43 GB"
key: EncString;
fileName: EncString;
constructor(obj?: AttachmentData) {
super();
if (obj == null) {
return;
}
this.size = obj.size;
this.buildDomainModel(
this,
obj,
{
id: null,
url: null,
sizeName: null,
fileName: null,
key: null,
},
["id", "url", "sizeName"],
);
}
async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<AttachmentView> {
const view = await this.decryptObj(
new AttachmentView(this),
{
fileName: null,
},
orgId,
encKey,
);
if (this.key != null) {
let cryptoService: CryptoService;
const containerService = (Utils.global as any).bitwardenContainerService;
if (containerService) {
cryptoService = containerService.getCryptoService();
} else {
throw new Error("global bitwardenContainerService not initialized.");
}
try {
const orgKey = await cryptoService.getOrgKey(orgId);
const decValue = await cryptoService.decryptToBytes(this.key, orgKey ?? encKey);
view.key = new SymmetricCryptoKey(decValue);
} catch (e) {
// TODO: error?
}
}
return view;
}
toAttachmentData(): AttachmentData {
const a = new AttachmentData();
a.size = this.size;
this.buildDataModel(
this,
a,
{
id: null,
url: null,
sizeName: null,
fileName: null,
key: null,
},
["id", "url", "sizeName"],
);
return a;
}
}

Some files were not shown because too many files have changed in this diff Show More