diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml deleted file mode 100644 index eb6af20f9ee..00000000000 --- a/.github/workflows/release-desktop-beta.yml +++ /dev/null @@ -1,1104 +0,0 @@ -name: Release Desktop Beta - -on: - workflow_dispatch: - inputs: - version_number: - description: "New Beta Version" - required: true - -defaults: - run: - shell: bash - -jobs: - setup: - name: Setup - runs-on: ubuntu-22.04 - permissions: - contents: write - outputs: - release_version: ${{ steps.version.outputs.version }} - release_channel: ${{ steps.release_channel.outputs.channel }} - branch_name: ${{ steps.branch.outputs.branch_name }} - build_number: ${{ steps.increment-version.outputs.build_number }} - node_version: ${{ steps.retrieve-node-version.outputs.node_version }} - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Branch check - run: | - if [[ "$GITHUB_REF" != "refs/heads/main" ]] && [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then - echo "===================================" - echo "[!] Can only release from the 'main', 'rc' or 'hotfix-rc' branches" - echo "===================================" - exit 1 - fi - - - name: Bump Desktop Version - Root - env: - VERSION: ${{ github.event.inputs.version_number }} - run: npm version --workspace=@bitwarden/desktop ${VERSION}-beta - - - name: Bump Desktop Version - App - env: - VERSION: ${{ github.event.inputs.version_number }} - run: npm version ${VERSION}-beta - working-directory: "apps/desktop/src" - - - name: Check Release Version - id: version - uses: bitwarden/gh-actions/release-version-check@main - with: - release-type: 'Initial Release' - project-type: ts - file: apps/desktop/src/package.json - monorepo: true - monorepo-project: desktop - - - name: Increment Version - id: increment-version - run: | - BUILD_NUMBER=$(expr 3000 + $GITHUB_RUN_NUMBER) - echo "Setting build number to $BUILD_NUMBER" - echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT - - - name: Get Version Channel - id: release_channel - run: | - case "${{ steps.version.outputs.version }}" in - *"alpha"*) - echo "channel=alpha" >> $GITHUB_OUTPUT - echo "[!] We do not yet support 'alpha'" - exit 1 - ;; - *"beta"*) - echo "channel=beta" >> $GITHUB_OUTPUT - ;; - *) - echo "channel=latest" >> $GITHUB_OUTPUT - ;; - esac - - - name: Setup git config - run: | - git config --global user.name "GitHub Action Bot" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global url."https://github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://".insteadOf ssh:// - - - name: Create desktop-beta-release branch - id: branch - env: - VERSION: ${{ github.event.inputs.version_number }} - run: | - find="." - replace="_" - ver=${VERSION//$find/$replace} - branch_name=desktop-beta-release-$ver-beta - - git switch -c $branch_name - git add . - git commit -m "Bump desktop version to $VERSION-beta" - - git push -u origin $branch_name - - echo "branch_name=$branch_name" >> $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: - name: Linux Build - runs-on: ubuntu-22.04 - needs: setup - permissions: - contents: read - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up environment - run: | - sudo apt-get update - sudo apt-get -y install pkg-config libxss-dev rpm - - - name: Set up Snap - run: sudo snap install snapcraft --classic - - - name: Print environment - run: | - node --version - npm --version - snap --version - snapcraft --version || echo 'snapcraft unavailable' - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build application - run: npm run dist:lin - - - name: Upload .deb artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb - if-no-files-found: error - - - name: Upload .rpm artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm - if-no-files-found: error - - - name: Upload .freebsd artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd - if-no-files-found: error - - - name: Upload .snap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap - path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap - if-no-files-found: error - - - name: Upload .AppImage artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage - if-no-files-found: error - - - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ needs.setup.outputs.release_channel }}-linux.yml - path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-linux.yml - if-no-files-found: error - - - windows: - name: Windows Build - runs-on: windows-2022 - needs: setup - permissions: - contents: read - id-token: write - defaults: - run: - shell: pwsh - working-directory: apps/desktop - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Install AST - run: dotnet tool install --global AzureSignTool --version 4.0.1 - - - name: Set up environment - run: choco install checksum --no-progress - - - name: Print environment - run: | - node --version - npm --version - choco --version - - - 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: Retrieve secrets - id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "code-signing-vault-url, - code-signing-client-id, - code-signing-tenant-id, - code-signing-client-secret, - code-signing-cert-name" - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build & Sign (dev) - env: - ELECTRON_BUILDER_SIGN: 1 - SIGNING_VAULT_URL: ${{ steps.retrieve-secrets.outputs.code-signing-vault-url }} - SIGNING_CLIENT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-client-id }} - SIGNING_TENANT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-tenant-id }} - SIGNING_CLIENT_SECRET: ${{ steps.retrieve-secrets.outputs.code-signing-client-secret }} - SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }} - run: | - npm run build - npm run pack:win - - - name: Rename appx files for store - run: | - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx" - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx" - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx" - - - name: Package for Chocolatey - run: | - Copy-Item -Path ./stores/chocolatey -Destination ./dist/chocolatey -Recurse - Copy-Item -Path ./dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe ` - -Destination ./dist/chocolatey - - $checksum = checksum -t sha256 ./dist/chocolatey/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe - $chocoInstall = "./dist/chocolatey/tools/chocolateyinstall.ps1" - (Get-Content $chocoInstall).replace('__version__', "$env:_PACKAGE_VERSION").replace('__checksum__', $checksum) | Set-Content $chocoInstall - choco pack ./dist/chocolatey/bitwarden.nuspec --version "$env:_PACKAGE_VERSION" --out ./dist/chocolatey - - - name: Fix NSIS artifact names for auto-updater - run: | - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - - - name: Upload portable exe artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe - path: apps/desktop/dist/Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe - if-no-files-found: error - - - name: Upload installer exe artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe - path: apps/desktop/dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe - if-no-files-found: error - - - name: Upload appx ia32 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx - if-no-files-found: error - - - name: Upload store appx ia32 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx - if-no-files-found: error - - - name: Upload NSIS ia32 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - if-no-files-found: error - - - name: Upload appx x64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx - if-no-files-found: error - - - name: Upload store appx x64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx - if-no-files-found: error - - - name: Upload NSIS x64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - if-no-files-found: error - - - name: Upload appx ARM64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx - if-no-files-found: error - - - name: Upload store appx ARM64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx - if-no-files-found: error - - - name: Upload NSIS ARM64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - if-no-files-found: error - - - name: Upload nupkg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg - path: apps/desktop/dist/chocolatey/bitwarden.${{ env._PACKAGE_VERSION }}.nupkg - if-no-files-found: error - - - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ needs.setup.outputs.release_channel }}.yml - path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml - if-no-files-found: error - - - macos-build: - name: MacOS Build - runs-on: macos-13 - needs: setup - permissions: - contents: read - id-token: write - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up Node-gyp - run: python3 -m pip install setuptools - - - name: Print environment - run: | - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Cache Build - id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/desktop/build - key: ${{ runner.os }}-${{ github.run_id }}-build - - - name: Cache Safari - id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/browser/dist/Safari - key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - - 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-clients - secrets: "KEYCHAIN-PASSWORD" - - - name: Download Provisioning Profiles secrets - env: - ACCOUNT_NAME: bitwardenci - CONTAINER_NAME: profiles - run: | - mkdir -p $HOME/secrets - - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ - --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - --output none - - - name: Get certificates - run: | - mkdir -p $HOME/certificates - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 - - 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 - - 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 - - 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 - - - name: Set up keychain - env: - KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} - run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 1200 build.keychain - security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - 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 - - - name: Set up provisioning profiles - run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile - - - name: Increment version - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} - run: | - $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json - $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" - $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build application (dev) - run: npm run build - - - macos-package-github: - name: MacOS Package GitHub Release Assets - runs-on: macos-13 - needs: - - setup - - macos-build - permissions: - contents: read - packages: read - id-token: write - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up Node-gyp - run: python3 -m pip install setuptools - - - name: Print environment - run: | - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Get Build Cache - id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/desktop/build - key: ${{ runner.os }}-${{ github.run_id }}-build - - - name: Setup Safari Cache - id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/browser/dist/Safari - key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - - 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-clients - secrets: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD" - - - name: Download Provisioning Profiles secrets - env: - ACCOUNT_NAME: bitwardenci - CONTAINER_NAME: profiles - run: | - mkdir -p $HOME/secrets - - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ - --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - --output none - - - name: Get certificates - run: | - mkdir -p $HOME/certificates - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 - - 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 - - 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 - - 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 - - - name: Set up keychain - env: - KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} - run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 1200 build.keychain - - security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - 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 - - - name: Set up provisioning profiles - run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile - - - name: Increment version - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} - run: | - $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json - $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" - $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm run build - - - name: Download artifact from hotfix-rc - if: github.ref == 'refs/heads/hotfix-rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: hotfix-rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifact from rc - if: github.ref == 'refs/heads/rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifacts from main - if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: main - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Unzip Safari artifact - run: | - SAFARI_DIR=$(find $GITHUB_WORKSPACE/browser-build-artifacts -name 'dist-safari-*.zip') - echo $SAFARI_DIR - unzip $SAFARI_DIR/dist-safari.zip -d $GITHUB_WORKSPACE/browser-build-artifacts - - - name: Load Safari extension for .dmg - run: | - mkdir PlugIns - cp -r $GITHUB_WORKSPACE/browser-build-artifacts/Safari/dmg/build/Release/safari.appex PlugIns/safari.appex - - - name: Build application (dist) - env: - APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }} - APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }} - run: npm run pack:mac - - - name: Upload .zip artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip - if-no-files-found: error - - - name: Upload .dmg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg - if-no-files-found: error - - - name: Upload .dmg blockmap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap - if-no-files-found: error - - - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ needs.setup.outputs.release_channel }}-mac.yml - path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml - if-no-files-found: error - - - macos-package-mas: - name: MacOS Package Prod Release Asset - runs-on: macos-13 - needs: - - setup - - macos-build - permissions: - contents: read - packages: read - id-token: write - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up Node-gyp - run: python3 -m pip install setuptools - - - name: Print environment - run: | - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Get Build Cache - id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/desktop/build - key: ${{ runner.os }}-${{ github.run_id }}-build - - - name: Setup Safari Cache - id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/browser/dist/Safari - key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - - 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-clients - secrets: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD" - - - name: Download Provisioning Profiles secrets - env: - ACCOUNT_NAME: bitwardenci - CONTAINER_NAME: profiles - run: | - mkdir -p $HOME/secrets - - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ - --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - --output none - - - name: Get certificates - run: | - mkdir -p $HOME/certificates - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 - - 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 - - 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 - - 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 - - - name: Set up keychain - env: - KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} - run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 1200 build.keychain - - security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - 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 - - - name: Set up provisioning profiles - run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile - - - name: Increment version - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} - run: | - $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json - $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" - $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm run build - - - name: Download artifact from hotfix-rc - if: github.ref == 'refs/heads/hotfix-rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: hotfix-rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifact from rc - if: github.ref == 'refs/heads/rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifact from main - if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: main - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Unzip Safari artifact - run: | - SAFARI_DIR=$(find $GITHUB_WORKSPACE/browser-build-artifacts -name 'dist-safari-*.zip') - echo $SAFARI_DIR - unzip $SAFARI_DIR/dist-safari.zip -d $GITHUB_WORKSPACE/browser-build-artifacts - - - name: Load Safari extension for App Store - run: | - mkdir PlugIns - cp -r $GITHUB_WORKSPACE/browser-build-artifacts/Safari/mas/build/Release/safari.appex PlugIns/safari.appex - - - name: Build application for App Store - run: npm run pack:mac:mas - env: - APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }} - APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }} - - - name: Upload .pkg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg - path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg - if-no-files-found: error - - release: - name: Release beta channel to S3 - runs-on: ubuntu-22.04 - needs: - - setup - - linux - - windows - - macos-build - - macos-package-github - - macos-package-mas - permissions: - contents: read - id-token: write - deployments: write - steps: - - name: Create GitHub deployment - uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7 - id: deployment - with: - token: '${{ secrets.GITHUB_TOKEN }}' - initial-status: 'in_progress' - environment: 'Desktop - Beta' - description: 'Deployment ${{ needs.setup.outputs.release_version }} to channel ${{ needs.setup.outputs.release_channel }} from branch ${{ needs.setup.outputs.branch_name }}' - task: release - - - 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: Retrieve secrets - id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "aws-electron-access-id, - aws-electron-access-key, - aws-electron-bucket-name" - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Download all artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - path: apps/desktop/artifacts - - - name: Rename .pkg to .pkg.archive - env: - PKG_VERSION: ${{ needs.setup.outputs.release_version }} - working-directory: apps/desktop/artifacts - run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive - - - name: Publish artifacts to S3 - env: - AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} - AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }} - AWS_DEFAULT_REGION: 'us-west-2' - AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} - working-directory: apps/desktop/artifacts - run: | - aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ - --acl "public-read" \ - --recursive \ - --quiet - - - name: Update deployment status to Success - if: ${{ success() }} - uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - state: 'success' - deployment-id: ${{ steps.deployment.outputs.deployment_id }} - - - name: Update deployment status to Failure - if: ${{ failure() }} - uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - state: 'failure' - deployment-id: ${{ steps.deployment.outputs.deployment_id }} - - remove-branch: - name: Remove branch - runs-on: ubuntu-22.04 - if: always() - needs: - - setup - - linux - - windows - - macos-build - - macos-package-github - - macos-package-mas - - release - permissions: - contents: write - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Setup git config - run: | - git config --global user.name "GitHub Action Bot" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global url."https://github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://".insteadOf ssh:// - - name: Remove branch - env: - BRANCH: ${{ needs.setup.outputs.branch_name }} - run: git push origin --delete $BRANCH diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index bb2483daf3b..3c46085c3d7 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5588,6 +5588,9 @@ "showLess": { "message": "Show less" }, + "next": { + "message": "Next" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index df29502edeb..35704fc289f 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -302,6 +302,7 @@ import { OffscreenStorageService } from "../platform/storage/offscreen-storage.s import { SyncServiceListener } from "../platform/sync/sync-service.listener"; import { BrowserSystemNotificationService } from "../platform/system-notifications/browser-system-notification.service"; import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging"; +import { AtRiskCipherBadgeUpdaterService } from "../vault/services/at-risk-cipher-badge-updater.service"; import CommandsBackground from "./commands.background"; import IdleBackground from "./idle.background"; @@ -433,6 +434,7 @@ export default class MainBackground { badgeService: BadgeService; authStatusBadgeUpdaterService: AuthStatusBadgeUpdaterService; autofillBadgeUpdaterService: AutofillBadgeUpdaterService; + atRiskCipherUpdaterService: AtRiskCipherBadgeUpdaterService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -735,6 +737,7 @@ export default class MainBackground { this.logService, (logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId), this.vaultTimeoutSettingsService, + this.accountService, { createRequest: (url, request) => new Request(url, request) }, ); @@ -841,7 +844,7 @@ export default class MainBackground { this.tokenService, ); - this.configApiService = new ConfigApiService(this.apiService, this.tokenService); + this.configApiService = new ConfigApiService(this.apiService); this.configService = new DefaultConfigService( this.configApiService, @@ -1838,6 +1841,14 @@ export default class MainBackground { this.logService, ); + this.atRiskCipherUpdaterService = new AtRiskCipherBadgeUpdaterService( + this.badgeService, + this.accountService, + this.cipherService, + this.logService, + this.taskService, + ); + this.tabsBackground = new TabsBackground( this, this.notificationBackground, @@ -1847,6 +1858,7 @@ export default class MainBackground { await this.overlayBackground.init(); await this.tabsBackground.init(); await this.autofillBadgeUpdaterService.init(); + await this.atRiskCipherUpdaterService.init(); } generatePassword = async (): Promise => { diff --git a/apps/browser/src/images/berry19.png b/apps/browser/src/images/berry19.png new file mode 100644 index 00000000000..51deb3b8d68 Binary files /dev/null and b/apps/browser/src/images/berry19.png differ diff --git a/apps/browser/src/images/berry38.png b/apps/browser/src/images/berry38.png new file mode 100644 index 00000000000..44a67063701 Binary files /dev/null and b/apps/browser/src/images/berry38.png differ diff --git a/apps/browser/src/platform/badge/icon.ts b/apps/browser/src/platform/badge/icon.ts index d6dcdcc5f7d..d60633a0ef1 100644 --- a/apps/browser/src/platform/badge/icon.ts +++ b/apps/browser/src/platform/badge/icon.ts @@ -1,4 +1,8 @@ export const BadgeIcon = { + Berry: { + 19: "/images/berry19.png", + 38: "/images/berry38.png", + }, LoggedOut: { 19: "/images/icon19_gray.png", 38: "/images/icon38_gray.png", diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts new file mode 100644 index 00000000000..f2567ef4267 --- /dev/null +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts @@ -0,0 +1,84 @@ +import { BehaviorSubject } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { BadgeService } from "../../platform/badge/badge.service"; +import { BadgeIcon } from "../../platform/badge/icon"; +import { BadgeStatePriority } from "../../platform/badge/priority"; +import { Unset } from "../../platform/badge/state"; +import { BrowserApi } from "../../platform/browser/browser-api"; + +import { AtRiskCipherBadgeUpdaterService } from "./at-risk-cipher-badge-updater.service"; + +describe("AtRiskCipherBadgeUpdaterService", () => { + let service: AtRiskCipherBadgeUpdaterService; + + let setState: jest.Mock; + let clearState: jest.Mock; + let warning: jest.Mock; + let getAllDecryptedForUrl: jest.Mock; + let getTab: jest.Mock; + let addListener: jest.Mock; + + const activeAccount$ = new BehaviorSubject({ id: "test-account-id" }); + const cipherViews$ = new BehaviorSubject([]); + const pendingTasks$ = new BehaviorSubject([]); + const userId = "test-user-id" as UserId; + + beforeEach(async () => { + setState = jest.fn().mockResolvedValue(undefined); + clearState = jest.fn().mockResolvedValue(undefined); + warning = jest.fn(); + getAllDecryptedForUrl = jest.fn().mockResolvedValue([]); + getTab = jest.fn(); + addListener = jest.fn(); + + jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener); + jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab); + + service = new AtRiskCipherBadgeUpdaterService( + { setState, clearState } as unknown as BadgeService, + { activeAccount$ } as unknown as AccountService, + { cipherViews$, getAllDecryptedForUrl } as unknown as CipherService, + { warning } as unknown as LogService, + { pendingTasks$ } as unknown as TaskService, + ); + + await service.init(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("clears the tab state when there are no ciphers and no pending tasks", async () => { + const tab = { id: 1 } as chrome.tabs.Tab; + + await service["setTabState"](tab, userId, []); + + expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1"); + }); + + it("sets state when there are pending tasks for the tab", async () => { + const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab; + const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask]; + getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]); + + await service["setTabState"](tab, userId, pendingTasks); + + expect(setState).toHaveBeenCalledWith( + "at-risk-cipher-badge-3", + BadgeStatePriority.High, + { + icon: BadgeIcon.Berry, + text: Unset, + backgroundColor: Unset, + }, + 3, + ); + }); +}); diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts new file mode 100644 index 00000000000..47364958ad8 --- /dev/null +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts @@ -0,0 +1,163 @@ +import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; + +import { BadgeService } from "../../platform/badge/badge.service"; +import { BadgeIcon } from "../../platform/badge/icon"; +import { BadgeStatePriority } from "../../platform/badge/priority"; +import { Unset } from "../../platform/badge/state"; +import { BrowserApi } from "../../platform/browser/browser-api"; + +const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`; + +export class AtRiskCipherBadgeUpdaterService { + private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>(); + private tabUpdated$ = new Subject(); + private tabRemoved$ = new Subject(); + private tabActivated$ = new Subject(); + + private activeUserData$ = this.accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => + combineLatest([ + of(user.id), + this.taskService + .pendingTasks$(user.id) + .pipe( + map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)), + ), + this.cipherService.cipherViews$(user.id).pipe(filterOutNullish()), + ]), + ), + ); + + constructor( + private badgeService: BadgeService, + private accountService: AccountService, + private cipherService: CipherService, + private logService: LogService, + private taskService: TaskService, + ) { + combineLatest({ + replaced: this.tabReplaced$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => { + await this.clearTabState(replaced.removedTabId); + await this.setTabState(replaced.addedTab, userId, pendingTasks); + }), + ) + .subscribe(() => {}); + + combineLatest({ + tab: this.tabActivated$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { + await this.setTabState(tab, userId, pendingTasks); + }), + ) + .subscribe(); + + combineLatest({ + tab: this.tabUpdated$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { + await this.setTabState(tab, userId, pendingTasks); + }), + ) + .subscribe(); + + this.tabRemoved$ + .pipe( + mergeMap(async (tabId) => { + await this.clearTabState(tabId); + }), + ) + .subscribe(); + } + + init() { + BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => { + const newTab = await BrowserApi.getTab(addedTabId); + if (!newTab) { + this.logService.warning( + `Tab replaced event received but new tab not found (id: ${addedTabId})`, + ); + return; + } + + this.tabReplaced$.next({ + removedTabId, + addedTab: newTab, + }); + }); + + BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => { + if (changeInfo.url) { + this.tabUpdated$.next(tab); + } + }); + + BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => { + const tab = await BrowserApi.getTab(activeInfo.tabId); + if (!tab) { + this.logService.warning( + `Tab activated event received but tab not found (id: ${activeInfo.tabId})`, + ); + return; + } + + this.tabActivated$.next(tab); + }); + + BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId)); + } + + /** Sets the pending task state for the tab */ + private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) { + if (!tab.id) { + this.logService.warning("Tab event received but tab id is undefined"); + return; + } + + const ciphers = tab.url + ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true) + : []; + + const hasPendingTasksForTab = pendingTasks.some((task) => + ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted), + ); + + if (!hasPendingTasksForTab) { + await this.clearTabState(tab.id); + return; + } + + await this.badgeService.setState( + StateName(tab.id), + BadgeStatePriority.High, + { + icon: BadgeIcon.Berry, + // Unset text and background color to use default badge appearance + text: Unset, + backgroundColor: Unset, + }, + tab.id, + ); + } + + /** Clears the pending task state from a tab */ + private async clearTabState(tabId: number) { + await this.badgeService.clearState(StateName(tabId)); + } +} diff --git a/apps/cli/src/platform/services/node-api.service.ts b/apps/cli/src/platform/services/node-api.service.ts index d695272364b..e6527ed3abd 100644 --- a/apps/cli/src/platform/services/node-api.service.ts +++ b/apps/cli/src/platform/services/node-api.service.ts @@ -4,6 +4,7 @@ import * as FormData from "form-data"; import { HttpsProxyAgent } from "https-proxy-agent"; import * as fe from "node-fetch"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -28,6 +29,7 @@ export class NodeApiService extends ApiService { logService: LogService, logoutCallback: () => Promise, vaultTimeoutSettingsService: VaultTimeoutSettingsService, + accountService: AccountService, customUserAgent: string = null, ) { super( @@ -39,6 +41,7 @@ export class NodeApiService extends ApiService { logService, logoutCallback, vaultTimeoutSettingsService, + accountService, { createRequest: (url, request) => new Request(url, request) }, customUserAgent, ); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 508ade4650e..27fde5863de 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -504,12 +504,13 @@ export class ServiceContainer { this.logService, logoutCallback, this.vaultTimeoutSettingsService, + this.accountService, customUserAgent, ); this.containerService = new ContainerService(this.keyService, this.encryptService); - this.configApiService = new ConfigApiService(this.apiService, this.tokenService); + this.configApiService = new ConfigApiService(this.apiService); this.authService = new AuthService( this.accountService, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index c805096189b..1094a8be26f 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4080,5 +4080,8 @@ "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." + }, + "next": { + "message": "Next" } } diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts index 7e25a422477..20e69cf3bfd 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts @@ -42,7 +42,7 @@ export type TrialOrganizationType = Exclude - {{ freeTrialData.message }} + {{ freeTrialData?.message }} a?.id)), ); + + if (!userId) { + throw new Error("User ID is not found"); + } + const organizationPromise = await firstValueFrom( this.organizationService .organizations$(userId) @@ -173,15 +176,20 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { organizationSubscriptionPromise, organizationPromise, ]); + + if (!this.organization) { + throw new Error("Organization is not found"); + } + if (!this.paymentSource) { + throw new Error("Payment source is not found"); + } + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( this.organization, this.organizationSubscriptionResponse, - paymentSource, + this.paymentSource, ); } - // TODO: Eslint upgrade. Please resolve this since the ?? does nothing - // eslint-disable-next-line no-constant-binary-expression - this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false; // If the flag `launchPaymentModalAutomatically` is set to true, // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. // This delay ensures that any prior UI/rendering operations complete before triggering the modal. @@ -219,14 +227,14 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { data: { organizationId: this.organizationId, - subscription: this.organizationSubscriptionResponse, - productTierType: this.organization?.productTierType, + subscription: this.organizationSubscriptionResponse!, + productTierType: this.organization!.productTierType, }, }); const result = await lastValueFrom(dialogRef.closed); if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { this.location.replaceState(this.location.path(), "", {}); - if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + if (this.launchPaymentModalAutomatically && !this.organization?.enabled) { await this.syncService.fullSync(true); } this.launchPaymentModalAutomatically = false; @@ -238,13 +246,14 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request); this.toastService.showToast({ variant: "success", - title: null, + title: "", message: this.i18nService.t("verifiedBankAccount"), }); }; protected get accountCreditHeaderText(): string { - const key = this.accountCredit <= 0 ? "accountBalance" : "accountCredit"; + const hasAccountCredit = this.accountCredit && this.accountCredit > 0; + const key = hasAccountCredit ? "accountCredit" : "accountBalance"; return this.i18nService.t(key); } @@ -279,7 +288,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { if (!hasBillingAddress) { this.toastService.showToast({ variant: "error", - title: null, + title: "", message: this.i18nService.t("billingAddressRequiredToAddCredit"), }); return false; diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts index 94929c58656..9944085488f 100644 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts +++ b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts @@ -24,7 +24,7 @@ import { import { PaymentComponent } from "../payment/payment.component"; export interface AdjustPaymentDialogParams { - initialPaymentMethod?: PaymentMethodType; + initialPaymentMethod?: PaymentMethodType | null; organizationId?: string; productTier?: ProductTierType; providerId?: string; diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts index 0e116b4f39a..91d5925669a 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ b/apps/web/src/app/billing/shared/payment-method.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Location } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, FormControl, Validators } from "@angular/forms"; @@ -42,21 +40,21 @@ import { export class PaymentMethodComponent implements OnInit, OnDestroy { loading = false; firstLoaded = false; - billing: BillingPaymentResponse; - org: OrganizationSubscriptionResponse; - sub: SubscriptionResponse; + billing?: BillingPaymentResponse; + org?: OrganizationSubscriptionResponse; + sub?: SubscriptionResponse; paymentMethodType = PaymentMethodType; - organizationId: string; + organizationId?: string; isUnpaid = false; - organization: Organization; + organization?: Organization; verifyBankForm = this.formBuilder.group({ - amount1: new FormControl(null, [ + amount1: new FormControl(0, [ Validators.required, Validators.max(99), Validators.min(0), ]), - amount2: new FormControl(null, [ + amount2: new FormControl(0, [ Validators.required, Validators.max(99), Validators.min(0), @@ -64,7 +62,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { }); launchPaymentModalAutomatically = false; - protected freeTrialData: FreeTrial; + protected freeTrialData?: FreeTrial; constructor( protected apiService: ApiService, @@ -84,7 +82,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { private configService: ConfigService, ) { const state = this.router.getCurrentNavigation()?.extras?.state; - // incase the above state is undefined or null we use redundantState + // In case the above state is undefined or null, we use redundantState const redundantState: any = location.getState(); if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; @@ -129,17 +127,23 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { } this.loading = true; if (this.forOrganization) { - const billingPromise = this.organizationApiService.getBilling(this.organizationId); + const billingPromise = this.organizationApiService.getBilling(this.organizationId!); const organizationSubscriptionPromise = this.organizationApiService.getSubscription( - this.organizationId, + this.organizationId!, ); + const userId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); + + if (!userId) { + throw new Error("User ID is not found"); + } + const organizationPromise = await firstValueFrom( this.organizationService .organizations$(userId) - .pipe(getOrganizationById(this.organizationId)), + .pipe(getOrganizationById(this.organizationId!)), ); [this.billing, this.org, this.organization] = await Promise.all([ @@ -171,14 +175,16 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { }; addCredit = async () => { - const dialogRef = openAddCreditDialog(this.dialogService, { - data: { - organizationId: this.organizationId, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === AddCreditDialogResult.Added) { - await this.load(); + if (this.forOrganization) { + const dialogRef = openAddCreditDialog(this.dialogService, { + data: { + organizationId: this.organizationId!, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === AddCreditDialogResult.Added) { + await this.load(); + } } }; @@ -194,7 +200,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { if (result === AdjustPaymentDialogResultType.Submitted) { this.location.replaceState(this.location.path(), "", {}); - if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + if (this.launchPaymentModalAutomatically && !this.organization?.enabled) { await this.syncService.fullSync(true); } this.launchPaymentModalAutomatically = false; @@ -208,18 +214,22 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { } const request = new VerifyBankRequest(); - request.amount1 = this.verifyBankForm.value.amount1; - request.amount2 = this.verifyBankForm.value.amount2; - await this.organizationApiService.verifyBank(this.organizationId, request); + request.amount1 = this.verifyBankForm.value.amount1!; + request.amount2 = this.verifyBankForm.value.amount2!; + await this.organizationApiService.verifyBank(this.organizationId!, request); this.toastService.showToast({ variant: "success", - title: null, + title: "", message: this.i18nService.t("verifiedBankAccount"), }); await this.load(); }; determineOrgsWithUpcomingPaymentIssues() { + if (!this.organization || !this.org || !this.billing) { + throw new Error("Organization, organization subscription, or billing is not defined"); + } + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( this.organization, this.org, diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html index e74997cb9f5..c1a33a4c8df 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html @@ -37,7 +37,7 @@ bitButton buttonType="primary" [disabled]="orgInfoFormGroup.controls.name.invalid" - [loading]="loading && (trialPaymentOptional$ | async)" + [loading]="loading && (trialPaymentOptional$ | async)!" (click)="orgNameEntrySubmit()" > {{ @@ -55,8 +55,8 @@ { + .catch((e: unknown): null => { this.validationService.showError(e); this.submitting = false; return null; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c6f8d4a3ae9..72bdd9f8b2f 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -752,6 +752,7 @@ const safeProviders: SafeProvider[] = [ LogService, LOGOUT_CALLBACK, VaultTimeoutSettingsService, + AccountService, HTTP_OPERATIONS, ], }), @@ -1158,7 +1159,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: ConfigApiServiceAbstraction, useClass: ConfigApiService, - deps: [ApiServiceAbstraction, TokenServiceAbstraction], + deps: [ApiServiceAbstraction], }), safeProvider({ provide: AnonymousHubServiceAbstraction, diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 726b04534ad..ab217c56fc4 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -127,11 +127,34 @@ import { OptionalCipherResponse } from "../vault/models/response/optional-cipher * of this decision please read https://contributing.bitwarden.com/architecture/adr/refactor-api-service. */ export abstract class ApiService { + /** @deprecated Use the overload accepting the user you want the request authenticated for. */ abstract send( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body: any, - authed: boolean, + authed: true, + hasResponse: boolean, + apiUrl?: string | null, + alterHeaders?: (header: Headers) => void, + ): Promise; + + /** Sends an unauthenticated API request. */ + abstract send( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + path: string, + body: any, + authed: false, + hasResponse: boolean, + apiUrl?: string | null, + alterHeaders?: (header: Headers) => void, + ): Promise; + + /** Sends an API request authenticated with the given users ID. */ + abstract send( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + path: string, + body: any, + userId: UserId, hasResponse: boolean, apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, @@ -499,7 +522,7 @@ export abstract class ApiService { abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise; abstract postSetupPayment(): Promise; - abstract getActiveBearerToken(): Promise; + abstract getActiveBearerToken(userId: UserId): Promise; abstract fetch(request: Request): Promise; abstract nativeFetch(request: Request): Promise; diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index 2139f32fca2..673bc7bdf0a 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -72,14 +72,14 @@ export abstract class TokenService { * @param userId - The optional user id to get the access token for; if not provided, the active user is used. * @returns A promise that resolves with the access token or null. */ - abstract getAccessToken(userId?: UserId): Promise; + abstract getAccessToken(userId: UserId): Promise; /** * Gets the refresh token. * @param userId - The optional user id to get the refresh token for; if not provided, the active user is used. * @returns A promise that resolves with the refresh token or null. */ - abstract getRefreshToken(userId?: UserId): Promise; + abstract getRefreshToken(userId: UserId): Promise; /** * Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. @@ -96,10 +96,10 @@ export abstract class TokenService { ): Promise; /** - * Gets the API Key Client ID for the active user. + * Gets the API Key Client ID for the given user. * @returns A promise that resolves with the API Key Client ID or undefined */ - abstract getClientId(userId?: UserId): Promise; + abstract getClientId(userId: UserId): Promise; /** * Sets the API Key Client Secret for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. @@ -116,10 +116,10 @@ export abstract class TokenService { ): Promise; /** - * Gets the API Key Client Secret for the active user. + * Gets the API Key Client Secret for the given user. * @returns A promise that resolves with the API Key Client Secret or undefined */ - abstract getClientSecret(userId?: UserId): Promise; + abstract getClientSecret(userId: UserId): Promise; /** * Sets the two factor token for the given email in global state. @@ -157,7 +157,7 @@ export abstract class TokenService { * Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration * @returns A promise that resolves with the expiration date for the access token. */ - abstract getTokenExpirationDate(): Promise; + abstract getTokenExpirationDate(userId: UserId): Promise; /** * Calculates the adjusted time in seconds until the access token expires, considering an optional offset. @@ -168,14 +168,14 @@ export abstract class TokenService { * based on the actual expiration. * @returns {Promise} Promise resolving to the adjusted seconds remaining. */ - abstract tokenSecondsRemaining(offsetSeconds?: number): Promise; + abstract tokenSecondsRemaining(userId: UserId, offsetSeconds?: number): Promise; /** * Checks if the access token needs to be refreshed. * @param {number} [minutes=5] - Optional number of minutes before the access token expires to consider refreshing it. * @returns A promise that resolves with a boolean indicating if the access token needs to be refreshed. */ - abstract tokenNeedsRefresh(minutes?: number): Promise; + abstract tokenNeedsRefresh(userId: UserId, minutes?: number): Promise; /** * Gets the user id for the active user from the access token. diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts index 7274954c950..f4e4ec5e204 100644 --- a/libs/common/src/auth/services/token.service.spec.ts +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -409,28 +409,8 @@ describe("TokenService", () => { }); describe("getAccessToken", () => { - it("returns null when no user id is provided and there is no active user in global state", async () => { - // Act - const result = await tokenService.getAccessToken(); - // Assert - expect(result).toBeNull(); - }); - - it("returns null when no access token is found in memory, disk, or secure storage", async () => { - // Arrange - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getAccessToken(); - // Assert - expect(result).toBeNull(); - }); - describe("Memory storage tests", () => { - test.each([ - ["gets the access token from memory when a user id is provided ", userIdFromAccessToken], - ["gets the access token from memory when no user id is provided", undefined], - ])("%s", async (_, userId) => { + it("gets the access token from memory when a user id is provided ", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -442,12 +422,10 @@ describe("TokenService", () => { .nextState(undefined); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual(accessTokenJwt); @@ -455,10 +433,7 @@ describe("TokenService", () => { }); describe("Disk storage tests (secure storage not supported on platform)", () => { - test.each([ - ["gets the access token from disk when the user id is specified", userIdFromAccessToken], - ["gets the access token from disk when no user id is specified", undefined], - ])("%s", async (_, userId) => { + it("gets the access token from disk when the user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -469,12 +444,10 @@ describe("TokenService", () => { .nextState(accessTokenJwt); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual(accessTokenJwt); }); @@ -486,16 +459,7 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - test.each([ - [ - "gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided", - userIdFromAccessToken, - ], - [ - "gets the encrypted access token from disk, decrypts it, and returns it when no user id is provided", - undefined, - ], - ])("%s", async (_, userId) => { + it("gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -509,27 +473,17 @@ describe("TokenService", () => { encryptService.decryptString.mockResolvedValue("decryptedAccessToken"); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual("decryptedAccessToken"); }); - test.each([ - [ - "falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", - userIdFromAccessToken, - ], - [ - "falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided", - undefined, - ], - ])("%s", async (_, userId) => { + it("falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -540,14 +494,12 @@ describe("TokenService", () => { .nextState(accessTokenJwt); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // No access token key set // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual(accessTokenJwt); @@ -738,7 +690,7 @@ describe("TokenService", () => { // Act // note: don't await here because we want to test the error - const result = tokenService.getTokenExpirationDate(); + const result = tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); @@ -748,7 +700,7 @@ describe("TokenService", () => { tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toBeNull(); @@ -763,7 +715,7 @@ describe("TokenService", () => { .mockResolvedValue(accessTokenDecodedWithoutExp); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toBeNull(); @@ -777,7 +729,7 @@ describe("TokenService", () => { .mockResolvedValue(accessTokenDecodedWithNonNumericExp); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toBeNull(); @@ -788,7 +740,7 @@ describe("TokenService", () => { tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toEqual(new Date(accessTokenDecoded.exp * 1000)); @@ -801,7 +753,7 @@ describe("TokenService", () => { tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(null); // Act - const result = await tokenService.tokenSecondsRemaining(); + const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken); // Assert expect(result).toEqual(0); @@ -823,7 +775,7 @@ describe("TokenService", () => { tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); // Act - const result = await tokenService.tokenSecondsRemaining(); + const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken); // Assert expect(result).toEqual(expectedSecondsRemaining); @@ -849,7 +801,10 @@ describe("TokenService", () => { tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); // Act - const result = await tokenService.tokenSecondsRemaining(offsetSeconds); + const result = await tokenService.tokenSecondsRemaining( + userIdFromAccessToken, + offsetSeconds, + ); // Assert expect(result).toEqual(expectedSecondsRemaining); @@ -866,7 +821,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken); // Assert expect(result).toEqual(true); @@ -878,7 +833,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken); // Assert expect(result).toEqual(false); @@ -890,7 +845,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(2); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 2); // Assert expect(result).toEqual(true); @@ -902,7 +857,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(5); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 5); // Assert expect(result).toEqual(false); @@ -1565,26 +1520,6 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("gets the refresh token from memory when no user id is specified (uses global active user)", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(refreshToken); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(undefined); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - - // Assert - expect(result).toEqual(refreshToken); - }); - it("gets the refresh token from memory when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -1603,25 +1538,6 @@ describe("TokenService", () => { }); describe("Disk storage tests (secure storage not supported on platform)", () => { - it("gets the refresh token from disk when no user id is specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(refreshToken); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - // Assert - expect(result).toEqual(refreshToken); - }); - it("gets the refresh token from disk when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -1645,27 +1561,6 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - it("gets the refresh token from secure storage when no user id is specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(undefined); - - secureStorageService.get.mockResolvedValue(refreshToken); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - // Assert - expect(result).toEqual(refreshToken); - }); - it("gets the refresh token from secure storage when a user id is specified", async () => { // Arrange @@ -1705,29 +1600,6 @@ describe("TokenService", () => { expect(secureStorageService.get).not.toHaveBeenCalled(); }); - it("falls back and gets the refresh token from disk when no user id is specified even if the platform supports secure storage", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(refreshToken); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - - // Assert - expect(result).toEqual(refreshToken); - - // assert that secure storage was not called - expect(secureStorageService.get).not.toHaveBeenCalled(); - }); - it("returns null when the refresh token is not found in memory, on disk, or in secure storage", async () => { // Arrange secureStorageService.get.mockResolvedValue(null); @@ -1944,45 +1816,7 @@ describe("TokenService", () => { }); describe("getClientId", () => { - it("returns undefined when no user id is provided and there is no active user in global state", async () => { - // Act - const result = await tokenService.getClientId(); - // Assert - expect(result).toBeUndefined(); - }); - - it("returns null when no client id is found in memory or disk", async () => { - // Arrange - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientId(); - // Assert - expect(result).toBeNull(); - }); - describe("Memory storage tests", () => { - it("gets the client id from memory when no user id is specified (uses global active user)", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .nextState(clientId); - - // set disk to undefined - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .nextState(undefined); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientId(); - - // Assert - expect(result).toEqual(clientId); - }); - it("gets the client id from memory when given a user id", async () => { // Arrange singleUserStateProvider @@ -2002,25 +1836,6 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("gets the client id from disk when no user id is specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .nextState(clientId); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientId(); - // Assert - expect(result).toEqual(clientId); - }); - it("gets the client id from disk when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -2215,45 +2030,17 @@ describe("TokenService", () => { }); describe("getClientSecret", () => { - it("returns undefined when no user id is provided and there is no active user in global state", async () => { - // Act - const result = await tokenService.getClientSecret(); - // Assert - expect(result).toBeUndefined(); - }); - it("returns null when no client secret is found in memory or disk", async () => { // Arrange globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getClientSecret(); + const result = await tokenService.getClientSecret(userIdFromAccessToken); // Assert expect(result).toBeNull(); }); describe("Memory storage tests", () => { - it("gets the client secret from memory when no user id is specified (uses global active user)", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .nextState(clientSecret); - - // set disk to undefined - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .nextState(undefined); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientSecret(); - - // Assert - expect(result).toEqual(clientSecret); - }); - it("gets the client secret from memory when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -2273,25 +2060,6 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("gets the client secret from disk when no user id specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .nextState(clientSecret); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientSecret(); - // Assert - expect(result).toEqual(clientSecret); - }); - it("gets the client secret from disk when a user id is specified", async () => { // Arrange singleUserStateProvider diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index 21ccd672056..0721927bd13 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -452,9 +452,7 @@ export class TokenService implements TokenServiceAbstraction { await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); } - async getAccessToken(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getAccessToken(userId: UserId): Promise { if (!userId) { return null; } @@ -631,9 +629,7 @@ export class TokenService implements TokenServiceAbstraction { } } - async getRefreshToken(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getRefreshToken(userId: UserId): Promise { if (!userId) { return null; } @@ -746,9 +742,7 @@ export class TokenService implements TokenServiceAbstraction { } } - async getClientId(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getClientId(userId: UserId): Promise { if (!userId) { return undefined; } @@ -822,9 +816,7 @@ export class TokenService implements TokenServiceAbstraction { } } - async getClientSecret(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getClientSecret(userId: UserId): Promise { if (!userId) { return undefined; } @@ -915,7 +907,9 @@ export class TokenService implements TokenServiceAbstraction { if (Utils.isGuid(tokenOrUserId)) { token = await this.getAccessToken(tokenOrUserId as UserId); } else { - token ??= await this.getAccessToken(); + token ??= await this.getAccessToken( + await firstValueFrom(this.activeUserIdGlobalState.state$), + ); } if (token == null) { @@ -928,10 +922,10 @@ export class TokenService implements TokenServiceAbstraction { // TODO: PM-6678- tech debt - consider consolidating the return types of all these access // token data retrieval methods to return null if something goes wrong instead of throwing an error. - async getTokenExpirationDate(): Promise { + async getTokenExpirationDate(userId: UserId): Promise { let decoded: DecodedAccessToken; try { - decoded = await this.decodeAccessToken(); + decoded = await this.decodeAccessToken(userId); } catch (error) { throw new Error("Failed to decode access token: " + error.message); } @@ -947,8 +941,8 @@ export class TokenService implements TokenServiceAbstraction { return expirationDate; } - async tokenSecondsRemaining(offsetSeconds = 0): Promise { - const date = await this.getTokenExpirationDate(); + async tokenSecondsRemaining(userId: UserId, offsetSeconds = 0): Promise { + const date = await this.getTokenExpirationDate(userId); if (date == null) { return 0; } @@ -957,8 +951,8 @@ export class TokenService implements TokenServiceAbstraction { return Math.round(msRemaining / 1000); } - async tokenNeedsRefresh(minutes = 5): Promise { - const sRemaining = await this.tokenSecondsRemaining(); + async tokenNeedsRefresh(userId: UserId, minutes = 5): Promise { + const sRemaining = await this.tokenSecondsRemaining(userId); return sRemaining < 60 * minutes; } diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index 7e43ee394f6..e40b896dc8c 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -70,17 +70,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA // We swap these tokens from being on disk for lock actions, and in memory for logout actions // Get them here to set them to their new location after changing the timeout action and clearing if needed - const accessToken = await this.tokenService.getAccessToken(); - const refreshToken = await this.tokenService.getRefreshToken(); - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); + const accessToken = await this.tokenService.getAccessToken(userId); + const refreshToken = await this.tokenService.getRefreshToken(userId); + const clientId = await this.tokenService.getClientId(userId); + const clientSecret = await this.tokenService.getClientSecret(userId); await this.setVaultTimeout(userId, timeout); if (timeout != VaultTimeoutStringType.Never && action === VaultTimeoutAction.LogOut) { // if we have a vault timeout and the action is log out, reset tokens // as the tokens were stored on disk and now should be stored in memory - await this.tokenService.clearTokens(); + await this.tokenService.clearTokens(userId); } await this.setVaultTimeoutAction(userId, action); diff --git a/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts index 58d6311c668..5998668f138 100644 --- a/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts @@ -78,7 +78,7 @@ export class SignalRConnectionService { return new Observable((subsciber) => { const connection = this.hubConnectionBuilderFactory() .withUrl(notificationsUrl + "/hub", { - accessTokenFactory: () => this.apiService.getActiveBearerToken(), + accessTokenFactory: () => this.apiService.getActiveBearerToken(userId), skipNegotiation: true, transport: HttpTransportType.WebSockets, }) diff --git a/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts b/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts index 891dab2c069..861835c086d 100644 --- a/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts +++ b/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts @@ -1,3 +1,5 @@ +import { UserId } from "@bitwarden/user-core"; + import { ApiService } from "../../../abstractions/api.service"; import { AppIdService } from "../../abstractions/app-id.service"; @@ -12,13 +14,13 @@ export class WebPushNotificationsApiService { /** * Posts a device-user association to the server and ensures it's installed for push server notifications */ - async putSubscription(pushSubscription: PushSubscriptionJSON): Promise { + async putSubscription(pushSubscription: PushSubscriptionJSON, userId: UserId): Promise { const request = WebPushRequest.from(pushSubscription); await this.apiService.send( "POST", `/devices/identifier/${await this.appIdService.getAppId()}/web-push-auth`, request, - true, + userId, false, ); } diff --git a/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts index d8a2c33568e..8b38ebd5b17 100644 --- a/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts @@ -143,7 +143,7 @@ class MyWebPushConnector implements WebPushConnector { await subscriptionUsersState.update(() => subscriptionUsers); // Inform the server about the new subscription-user association - await this.webPushApiService.putSubscription(subscription.toJSON()); + await this.webPushApiService.putSubscription(subscription.toJSON(), this.userId); }), switchMap(() => this.pushEvent$), map((e) => { diff --git a/libs/common/src/platform/services/config/config-api.service.ts b/libs/common/src/platform/services/config/config-api.service.ts index b7ecb9c8712..752a0075346 100644 --- a/libs/common/src/platform/services/config/config-api.service.ts +++ b/libs/common/src/platform/services/config/config-api.service.ts @@ -1,22 +1,21 @@ import { ApiService } from "../../../abstractions/api.service"; -import { TokenService } from "../../../auth/abstractions/token.service"; import { UserId } from "../../../types/guid"; import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; import { ServerConfigResponse } from "../../models/response/server-config.response"; export class ConfigApiService implements ConfigApiServiceAbstraction { - constructor( - private apiService: ApiService, - private tokenService: TokenService, - ) {} + constructor(private apiService: ApiService) {} async get(userId: UserId | null): Promise { // Authentication adds extra context to config responses, if the user has an access token, we want to use it // We don't particularly care about ensuring the token is valid and not expired, just that it exists - const authed: boolean = - userId == null ? false : (await this.tokenService.getAccessToken(userId)) != null; + let r: any; + if (userId == null) { + r = await this.apiService.send("GET", "/config", null, false, true); + } else { + r = await this.apiService.send("GET", "/config", null, userId, true); + } - const r = await this.apiService.send("GET", "/config", null, authed, true); return new ServerConfigResponse(r); } } diff --git a/libs/common/src/services/api.service.spec.ts b/libs/common/src/services/api.service.spec.ts index fffe0478254..144b0cc02c4 100644 --- a/libs/common/src/services/api.service.spec.ts +++ b/libs/common/src/services/api.service.spec.ts @@ -1,13 +1,19 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; +import { ObservedValueOf, of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; +import { UserId } from "@bitwarden/user-core"; +import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; import { DeviceType } from "../enums"; -import { VaultTimeoutSettingsService } from "../key-management/vault-timeout"; +import { + VaultTimeoutAction, + VaultTimeoutSettingsService, + VaultTimeoutStringType, +} from "../key-management/vault-timeout"; import { ErrorResponse } from "../models/response/error.response"; import { AppIdService } from "../platform/abstractions/app-id.service"; import { Environment, EnvironmentService } from "../platform/abstractions/environment.service"; @@ -25,10 +31,14 @@ describe("ApiService", () => { let logService: MockProxy; let logoutCallback: jest.Mock, [reason: LogoutReason]>; let vaultTimeoutSettingsService: MockProxy; + let accountService: MockProxy; let httpOperations: MockProxy; let sut: ApiService; + const testActiveUser = "activeUser" as UserId; + const testInactiveUser = "inactiveUser" as UserId; + beforeEach(() => { tokenService = mock(); platformUtilsService = mock(); @@ -40,6 +50,15 @@ describe("ApiService", () => { logService = mock(); logoutCallback = jest.fn(); vaultTimeoutSettingsService = mock(); + accountService = mock(); + + accountService.activeAccount$ = of({ + id: testActiveUser, + email: "user1@example.com", + emailVerified: true, + name: "Test Name", + } satisfies ObservedValueOf); + httpOperations = mock(); sut = new ApiService( @@ -51,6 +70,7 @@ describe("ApiService", () => { logService, logoutCallback, vaultTimeoutSettingsService, + accountService, httpOperations, "custom-user-agent", ); @@ -62,6 +82,12 @@ describe("ApiService", () => { getApiUrl: () => "https://example.com", } satisfies Partial as Environment); + environmentService.getEnvironment$.mockReturnValue( + of({ + getApiUrl: () => "https://authed.example.com", + } satisfies Partial as Environment), + ); + httpOperations.createRequest.mockImplementation((url, request) => { return { url: url, @@ -96,6 +122,7 @@ describe("ApiService", () => { expect(nativeFetch).toHaveBeenCalledTimes(1); const request = nativeFetch.mock.calls[0][0]; + expect(request.url).toBe("https://authed.example.com/something"); // This should get set for users of send expect(request.cache).toBe("no-store"); // TODO: Could expect on the credentials parameter @@ -109,6 +136,185 @@ describe("ApiService", () => { // The response body expect(response).toEqual({ hello: "world" }); }); + + it("authenticates with non-active user when user is passed in", async () => { + environmentService.environment$ = of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment); + + environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValueOnce( + of({ + getApiUrl: () => "https://inactive.example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + tokenService.getAccessToken + .calledWith(testInactiveUser) + .mockResolvedValue("inactive_access_token"); + + tokenService.tokenNeedsRefresh.calledWith(testInactiveUser).mockResolvedValue(false); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ hello: "world" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + const response = await sut.send( + "GET", + "/something", + null, + testInactiveUser, + true, + null, + null, + ); + + expect(nativeFetch).toHaveBeenCalledTimes(1); + const request = nativeFetch.mock.calls[0][0]; + expect(request.url).toBe("https://inactive.example.com/something"); + // This should get set for users of send + expect(request.cache).toBe("no-store"); + // TODO: Could expect on the credentials parameter + expect(request.headers.get("Device-Type")).toBe("2"); // Chrome Extension + // Custom user agent should get set + expect(request.headers.get("User-Agent")).toBe("custom-user-agent"); + // This should be set when the caller has indicated there is a response + expect(request.headers.get("Accept")).toBe("application/json"); + // If they have indicated that it's authed, then the authorization header should get set. + expect(request.headers.get("Authorization")).toBe("Bearer inactive_access_token"); + // The response body + expect(response).toEqual({ hello: "world" }); + }); + + const cases: { + name: string; + authedOrUserId: boolean | UserId; + expectedEffectiveUser: UserId; + }[] = [ + { + name: "refreshes active user when true passed in for auth", + authedOrUserId: true, + expectedEffectiveUser: testActiveUser, + }, + { + name: "refreshes acess token when the user passed in happens to be the active one", + authedOrUserId: testActiveUser, + expectedEffectiveUser: testActiveUser, + }, + { + name: "refreshes access token when the user passed in happens to be inactive", + authedOrUserId: testInactiveUser, + expectedEffectiveUser: testInactiveUser, + }, + ]; + + it.each(cases)("$name does", async ({ authedOrUserId, expectedEffectiveUser }) => { + environmentService.getEnvironment$.calledWith(expectedEffectiveUser).mockReturnValue( + of({ + getApiUrl: () => `https://${expectedEffectiveUser}.example.com`, + getIdentityUrl: () => `https://${expectedEffectiveUser}.identity.example.com`, + } satisfies Partial as Environment), + ); + + tokenService.getAccessToken + .calledWith(expectedEffectiveUser) + .mockResolvedValue(`${expectedEffectiveUser}_access_token`); + + tokenService.tokenNeedsRefresh.calledWith(expectedEffectiveUser).mockResolvedValue(true); + + tokenService.getRefreshToken + .calledWith(expectedEffectiveUser) + .mockResolvedValue(`${expectedEffectiveUser}_refresh_token`); + + tokenService.decodeAccessToken + .calledWith(expectedEffectiveUser) + .mockResolvedValue({ client_id: "web" }); + + tokenService.decodeAccessToken + .calledWith(`${expectedEffectiveUser}_new_access_token`) + .mockResolvedValue({ sub: expectedEffectiveUser }); + + vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$ + .calledWith(expectedEffectiveUser) + .mockReturnValue(of(VaultTimeoutAction.Lock)); + + vaultTimeoutSettingsService.getVaultTimeoutByUserId$ + .calledWith(expectedEffectiveUser) + .mockReturnValue(of(VaultTimeoutStringType.Never)); + + tokenService.setTokens + .calledWith( + `${expectedEffectiveUser}_new_access_token`, + VaultTimeoutAction.Lock, + VaultTimeoutStringType.Never, + `${expectedEffectiveUser}_new_refresh_token`, + ) + .mockResolvedValue({ accessToken: `${expectedEffectiveUser}_refreshed_access_token` }); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + if (request.url.includes("identity")) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + access_token: `${expectedEffectiveUser}_new_access_token`, + refresh_token: `${expectedEffectiveUser}_new_refresh_token`, + }), + } satisfies Partial as Response); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ hello: "world" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + await sut.send("GET", "/something", null, authedOrUserId, true, null, null); + + expect(nativeFetch).toHaveBeenCalledTimes(2); + }); }); const errorData: { @@ -169,9 +375,11 @@ describe("ApiService", () => { it.each(errorData)( "throws error-like response when not ok response with $name", async ({ input, error }) => { - environmentService.environment$ = of({ - getApiUrl: () => "https://example.com", - } satisfies Partial as Environment); + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment), + ); httpOperations.createRequest.mockImplementation((url, request) => { return { diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 6a670368b1f..bbf990122df 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -47,6 +47,7 @@ import { ProviderUserUserDetailsResponse, } from "../admin-console/models/response/provider/provider-user.response"; import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; +import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; @@ -121,7 +122,7 @@ import { ListResponse } from "../models/response/list.response"; import { ProfileResponse } from "../models/response/profile.response"; import { UserKeyResponse } from "../models/response/user-key.response"; import { AppIdService } from "../platform/abstractions/app-id.service"; -import { EnvironmentService } from "../platform/abstractions/environment.service"; +import { Environment, EnvironmentService } from "../platform/abstractions/environment.service"; import { LogService } from "../platform/abstractions/log.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; import { flagEnabled } from "../platform/misc/flags"; @@ -155,7 +156,7 @@ export type HttpOperations = { export class ApiService implements ApiServiceAbstraction { private device: DeviceType; private deviceType: string; - private refreshTokenPromise: Promise | undefined; + private refreshTokenPromise: Record> = {}; /** * The message (responseJson.ErrorModel.Message) that comes back from the server when a new device verification is required. @@ -172,6 +173,7 @@ export class ApiService implements ApiServiceAbstraction { private logService: LogService, private logoutCallback: (logoutReason: LogoutReason) => Promise, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private readonly accountService: AccountService, private readonly httpOperations: HttpOperations, private customUserAgent: string = null, ) { @@ -209,7 +211,7 @@ export class ApiService implements ApiServiceAbstraction { const response = await this.fetch( this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", { body: this.qsStringify(identityToken), - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), cache: "no-store", headers: headers, method: "POST", @@ -241,9 +243,13 @@ export class ApiService implements ApiServiceAbstraction { return Promise.reject(new ErrorResponse(responseJson, response.status, true)); } - async refreshIdentityToken(): Promise { + async refreshIdentityToken(userId: UserId | null = null): Promise { + const normalizedUser = (userId ??= await this.getActiveUser()); + if (normalizedUser == null) { + throw new Error("No user provided and no active user, cannot refresh the identity token."); + } try { - await this.refreshToken(); + await this.refreshToken(normalizedUser); } catch (e) { this.logService.error("Error refreshing access token: ", e); throw e; @@ -1398,11 +1404,16 @@ export class ApiService implements ApiServiceAbstraction { if (this.customUserAgent != null) { headers.set("User-Agent", this.customUserAgent); } - const env = await firstValueFrom(this.environmentService.environment$); + + const env = await firstValueFrom( + userId == null + ? this.environmentService.environment$ + : this.environmentService.getEnvironment$(userId), + ); const response = await this.fetch( this.httpOperations.createRequest(env.getEventsUrl() + "/collect", { cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), method: "POST", body: JSON.stringify(request), headers: headers, @@ -1444,7 +1455,11 @@ export class ApiService implements ApiServiceAbstraction { async getMasterKeyFromKeyConnector( keyConnectorUrl: string, ): Promise { - const authHeader = await this.getActiveBearerToken(); + const activeUser = await this.getActiveUser(); + if (activeUser == null) { + throw new Error("No active user, cannot get master key from key connector."); + } + const authHeader = await this.getActiveBearerToken(activeUser); const response = await this.fetch( this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", { @@ -1469,7 +1484,11 @@ export class ApiService implements ApiServiceAbstraction { keyConnectorUrl: string, request: KeyConnectorUserKeyRequest, ): Promise { - const authHeader = await this.getActiveBearerToken(); + const activeUser = await this.getActiveUser(); + if (activeUser == null) { + throw new Error("No active user, cannot post key to key connector."); + } + const authHeader = await this.getActiveBearerToken(activeUser); const response = await this.fetch( this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", { @@ -1521,10 +1540,10 @@ export class ApiService implements ApiServiceAbstraction { // Helpers - async getActiveBearerToken(): Promise { - let accessToken = await this.tokenService.getAccessToken(); - if (await this.tokenService.tokenNeedsRefresh()) { - accessToken = await this.refreshToken(); + async getActiveBearerToken(userId: UserId): Promise { + let accessToken = await this.tokenService.getAccessToken(userId); + if (await this.tokenService.tokenNeedsRefresh(userId)) { + accessToken = await this.refreshToken(userId); } return accessToken; } @@ -1563,7 +1582,7 @@ export class ApiService implements ApiServiceAbstraction { const response = await this.fetch( this.httpOperations.createRequest(env.getIdentityUrl() + path, { cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), headers: headers, method: "GET", }), @@ -1646,26 +1665,27 @@ export class ApiService implements ApiServiceAbstraction { } // Keep the running refreshTokenPromise to prevent parallel calls. - protected refreshToken(): Promise { - if (this.refreshTokenPromise === undefined) { - this.refreshTokenPromise = this.internalRefreshToken(); - void this.refreshTokenPromise.finally(() => { - this.refreshTokenPromise = undefined; + protected refreshToken(userId: UserId): Promise { + if (this.refreshTokenPromise[userId] === undefined) { + // TODO: Have different promise for each user + this.refreshTokenPromise[userId] = this.internalRefreshToken(userId); + void this.refreshTokenPromise[userId].finally(() => { + delete this.refreshTokenPromise[userId]; }); } - return this.refreshTokenPromise; + return this.refreshTokenPromise[userId]; } - private async internalRefreshToken(): Promise { - const refreshToken = await this.tokenService.getRefreshToken(); + private async internalRefreshToken(userId: UserId): Promise { + const refreshToken = await this.tokenService.getRefreshToken(userId); if (refreshToken != null && refreshToken !== "") { - return this.refreshAccessToken(); + return await this.refreshAccessToken(userId); } - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); + const clientId = await this.tokenService.getClientId(userId); + const clientSecret = await this.tokenService.getClientSecret(userId); if (!Utils.isNullOrWhitespace(clientId) && !Utils.isNullOrWhitespace(clientSecret)) { - return this.refreshApiToken(); + return await this.refreshApiToken(userId); } this.refreshAccessTokenErrorCallback(); @@ -1673,8 +1693,8 @@ export class ApiService implements ApiServiceAbstraction { throw new Error("Cannot refresh access token, no refresh token or api keys are stored."); } - protected async refreshAccessToken(): Promise { - const refreshToken = await this.tokenService.getRefreshToken(); + private async refreshAccessToken(userId: UserId): Promise { + const refreshToken = await this.tokenService.getRefreshToken(userId); if (refreshToken == null || refreshToken === "") { throw new Error(); } @@ -1687,8 +1707,8 @@ export class ApiService implements ApiServiceAbstraction { headers.set("User-Agent", this.customUserAgent); } - const env = await firstValueFrom(this.environmentService.environment$); - const decodedToken = await this.tokenService.decodeAccessToken(); + const env = await firstValueFrom(this.environmentService.getEnvironment$(userId)); + const decodedToken = await this.tokenService.decodeAccessToken(userId); const response = await this.fetch( this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", { body: this.qsStringify({ @@ -1697,7 +1717,7 @@ export class ApiService implements ApiServiceAbstraction { refresh_token: refreshToken, }), cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), headers: headers, method: "POST", }), @@ -1732,9 +1752,9 @@ export class ApiService implements ApiServiceAbstraction { } } - protected async refreshApiToken(): Promise { - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); + protected async refreshApiToken(userId: UserId): Promise { + const clientId = await this.tokenService.getClientId(userId); + const clientSecret = await this.tokenService.getClientSecret(userId); const appId = await this.appIdService.getAppId(); const deviceRequest = new DeviceRequest(appId, this.platformUtilsService); @@ -1751,7 +1771,12 @@ export class ApiService implements ApiServiceAbstraction { } const newDecodedAccessToken = await this.tokenService.decodeAccessToken(response.accessToken); - const userId = newDecodedAccessToken.sub; + + if (newDecodedAccessToken.sub !== userId) { + throw new Error( + `Token was supposed to be refreshed for ${userId} but the token we got back was for ${newDecodedAccessToken.sub}`, + ); + } const vaultTimeoutAction = await firstValueFrom( this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId), @@ -1772,12 +1797,28 @@ export class ApiService implements ApiServiceAbstraction { method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body: any, - authed: boolean, + authedOrUserId: UserId | boolean, hasResponse: boolean, apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, ): Promise { - const env = await firstValueFrom(this.environmentService.environment$); + if (authedOrUserId == null) { + throw new Error("A user id was given but it was null, cannot complete API request."); + } + + let userId: UserId | null = null; + if (typeof authedOrUserId === "boolean" && authedOrUserId) { + // Backwards compatible for authenticating the active user when `true` is passed in + userId = await this.getActiveUser(); + } else if (typeof authedOrUserId === "string") { + userId = authedOrUserId; + } + + const env = await firstValueFrom( + userId == null + ? this.environmentService.environment$ + : this.environmentService.getEnvironment$(userId), + ); apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl; // Prevent directory traversal from malicious paths @@ -1786,7 +1827,7 @@ export class ApiService implements ApiServiceAbstraction { apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : ""); const [requestHeaders, requestBody] = await this.buildHeadersAndBody( - authed, + userId, hasResponse, body, alterHeaders, @@ -1794,7 +1835,7 @@ export class ApiService implements ApiServiceAbstraction { const requestInit: RequestInit = { cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), method: method, }; requestInit.headers = requestHeaders; @@ -1810,13 +1851,13 @@ export class ApiService implements ApiServiceAbstraction { } else if (hasResponse && response.status === 200 && responseIsCsv) { return await response.text(); } else if (response.status !== 200 && response.status !== 204) { - const error = await this.handleError(response, false, authed); + const error = await this.handleError(response, false, userId != null); return Promise.reject(error); } } private async buildHeadersAndBody( - authed: boolean, + userToAuthenticate: UserId | null, hasResponse: boolean, body: any, alterHeaders: (headers: Headers) => void, @@ -1838,8 +1879,8 @@ export class ApiService implements ApiServiceAbstraction { if (alterHeaders != null) { alterHeaders(headers); } - if (authed) { - const authHeader = await this.getActiveBearerToken(); + if (userToAuthenticate != null) { + const authHeader = await this.getActiveBearerToken(userToAuthenticate); headers.set("Authorization", "Bearer " + authHeader); } else { // For unauthenticated requests, we need to tell the server what the device is for flag targeting, @@ -1901,8 +1942,11 @@ export class ApiService implements ApiServiceAbstraction { .join("&"); } - private async getCredentials(): Promise { - const env = await firstValueFrom(this.environmentService.environment$); + private async getActiveUser(): Promise { + return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); + } + + private async getCredentials(env: Environment): Promise { if (this.platformUtilsService.getClientType() !== ClientType.Web || env.hasBaseUrl()) { return "include"; } diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 2f4fcf0ef51..7eb2d4b0656 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -65,12 +65,16 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract filterCiphersForUrl( ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch?: UriMatchStrategySetting, + /** When true, will override the match strategy for the cipher if it is Never. */ + overrideNeverMatchStrategy?: true, ): Promise; abstract getAllFromApiForOrganization(organizationId: string): Promise; /** diff --git a/libs/common/src/vault/models/view/login-uri-view.spec.ts b/libs/common/src/vault/models/view/login-uri-view.spec.ts index 155d3d59f7c..aae9438df2e 100644 --- a/libs/common/src/vault/models/view/login-uri-view.spec.ts +++ b/libs/common/src/vault/models/view/login-uri-view.spec.ts @@ -111,6 +111,33 @@ describe("LoginUriView", () => { expect(actual).toBe(false); }); + + it("overrides Never match strategy with Domain when parameter is set", () => { + const loginUri = new LoginUriView(); + loginUri.uri = "https://example.org"; + loginUri.match = UriMatchStrategy.Never; + + expect(loginUri.matchesUri("https://example.org", new Set(), undefined, true)).toBe(true); + expect(loginUri.matchesUri("https://example.org", new Set(), undefined)).toBe(false); + }); + + it("overrides Never match strategy when passed in as default strategy", () => { + const loginUriNoMatch = new LoginUriView(); + loginUriNoMatch.uri = "https://example.org"; + + expect( + loginUriNoMatch.matchesUri( + "https://example.org", + new Set(), + UriMatchStrategy.Never, + true, + ), + ).toBe(true); + + expect( + loginUriNoMatch.matchesUri("https://example.org", new Set(), UriMatchStrategy.Never), + ).toBe(false); + }); }); describe("using host matching", () => { diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 38cd517e542..49ac9c6278f 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -142,6 +142,8 @@ export class LoginUriView implements View { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = null, + /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ + overrideNeverMatchStrategy?: true, ): boolean { if (!this.uri || !targetUri) { return false; @@ -150,6 +152,12 @@ export class LoginUriView implements View { let matchType = this.match ?? defaultUriMatch; matchType ??= UriMatchStrategy.Domain; + // Override the match strategy with `Domain` when it is `Never` and `overrideNeverMatchStrategy` is true. + // This is useful in scenarios when the cipher should be matched to rely other information other than autofill. + if (overrideNeverMatchStrategy && matchType === UriMatchStrategy.Never) { + matchType = UriMatchStrategy.Domain; + } + const targetDomain = Utils.getDomain(targetUri); const matchDomains = equivalentDomains.add(targetDomain); diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index d268cf4afaa..44c6ee8f2e9 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -82,12 +82,16 @@ export class LoginView extends ItemView { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = null, + /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ + overrideNeverMatchStrategy?: true, ): boolean { if (this.uris == null) { return false; } - return this.uris.some((uri) => uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch)); + return this.uris.some((uri) => + uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy), + ); } static fromJSON(obj: Partial>): LoginView { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index d89a41aba1f..f6e12e71edd 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -601,6 +601,7 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, + overrideNeverMatchStrategy?: true, ): Promise { return await firstValueFrom( this.cipherViews$(userId).pipe( @@ -612,6 +613,7 @@ export class CipherService implements CipherServiceAbstraction { url, includeOtherTypes, defaultMatch, + overrideNeverMatchStrategy, ), ), ), @@ -623,6 +625,7 @@ export class CipherService implements CipherServiceAbstraction { url: string, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, + overrideNeverMatchStrategy?: true, ): Promise { if (url == null && includeOtherTypes == null) { return []; @@ -647,7 +650,13 @@ export class CipherService implements CipherServiceAbstraction { } if (cipherIsLogin) { - return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch); + return CipherViewLikeUtils.matchesUri( + cipher, + url, + equivalentDomains, + defaultMatch, + overrideNeverMatchStrategy, + ); } return false; diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts index 1c7a4382a04..5ef1d9bdc75 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -174,13 +174,19 @@ export class CipherViewLikeUtils { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain, + overrideNeverMatchStrategy?: true, ): boolean => { if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) { return false; } if (!this.isCipherListView(cipher)) { - return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch); + return cipher.login.matchesUri( + targetUri, + equivalentDomains, + defaultUriMatch, + overrideNeverMatchStrategy, + ); } const login = this.getLogin(cipher); @@ -198,7 +204,7 @@ export class CipherViewLikeUtils { }); return loginUriViews.some((uriView) => - uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch), + uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy), ); }; diff --git a/libs/vault/src/components/carousel/carousel.component.html b/libs/vault/src/components/carousel/carousel.component.html index 04c6e559056..778b70e15e2 100644 --- a/libs/vault/src/components/carousel/carousel.component.html +++ b/libs/vault/src/components/carousel/carousel.component.html @@ -6,18 +6,40 @@ #container > -
- +
+ +
+ +
+
diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts index 1409aea0cb2..ebb38576813 100644 --- a/libs/vault/src/components/carousel/carousel.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; import { VaultCarouselComponent } from "./carousel.component"; @@ -33,6 +35,7 @@ describe("VaultCarouselComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], }).compileComponents(); }); @@ -48,7 +51,7 @@ describe("VaultCarouselComponent", () => { it("shows the active slides content", () => { // Set the second slide as active - fixture.debugElement.queryAll(By.css("button"))[1].nativeElement.click(); + fixture.debugElement.queryAll(By.css("button"))[2].nativeElement.click(); fixture.detectChanges(); const heading = fixture.debugElement.query(By.css("h1")).nativeElement; @@ -63,10 +66,37 @@ describe("VaultCarouselComponent", () => { it('emits "slideChange" event when slide changes', () => { jest.spyOn(component.slideChange, "emit"); - const thirdSlideButton = fixture.debugElement.queryAll(By.css("button"))[2]; + const thirdSlideButton = fixture.debugElement.queryAll(By.css("button"))[3]; thirdSlideButton.nativeElement.click(); expect(component.slideChange.emit).toHaveBeenCalledWith(2); }); + + it('advances to the next slide when the "next" button is pressed', () => { + const middleSlideButton = fixture.debugElement.queryAll(By.css("button"))[2]; + const nextButton = fixture.debugElement.queryAll(By.css("button"))[4]; + + middleSlideButton.nativeElement.click(); + + jest.spyOn(component.slideChange, "emit"); + + nextButton.nativeElement.click(); + + expect(component.slideChange.emit).toHaveBeenCalledWith(2); + }); + + it('advances to the previous slide when the "back" button is pressed', async () => { + const middleSlideButton = fixture.debugElement.queryAll(By.css("button"))[2]; + const backButton = fixture.debugElement.queryAll(By.css("button"))[0]; + + middleSlideButton.nativeElement.click(); + await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update. + + jest.spyOn(component.slideChange, "emit"); + + backButton.nativeElement.click(); + + expect(component.slideChange.emit).toHaveBeenCalledWith(0); + }); }); diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index f2d211697df..fdebbebc33b 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -20,7 +20,9 @@ import { import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { take } from "rxjs"; -import { ButtonModule } from "@bitwarden/components"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, IconButtonModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component"; import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component"; @@ -32,9 +34,12 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com imports: [ CdkPortalOutlet, CommonModule, + JslibModule, + IconButtonModule, ButtonModule, VaultCarouselContentComponent, VaultCarouselButtonComponent, + I18nPipe, ], }) export class VaultCarouselComponent implements AfterViewInit { @@ -97,6 +102,18 @@ export class VaultCarouselComponent implements AfterViewInit { this.slideChange.emit(index); } + protected nextSlide() { + if (this.selectedIndex < this.slides.length - 1) { + this.selectSlide(this.selectedIndex + 1); + } + } + + protected prevSlide() { + if (this.selectedIndex > 0) { + this.selectSlide(this.selectedIndex - 1); + } + } + async ngAfterViewInit() { this.keyManager = new FocusKeyManager(this.carouselButtons) .withHorizontalOrientation("ltr") diff --git a/libs/vault/src/components/carousel/carousel.stories.ts b/libs/vault/src/components/carousel/carousel.stories.ts index 521a561a19f..1e393779a6a 100644 --- a/libs/vault/src/components/carousel/carousel.stories.ts +++ b/libs/vault/src/components/carousel/carousel.stories.ts @@ -1,5 +1,6 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonComponent, TypographyModule } from "@bitwarden/components"; import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; @@ -11,6 +12,7 @@ export default { decorators: [ moduleMetadata({ imports: [VaultCarouselSlideComponent, TypographyModule, ButtonComponent], + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], }), ], } as Meta;