diff --git a/.github/renovate.json b/.github/renovate.json index 1fd0e87685..bd9ea0da5c 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -37,7 +37,6 @@ "base64-loader", "buffer", "bufferutil", - "clean-webpack-plugin", "copy-webpack-plugin", "core-js", "css-loader", diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index 940a130c29..d0d028e570 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -6,37 +6,11 @@ on: tags: - desktop-v** -defaults: - run: - shell: bash - jobs: bump-version: name: Bump Desktop Version runs-on: ubuntu-22.04 steps: - - name: Checkout Branch - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - - name: Calculate bumped version - id: version - env: - RELEASE_TAG: ${{ github.ref }} - run: | - CURR_MAJOR=$(echo $RELEASE_TAG | sed -r 's/refs\/tags\/[a-z]*-v([0-9]{4}\.[0-9]{1,2})\.([0-9]{1,2})/\1/') - CURR_PATCH=$(echo $RELEASE_TAG | sed -r 's/refs\/tags\/[a-z]*-v([0-9]{4}\.[0-9]{1,2})\.([0-9]{1,2})/\2/') - echo "Current Major: $CURR_MAJOR" - echo "Current Patch: $CURR_PATCH" - - NEW_PATCH=$((CURR_PATCH+1)) - - echo "New patch: $NEW_PATCH" - - NEW_VER=$CURR_MAJOR.$NEW_PATCH - echo "New Version: $NEW_VER" - echo "New Version: $NEW_VER" >> $GITHUB_STEP_SUMMARY - echo "new_version=$NEW_VER" >> $GITHUB_OUTPUT - - name: Login to Azure - CI Subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 with: @@ -49,10 +23,13 @@ jobs: keyvault: bitwarden-ci secrets: "github-pat-bitwarden-devops-bot-repo-scope" - - name: "Bump version to ${{ steps.version.outputs.new_version }}" + - name: Trigger Version Bump workflow env: GH_TOKEN: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} run: | - echo '{"cut_rc_branch": "false", "version_number": "${{ steps.version.outputs.new_version }}", - "bump_browser": "false", "bump_cli": "false", "bump_desktop": "true", "bump_web": "false"}' | \ + echo '{"cut_rc_branch": "false", \ + "bump_browser": "false", \ + "bump_cli": "false", \ + "bump_desktop": "true", \ + "bump_web": "false"}' | \ gh workflow run version-bump.yml --json --repo bitwarden/clients diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 7329667452..5aec22926d 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -1,6 +1,5 @@ --- name: Version Bump -run-name: Version Bump - v${{ inputs.version_number }} on: workflow_dispatch: @@ -21,9 +20,10 @@ on: description: "Bump Web?" type: boolean default: false - version_number: - description: "New version (example: '2024.1.0')" - required: true + version_number_override: + description: "New version override (leave blank for automatic calculation, example: '2024.1.0')" + required: false + type: string cut_rc_branch: description: "Cut RC branch?" default: true @@ -31,22 +31,19 @@ on: jobs: bump_version: - name: "Bump Version to v${{ inputs.version_number }}" + name: Bump Version runs-on: ubuntu-22.04 + outputs: + version_browser: ${{ steps.set-final-version-output.outputs.version_browser }} + version_cli: ${{ steps.set-final-version-output.outputs.version_cli }} + version_desktop: ${{ steps.set-final-version-output.outputs.version_desktop }} + version_web: ${{ steps.set-final-version-output.outputs.version_web }} steps: - - name: Login to Azure - Prod Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Validate version input + if: ${{ inputs.version_number_override != '' }} + uses: bitwarden/gh-actions/version-check@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Retrieve secrets - id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "github-gpg-private-key, - github-gpg-private-key-passphrase, - github-pat-bitwarden-devops-bot-repo-scope" + version: ${{ inputs.version_number_override }} - name: Checkout Branch uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 @@ -63,6 +60,20 @@ jobs: exit 1 fi + - name: Login to Azure - CI Subscription + uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + with: + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Retrieve secrets + id: retrieve-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: "bitwarden-ci" + secrets: "github-gpg-private-key, + github-gpg-private-key-passphrase, + github-pat-bitwarden-devops-bot-repo-scope" + - name: Import GPG key uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0 with: @@ -71,6 +82,11 @@ jobs: git_user_signingkey: true git_commit_gpgsign: true + - name: Setup git + run: | + git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" + git config --local user.name "bitwarden-devops-bot" + - name: Create Version Branch id: create-branch run: | @@ -90,7 +106,7 @@ jobs: printf -v joined '%s,' "${CLIENTS[@]}" echo "client=${joined%,}" >> $GITHUB_OUTPUT - NAME=version_bump_${{ github.ref_name }}_${{ inputs.version_number }} + NAME=version_bump_${{ github.ref_name }}_$(date +"%Y-%m-%d") git switch -c $NAME echo "name=$NAME" >> $GITHUB_OUTPUT @@ -99,13 +115,20 @@ jobs: ######################## ### Browser - - name: Browser - Verify input version + - name: Get current Browser version if: ${{ inputs.bump_browser == true }} - env: - NEW_VERSION: ${{ inputs.version_number }} + id: current-browser-version run: | CURRENT_VERSION=$(cat package.json | jq -r '.version') + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + working-directory: apps/browser + - name: Browser - Verify input version + if: ${{ inputs.bump_browser == true && inputs.version_number_override != '' }} + env: + CURRENT_VERSION: ${{ steps.current-browser-version.outputs.version }} + NEW_VERSION: ${{ inputs.version_number_override }} + run: | # Error if version has not changed. if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then echo "Version has not changed." @@ -122,23 +145,52 @@ jobs: fi working-directory: apps/browser - - name: Bump Browser Version - if: ${{ inputs.bump_browser == true }} - run: npm version --workspace=@bitwarden/browser ${{ inputs.version_number }} + - name: Calculate next Browser release version + if: ${{ inputs.bump_browser == true && inputs.version_number_override == '' }} + id: calculate-next-browser-version + uses: bitwarden/gh-actions/version-next@main + with: + version: ${{ steps.current-browser-version.outputs.version }} - - name: Bump Browser Version - Manifest - if: ${{ inputs.bump_browser == true }} + - name: Bump Browser Version - Version Override + if: ${{ inputs.bump_browser == true && inputs.version_number_override != '' }} + id: bump-browser-version-override + run: npm version --workspace=@bitwarden/browser ${{ inputs.version_number_override }} + + - name: Bump Browser Version - Automatic Calculation + if: ${{ inputs.bump_browser == true && inputs.version_number_override == '' }} + id: bump-browser-version-automatic + env: + VERSION: ${{ steps.calculate-next-browser-version.outputs.version }} + run: npm version --workspace=@bitwarden/browser $VERSION + + - name: Bump Browser Version - Manifest - Version Override + if: ${{ inputs.bump_browser == true && inputs.version_number_override != '' }} uses: bitwarden/gh-actions/version-bump@main with: - version: ${{ inputs.version_number }} file_path: "apps/browser/src/manifest.json" + version: ${{ inputs.version_number_override }} - - name: Bump Browser Version - Manifest v3 - if: ${{ inputs.bump_browser == true }} + - name: Bump Browser Version - Manifest - Automatic Calculation + if: ${{ inputs.bump_browser == true && inputs.version_number_override == '' }} + uses: bitwarden/gh-actions/version-bump@main + with: + file_path: "apps/browser/src/manifest.json" + version: ${{ steps.calculate-next-browser-version.outputs.version }} + + - name: Bump Browser Version - Manifest v3 - Version Override + if: ${{ inputs.bump_browser == true && inputs.version_number_override != '' }} uses: bitwarden/gh-actions/version-bump@main with: - version: ${{ inputs.version_number }} file_path: "apps/browser/src/manifest.v3.json" + version: ${{ inputs.version_number_override }} + + - name: Bump Browser Version - Manifest v3 - Automatic Calculation + if: ${{ inputs.bump_browser == true && inputs.version_number_override == '' }} + uses: bitwarden/gh-actions/version-bump@main + with: + file_path: "apps/browser/src/manifest.v3.json" + version: ${{ steps.calculate-next-browser-version.outputs.version }} - name: Run Prettier after Browser Version Bump if: ${{ inputs.bump_browser == true }} @@ -148,13 +200,20 @@ jobs: prettier --write apps/browser/src/manifest.v3.json ### CLI - - name: CLI - Verify input version + - name: Get current CLI version if: ${{ inputs.bump_cli == true }} - env: - NEW_VERSION: ${{ inputs.version_number }} + id: current-cli-version run: | CURRENT_VERSION=$(cat package.json | jq -r '.version') + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + working-directory: apps/cli + - name: CLI - Verify input version + if: ${{ inputs.bump_cli == true && inputs.version_number_override != '' }} + env: + CURRENT_VERSION: ${{ steps.current-cli-version.outputs.version }} + NEW_VERSION: ${{ inputs.version_number_override }} + run: | # Error if version has not changed. if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then echo "Version has not changed." @@ -171,18 +230,40 @@ jobs: fi working-directory: apps/cli - - name: Bump CLI Version - if: ${{ inputs.bump_cli == true }} - run: npm version --workspace=@bitwarden/cli ${{ inputs.version_number }} + - name: Calculate next CLI release version + if: ${{ inputs.bump_cli == true && inputs.version_number_override == '' }} + id: calculate-next-cli-version + uses: bitwarden/gh-actions/version-next@main + with: + version: ${{ steps.current-cli-version.outputs.version }} + + - name: Bump CLI Version - Version Override + if: ${{ inputs.bump_cli == true && inputs.version_number_override != '' }} + id: bump-cli-version-override + run: npm version --workspace=@bitwarden/cli ${{ inputs.version_number_override }} + + - name: Bump CLI Version - Automatic Calculation + if: ${{ inputs.bump_cli == true && inputs.version_number_override == '' }} + id: bump-cli-version-automatic + env: + VERSION: ${{ steps.calculate-next-cli-version.outputs.version }} + run: npm version --workspace=@bitwarden/cli $VERSION ### Desktop - - name: Desktop - Verify input version + - name: Get current Desktop version if: ${{ inputs.bump_desktop == true }} - env: - NEW_VERSION: ${{ inputs.version_number }} + id: current-desktop-version run: | CURRENT_VERSION=$(cat package.json | jq -r '.version') + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + working-directory: apps/desktop + - name: Desktop - Verify input version + if: ${{ inputs.bump_desktop == true && inputs.version_number_override != '' }} + env: + CURRENT_VERSION: ${{ steps.current-desktop-version.outputs.version }} + NEW_VERSION: ${{ inputs.version_number_override }} + run: | # Error if version has not changed. if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then echo "Version has not changed." @@ -199,23 +280,52 @@ jobs: fi working-directory: apps/desktop - - name: Bump Desktop Version - Root - if: ${{ inputs.bump_desktop == true }} - run: npm version --workspace=@bitwarden/desktop ${{ inputs.version_number }} + - name: Calculate next Desktop release version + if: ${{ inputs.bump_desktop == true && inputs.version_number_override == '' }} + id: calculate-next-desktop-version + uses: bitwarden/gh-actions/version-next@main + with: + version: ${{ steps.current-desktop-version.outputs.version }} - - name: Bump Desktop Version - App - if: ${{ inputs.bump_desktop == true }} - run: npm version ${{ inputs.version_number }} + - name: Bump Desktop Version - Root - Version Override + if: ${{ inputs.bump_desktop == true && inputs.version_number_override != '' }} + id: bump-desktop-version-override + run: npm version --workspace=@bitwarden/desktop ${{ inputs.version_number_override }} + + - name: Bump Desktop Version - Root - Automatic Calculation + if: ${{ inputs.bump_desktop == true && inputs.version_number_override == '' }} + id: bump-desktop-version-automatic + env: + VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} + run: npm version --workspace=@bitwarden/desktop $VERSION + + - name: Bump Desktop Version - App - Version Override + if: ${{ inputs.bump_desktop == true && inputs.version_number_override != '' }} + run: npm version ${{ inputs.version_number_override }} + working-directory: "apps/desktop/src" + + - name: Bump Desktop Version - App - Automatic Calculation + if: ${{ inputs.bump_desktop == true && inputs.version_number_override == '' }} + env: + VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} + run: npm version $VERSION working-directory: "apps/desktop/src" ### Web - - name: Web - Verify input version + - name: Get current Web version if: ${{ inputs.bump_web == true }} - env: - NEW_VERSION: ${{ inputs.version_number }} + id: current-web-version run: | CURRENT_VERSION=$(cat package.json | jq -r '.version') + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + working-directory: apps/web + - name: Web - Verify input version + if: ${{ inputs.bump_web == true && inputs.version_number_override != '' }} + env: + CURRENT_VERSION: ${{ steps.current-web-version.outputs.version }} + NEW_VERSION: ${{ inputs.version_number_override }} + run: | # Error if version has not changed. if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then echo "Version has not changed." @@ -232,16 +342,47 @@ jobs: fi working-directory: apps/web - - name: Bump Web Version - if: ${{ inputs.bump_web == true }} - run: npm version --workspace=@bitwarden/web-vault ${{ inputs.version_number }} + - name: Calculate next Web release version + if: ${{ inputs.bump_web == true && inputs.version_number_override == '' }} + id: calculate-next-web-version + uses: bitwarden/gh-actions/version-next@main + with: + version: ${{ steps.current-web-version.outputs.version }} + + - name: Bump Web Version - Version Override + if: ${{ inputs.bump_web == true && inputs.version_number_override != '' }} + id: bump-web-version-override + run: npm version --workspace=@bitwarden/web-vault ${{ inputs.version_number_override }} + + - name: Bump Web Version - Automatic Calculation + if: ${{ inputs.bump_web == true && inputs.version_number_override == '' }} + id: bump-web-version-automatic + env: + VERSION: ${{ steps.calculate-next-web-version.outputs.version }} + run: npm version --workspace=@bitwarden/web-vault $VERSION ######################## - - name: Setup git + - name: Set final version output + id: set-final-version-output run: | - git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" - git config --local user.name "bitwarden-devops-bot" + if [[ "${{ steps.bump-browser-version-override.outcome }}" = "success" ]]; then + echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + elif [[ "${{ steps.bump-browser-version-automatic.outcome }}" = "success" ]]; then + echo "version=${{ steps.calculate-next-browser-version.outputs.version }}" >> $GITHUB_OUTPUT + elif [[ "${{ steps.bump-cli-version-override.outcome }}" = "success" ]]; then + echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + elif [[ "${{ steps.bump-cli-version-automatic.outcome }}" = "success" ]]; then + echo "version=${{ steps.calculate-next-cli-version.outputs.version }}" >> $GITHUB_OUTPUT + elif [[ "${{ steps.bump-desktop-version-override.outcome }}" = "success" ]]; then + echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + elif [[ "${{ steps.bump-desktop-version-automatic.outcome }}" = "success" ]]; then + echo "version=${{ steps.calculate-next-desktop-version.outputs.version }}" >> $GITHUB_OUTPUT + elif [[ "${{ steps.bump-web-version-override.outcome }}" = "success" ]]; then + echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + elif [[ "${{ steps.bump-web-version-automatic.outcome }}" = "success" ]]; then + echo "version=${{ steps.calculate-next-web-version.outputs.version }}" >> $GITHUB_OUTPUT + fi - name: Check if version changed id: version-changed @@ -257,7 +398,7 @@ jobs: if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} env: CLIENT: ${{ steps.create-branch.outputs.client }} - VERSION: ${{ inputs.version_number }} + VERSION: ${{ steps.set-final-version-output.outputs.version }} run: git commit -m "Bumped ${CLIENT} version to ${VERSION}" -a - name: Push changes @@ -272,7 +413,7 @@ jobs: env: GH_TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} PR_BRANCH: ${{ steps.create-branch.outputs.name }} - TITLE: "Bump ${{ steps.create-branch.outputs.client }} version to ${{ inputs.version_number }}" + TITLE: "Bump ${{ steps.create-branch.outputs.client }} version to ${{ steps.set-final-version-output.outputs.version }}" run: | PR_URL=$(gh pr create --title "$TITLE" \ --base "main" \ @@ -288,7 +429,7 @@ jobs: - [X] Other ## Objective - Automated ${{ steps.create-branch.outputs.client }} version bump to ${{ inputs.version_number }}") + Automated ${{ steps.create-branch.outputs.client }} version bump to ${{ steps.set-final-version-output.outputs.version }}") echo "pr_number=${PR_URL##*/}" >> $GITHUB_OUTPUT - name: Approve PR @@ -307,8 +448,8 @@ jobs: cut_rc: name: Cut RC branch - needs: bump_version if: ${{ inputs.cut_rc_branch == true }} + needs: bump_version runs-on: ubuntu-22.04 steps: - name: Checkout Branch @@ -320,7 +461,7 @@ jobs: - name: Browser - Verify version has been updated if: ${{ inputs.bump_browser == true }} env: - NEW_VERSION: ${{ inputs.version_number }} + NEW_VERSION: ${{ needs.bump_version.outputs.version_browser }} run: | # Wait for version to change. while : ; do @@ -338,7 +479,7 @@ jobs: - name: CLI - Verify version has been updated if: ${{ inputs.bump_cli == true }} env: - NEW_VERSION: ${{ inputs.version_number }} + NEW_VERSION: ${{ needs.bump_version.outputs.version_cli }} run: | # Wait for version to change. while : ; do @@ -356,7 +497,7 @@ jobs: - name: Desktop - Verify version has been updated if: ${{ inputs.bump_desktop == true }} env: - NEW_VERSION: ${{ inputs.version_number }} + NEW_VERSION: ${{ needs.bump_version.outputs.version_desktop }} run: | # Wait for version to change. while : ; do @@ -374,7 +515,7 @@ jobs: - name: Web - Verify version has been updated if: ${{ inputs.bump_web == true }} env: - NEW_VERSION: ${{ inputs.version_number }} + NEW_VERSION: ${{ needs.bump_version.outputs.version_web }} run: | # Wait for version to change. while : ; do diff --git a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts index ecd695f373..078bfb8a63 100644 --- a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts @@ -26,6 +26,10 @@ import { factory, FactoryOptions, } from "../../../platform/background/service-factories/factory-options"; +import { + globalStateProviderFactory, + GlobalStateProviderInitOptions, +} from "../../../platform/background/service-factories/global-state-provider.factory"; import { i18nServiceFactory, I18nServiceInitOptions, @@ -84,7 +88,8 @@ export type LoginStrategyServiceInitOptions = LoginStrategyServiceFactoryOptions PolicyServiceInitOptions & PasswordStrengthServiceInitOptions & DeviceTrustCryptoServiceInitOptions & - AuthRequestServiceInitOptions; + AuthRequestServiceInitOptions & + GlobalStateProviderInitOptions; export function loginStrategyServiceFactory( cache: { loginStrategyService?: LoginStrategyServiceAbstraction } & CachedServices, @@ -113,6 +118,7 @@ export function loginStrategyServiceFactory( await policyServiceFactory(cache, opts), await deviceTrustCryptoServiceFactory(cache, opts), await authRequestServiceFactory(cache, opts), + await globalStateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index 322ccd732d..ab704a1d37 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -1,3 +1,4 @@ +import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { NotificationQueueMessageTypes } from "../../enums/notification-queue-message-type.enum"; @@ -111,6 +112,7 @@ type NotificationBackgroundExtensionMessageHandlers = { collectPageDetailsResponse: ({ message }: BackgroundMessageParam) => Promise; bgGetEnableChangedPasswordPrompt: () => Promise; bgGetEnableAddedLoginPrompt: () => Promise; + bgGetExcludedDomains: () => Promise; getWebVaultUrlForNotification: () => string; }; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index da4a3d793e..7de682afac 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -4,6 +4,7 @@ import { firstValueFrom } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; @@ -47,6 +48,7 @@ describe("NotificationBackground", () => { const folderService = mock(); const stateService = mock(); const userNotificationSettingsService = mock(); + const domainSettingsService = mock(); const environmentService = mock(); const logService = mock(); @@ -59,6 +61,7 @@ describe("NotificationBackground", () => { folderService, stateService, userNotificationSettingsService, + domainSettingsService, environmentService, logService, ); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 7ec3d5ee88..eb3eb893ad 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -5,7 +5,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { NOTIFICATION_BAR_LIFESPAN_MS } from "@bitwarden/common/autofill/constants"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; +import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -60,6 +62,7 @@ export default class NotificationBackground { bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab), bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(), bgGetEnableAddedLoginPrompt: () => this.getEnableAddedLoginPrompt(), + bgGetExcludedDomains: () => this.getExcludedDomains(), getWebVaultUrlForNotification: () => this.getWebVaultUrl(), }; @@ -71,6 +74,7 @@ export default class NotificationBackground { private folderService: FolderService, private stateService: BrowserStateService, private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction, + private domainSettingsService: DomainSettingsService, private environmentService: EnvironmentService, private logService: LogService, ) {} @@ -99,6 +103,13 @@ export default class NotificationBackground { return await firstValueFrom(this.userNotificationSettingsService.enableAddedLoginPrompt$); } + /** + * Gets the neverDomains setting from the domain settings service. + */ + async getExcludedDomains(): Promise { + return await firstValueFrom(this.domainSettingsService.neverDomains$); + } + /** * Checks the notification queue for any messages that need to be sent to the * specified tab. If no tab is specified, the current tab will be used. diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index f67213f980..33a1e26ddd 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -17,8 +17,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { BrowserApi } from "../../platform/browser/browser-api"; -import BrowserPlatformUtilsService from "../../platform/services/browser-platform-utils.service"; import { BrowserStateService } from "../../platform/services/browser-state.service"; +import { BrowserPlatformUtilsService } from "../../platform/services/platform-utils/browser-platform-utils.service"; import { AutofillService } from "../services/abstractions/autofill.service"; import { createAutofillPageDetailsMock, diff --git a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts index 94e2356821..bbbca2f16a 100644 --- a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts +++ b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts @@ -6,10 +6,6 @@ import { EventCollectionServiceInitOptions, eventCollectionServiceFactory, } from "../../../background/service-factories/event-collection-service.factory"; -import { - settingsServiceFactory, - SettingsServiceInitOptions, -} from "../../../background/service-factories/settings-service.factory"; import { CachedServices, factory, @@ -38,6 +34,10 @@ import { AutofillSettingsServiceInitOptions, autofillSettingsServiceFactory, } from "./autofill-settings-service.factory"; +import { + DomainSettingsServiceInitOptions, + domainSettingsServiceFactory, +} from "./domain-settings-service.factory"; type AutoFillServiceOptions = FactoryOptions; @@ -48,8 +48,8 @@ export type AutoFillServiceInitOptions = AutoFillServiceOptions & TotpServiceInitOptions & EventCollectionServiceInitOptions & LogServiceInitOptions & - SettingsServiceInitOptions & - UserVerificationServiceInitOptions; + UserVerificationServiceInitOptions & + DomainSettingsServiceInitOptions; export function autofillServiceFactory( cache: { autofillService?: AbstractAutoFillService } & CachedServices, @@ -67,7 +67,7 @@ export function autofillServiceFactory( await totpServiceFactory(cache, opts), await eventCollectionServiceFactory(cache, opts), await logServiceFactory(cache, opts), - await settingsServiceFactory(cache, opts), + await domainSettingsServiceFactory(cache, opts), await userVerificationServiceFactory(cache, opts), ), ); diff --git a/apps/browser/src/autofill/background/service_factories/domain-settings-service.factory.ts b/apps/browser/src/autofill/background/service_factories/domain-settings-service.factory.ts new file mode 100644 index 0000000000..1b4127c4cc --- /dev/null +++ b/apps/browser/src/autofill/background/service_factories/domain-settings-service.factory.ts @@ -0,0 +1,25 @@ +import { DefaultDomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; + +import { + CachedServices, + factory, + FactoryOptions, +} from "../../../platform/background/service-factories/factory-options"; +import { + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; + +export type DomainSettingsServiceInitOptions = FactoryOptions & StateProviderInitOptions; + +export function domainSettingsServiceFactory( + cache: { domainSettingsService?: DefaultDomainSettingsService } & CachedServices, + opts: DomainSettingsServiceInitOptions, +): Promise { + return factory( + cache, + "domainSettingsService", + opts, + async () => new DefaultDomainSettingsService(await stateProviderFactory(cache, opts)), + ); +} diff --git a/apps/browser/src/autofill/background/web-request.background.ts b/apps/browser/src/autofill/background/web-request.background.ts index e8f77a6d47..f4422e6d7f 100644 --- a/apps/browser/src/autofill/background/web-request.background.ts +++ b/apps/browser/src/autofill/background/web-request.background.ts @@ -1,8 +1,8 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { UriMatchType } from "@bitwarden/common/vault/enums"; import { BrowserApi } from "../../platform/browser/browser-api"; @@ -73,7 +73,7 @@ export default class WebRequestBackground { const ciphers = await this.cipherService.getAllDecryptedForUrl( domain, null, - UriMatchType.Host, + UriMatchStrategy.Host, ); if (ciphers == null || ciphers.length !== 1) { error(); diff --git a/apps/browser/src/autofill/content/notification-bar.ts b/apps/browser/src/autofill/content/notification-bar.ts index f2a74310c2..8c1ef93c32 100644 --- a/apps/browser/src/autofill/content/notification-bar.ts +++ b/apps/browser/src/autofill/content/notification-bar.ts @@ -6,7 +6,7 @@ import AutofillField from "../models/autofill-field"; import { WatchedForm } from "../models/watched-form"; import { NotificationBarIframeInitData } from "../notification/abstractions/notification-bar"; import { FormData } from "../services/abstractions/autofill.service"; -import { GlobalSettings, UserSettings } from "../types"; +import { UserSettings } from "../types"; import { getFromLocalStorage, sendExtensionMessage, @@ -94,10 +94,11 @@ async function loadNotificationBar() { "bgGetEnableChangedPasswordPrompt", ); const enableAddedLoginPrompt = await sendExtensionMessage("bgGetEnableAddedLoginPrompt"); + const excludedDomains = await sendExtensionMessage("bgGetExcludedDomains"); + let showNotificationBar = true; // Look up the active user id from storage const activeUserIdKey = "activeUserId"; - const globalStorageKey = "global"; let activeUserId: string; const activeUserStorageValue = await getFromLocalStorage(activeUserIdKey); @@ -109,9 +110,6 @@ async function loadNotificationBar() { const userSettingsStorageValue = await getFromLocalStorage(activeUserId); if (userSettingsStorageValue[activeUserId]) { const userSettings: UserSettings = userSettingsStorageValue[activeUserId].settings; - const globalSettings: GlobalSettings = (await getFromLocalStorage(globalStorageKey))[ - globalStorageKey - ]; // Do not show the notification bar on the Bitwarden vault // because they can add logins and change passwords there @@ -122,8 +120,8 @@ async function loadNotificationBar() { // show the notification bar on (for login detail collection or password change). // It is managed in the Settings > Excluded Domains page in the browser extension. // Example: '{"bitwarden.com":null}' - const excludedDomainsDict = globalSettings.neverDomains; - if (!excludedDomainsDict || !(window.location.hostname in excludedDomainsDict)) { + + if (!excludedDomains || !(window.location.hostname in excludedDomains)) { if (enableAddedLoginPrompt || enableChangedPasswordPrompt) { // If the user has not disabled both notifications, then handle the initial page change (null -> actual page) handlePageChange(); diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index 857442d309..67cc25f227 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -1,14 +1,16 @@ import { Component, OnInit } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { UriMatchType } from "@bitwarden/common/vault/enums"; import { DialogService } from "@bitwarden/components"; import { BrowserApi } from "../../../platform/browser/browser-api"; @@ -28,16 +30,15 @@ export class AutofillComponent implements OnInit { enableAutoFillOnPageLoad = false; autoFillOnPageLoadDefault = false; autoFillOnPageLoadOptions: any[]; - defaultUriMatch = UriMatchType.Domain; + defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain; uriMatchOptions: any[]; autofillKeyboardHelperText: string; accountSwitcherEnabled = false; constructor( - private stateService: StateService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private settingsService: SettingsService, + private domainSettingsService: DomainSettingsService, private autofillService: AutofillService, private dialogService: DialogService, private autofillSettingsService: AutofillSettingsServiceAbstraction, @@ -61,12 +62,12 @@ export class AutofillComponent implements OnInit { { name: i18nService.t("autoFillOnPageLoadNo"), value: false }, ]; this.uriMatchOptions = [ - { name: i18nService.t("baseDomain"), value: UriMatchType.Domain }, - { name: i18nService.t("host"), value: UriMatchType.Host }, - { name: i18nService.t("startsWith"), value: UriMatchType.StartsWith }, - { name: i18nService.t("regEx"), value: UriMatchType.RegularExpression }, - { name: i18nService.t("exact"), value: UriMatchType.Exact }, - { name: i18nService.t("never"), value: UriMatchType.Never }, + { name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain }, + { name: i18nService.t("host"), value: UriMatchStrategy.Host }, + { name: i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith }, + { name: i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression }, + { name: i18nService.t("exact"), value: UriMatchStrategy.Exact }, + { name: i18nService.t("never"), value: UriMatchStrategy.Never }, ]; this.accountSwitcherEnabled = enableAccountSwitching(); @@ -94,8 +95,10 @@ export class AutofillComponent implements OnInit { this.autofillSettingsService.autofillOnPageLoadDefault$, ); - const defaultUriMatch = await this.stateService.getDefaultUriMatch(); - this.defaultUriMatch = defaultUriMatch == null ? UriMatchType.Domain : defaultUriMatch; + const defaultUriMatch = await firstValueFrom( + this.domainSettingsService.defaultUriMatchStrategy$, + ); + this.defaultUriMatch = defaultUriMatch == null ? UriMatchStrategy.Domain : defaultUriMatch; const command = await this.platformUtilsService.getAutofillKeyboardShortcut(); await this.setAutofillKeyboardHelperText(command); @@ -119,7 +122,7 @@ export class AutofillComponent implements OnInit { } async saveDefaultUriMatch() { - await this.stateService.setDefaultUriMatch(this.defaultUriMatch); + await this.domainSettingsService.setDefaultUriMatchStrategy(this.defaultUriMatch); } private async setAutofillKeyboardHelperText(command: string) { diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index c44e3adf7c..77a5f982fd 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -1,4 +1,5 @@ -import { UriMatchType, CipherType } from "@bitwarden/common/vault/enums"; +import { UriMatchStrategySetting } from "@bitwarden/common/models/domain/domain-service"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import AutofillField from "../../models/autofill-field"; @@ -40,7 +41,7 @@ export interface GenerateFillScriptOptions { allowTotpAutofill: boolean; cipher: CipherView; tabUrl: string; - defaultUriMatch: UriMatchType; + defaultUriMatch: UriMatchStrategySetting; } export abstract class AutofillService { diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 4be338da8e..2a9519292e 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -1,19 +1,25 @@ import { mock, mockReset } from "jest-mock-extended"; +import { of } from "rxjs"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; -import { EventType } from "@bitwarden/common/enums"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; import { - FieldType, - LinkedIdType, - LoginLinkedId, - UriMatchType, - CipherType, -} from "@bitwarden/common/vault/enums"; + DefaultDomainSettingsService, + DomainSettingsService, +} from "@bitwarden/common/autofill/services/domain-settings.service"; +import { EventType } from "@bitwarden/common/enums"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; +import { + FakeStateProvider, + FakeAccountService, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { FieldType, LinkedIdType, LoginLinkedId, CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -47,15 +53,24 @@ import { import { AutoFillConstants, IdentityAutoFillConstants } from "./autofill-constants"; import AutofillService from "./autofill.service"; +const mockEquivalentDomains = [ + ["example.com", "exampleapp.com", "example.co.uk", "ejemplo.es"], + ["bitwarden.com", "bitwarden.co.uk", "sm-bitwarden.com"], + ["example.co.uk", "exampleapp.co.uk"], +]; + describe("AutofillService", () => { let autofillService: AutofillService; const cipherService = mock(); const stateService = mock(); const autofillSettingsService = mock(); + const mockUserId = Utils.newGuid() as UserId; + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); + let domainSettingsService: DomainSettingsService; const totpService = mock(); const eventCollectionService = mock(); const logService = mock(); - const settingsService = mock(); const userVerificationService = mock(); beforeEach(() => { @@ -66,9 +81,12 @@ describe("AutofillService", () => { totpService, eventCollectionService, logService, - settingsService, + domainSettingsService, userVerificationService, ); + + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); }); afterEach(() => { @@ -407,6 +425,8 @@ describe("AutofillService", () => { autofillOptions.cipher.login.matchesUri = jest.fn().mockReturnValue(true); autofillOptions.cipher.login.username = "username"; autofillOptions.cipher.login.password = "password"; + + jest.spyOn(autofillService, "getDefaultUriMatchStrategy").mockResolvedValue(0); }); describe("given a set of autofill options that are incomplete", () => { @@ -468,7 +488,6 @@ describe("AutofillService", () => { it("will autofill login data for a page", async () => { jest.spyOn(stateService, "getCanAccessPremium"); - jest.spyOn(stateService, "getDefaultUriMatch"); jest.spyOn(autofillService as any, "generateFillScript"); jest.spyOn(autofillService as any, "generateLoginFillScript"); jest.spyOn(logService, "info"); @@ -479,7 +498,7 @@ describe("AutofillService", () => { const currentAutofillPageDetails = autofillOptions.pageDetails[0]; expect(stateService.getCanAccessPremium).toHaveBeenCalled(); - expect(stateService.getDefaultUriMatch).toHaveBeenCalled(); + expect(autofillService["getDefaultUriMatchStrategy"]).toHaveBeenCalled(); expect(autofillService["generateFillScript"]).toHaveBeenCalledWith( currentAutofillPageDetails.details, { @@ -1488,7 +1507,7 @@ describe("AutofillService", () => { }; defaultLoginUriView = mock({ uri: "https://www.example.com", - match: UriMatchType.Domain, + match: UriMatchStrategy.Domain, }); options = createGenerateFillScriptOptionsMock(); options.cipher.login = mock({ @@ -1559,13 +1578,13 @@ describe("AutofillService", () => { ]); }); - it("skips adding any login uri views that have a UriMatchType of Never to the list of saved urls", async () => { + it("skips adding any login uri views that have a UriMatchStrategySetting of Never to the list of saved urls", async () => { const secondUriView = mock({ uri: "https://www.second-example.com", }); const thirdUriView = mock({ uri: "https://www.third-example.com", - match: UriMatchType.Never, + match: UriMatchStrategy.Never, }); options.cipher.login.uris = [defaultLoginUriView, secondUriView, thirdUriView]; @@ -2752,31 +2771,32 @@ describe("AutofillService", () => { }); describe("inUntrustedIframe", () => { - it("returns a false value if the passed pageUrl is equal to the options tabUrl", () => { + it("returns a false value if the passed pageUrl is equal to the options tabUrl", async () => { const pageUrl = "https://www.example.com"; const tabUrl = "https://www.example.com"; const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl }); generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(true); - jest.spyOn(settingsService, "getEquivalentDomains"); - const result = autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); + const result = await autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); - expect(settingsService.getEquivalentDomains).not.toHaveBeenCalled(); expect(generateFillScriptOptions.cipher.login.matchesUri).not.toHaveBeenCalled(); expect(result).toBe(false); }); - it("returns a false value if the passed pageUrl matches the domain of the tabUrl", () => { + it("returns a false value if the passed pageUrl matches the domain of the tabUrl", async () => { const pageUrl = "https://subdomain.example.com"; const tabUrl = "https://www.example.com"; - const equivalentDomains = new Set(["example.com"]); + const equivalentDomains = new Set([ + "ejemplo.es", + "example.co.uk", + "example.com", + "exampleapp.com", + ]); const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl }); generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(true); - jest.spyOn(settingsService as any, "getEquivalentDomains").mockReturnValue(equivalentDomains); - const result = autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); + const result = await autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); - expect(settingsService.getEquivalentDomains).toHaveBeenCalledWith(pageUrl); expect(generateFillScriptOptions.cipher.login.matchesUri).toHaveBeenCalledWith( pageUrl, equivalentDomains, @@ -2785,17 +2805,21 @@ describe("AutofillService", () => { expect(result).toBe(false); }); - it("returns a true value if the passed pageUrl does not match the domain of the tabUrl", () => { + it("returns a true value if the passed pageUrl does not match the domain of the tabUrl", async () => { + const equivalentDomains = new Set([ + "ejemplo.es", + "example.co.uk", + "example.com", + "exampleapp.com", + ]); + domainSettingsService.equivalentDomains$ = of([["not-example.com"]]); const pageUrl = "https://subdomain.example.com"; const tabUrl = "https://www.not-example.com"; - const equivalentDomains = new Set(["not-example.com"]); const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl }); generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(false); - jest.spyOn(settingsService as any, "getEquivalentDomains").mockReturnValue(equivalentDomains); - const result = autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); + const result = await autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); - expect(settingsService.getEquivalentDomains).toHaveBeenCalledWith(pageUrl); expect(generateFillScriptOptions.cipher.login.matchesUri).toHaveBeenCalledWith( pageUrl, equivalentDomains, diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 4357a9cb6c..3cb73dd72b 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -1,15 +1,19 @@ import { firstValueFrom } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { EventType } from "@bitwarden/common/enums"; +import { + UriMatchStrategySetting, + UriMatchStrategy, +} from "@bitwarden/common/models/domain/domain-service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; -import { FieldType, UriMatchType, CipherType } from "@bitwarden/common/vault/enums"; +import { FieldType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; @@ -48,7 +52,7 @@ export default class AutofillService implements AutofillServiceInterface { private totpService: TotpService, private eventCollectionService: EventCollectionService, private logService: LogService, - private settingsService: SettingsService, + private domainSettingsService: DomainSettingsService, private userVerificationService: UserVerificationService, ) {} @@ -215,6 +219,13 @@ export default class AutofillService implements AutofillServiceInterface { return await firstValueFrom(this.autofillSettingsService.autofillOnPageLoad$); } + /** + * Gets the default URI match strategy setting from the domain settings service. + */ + async getDefaultUriMatchStrategy(): Promise { + return await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$); + } + /** * Autofill a given tab with a given login item * @param {AutoFillOptions} options Instructions about the autofill operation, including tab and login item @@ -229,7 +240,7 @@ export default class AutofillService implements AutofillServiceInterface { let totp: string | null = null; const canAccessPremium = await this.stateService.getCanAccessPremium(); - const defaultUriMatch = (await this.stateService.getDefaultUriMatch()) ?? UriMatchType.Domain; + const defaultUriMatch = await this.getDefaultUriMatchStrategy(); if (!canAccessPremium) { options.cipher.login.totp = null; @@ -579,9 +590,9 @@ export default class AutofillService implements AutofillServiceInterface { let totp: AutofillField = null; const login = options.cipher.login; fillScript.savedUrls = - login?.uris?.filter((u) => u.match != UriMatchType.Never).map((u) => u.uri) ?? []; + login?.uris?.filter((u) => u.match != UriMatchStrategy.Never).map((u) => u.uri) ?? []; - fillScript.untrustedIframe = this.inUntrustedIframe(pageDetails.url, options); + fillScript.untrustedIframe = await this.inUntrustedIframe(pageDetails.url, options); let passwordFields = AutofillService.loadPasswordFields( pageDetails, @@ -1066,7 +1077,10 @@ export default class AutofillService implements AutofillServiceInterface { * @returns {boolean} `true` if the iframe is untrusted and a warning should be shown, `false` otherwise * @private */ - private inUntrustedIframe(pageUrl: string, options: GenerateFillScriptOptions): boolean { + private async inUntrustedIframe( + pageUrl: string, + options: GenerateFillScriptOptions, + ): Promise { // If the pageUrl (from the content script) matches the tabUrl (from the sender tab), we are not in an iframe // This also avoids a false positive if no URI is saved and the user triggers auto-fill anyway if (pageUrl === options.tabUrl) { @@ -1076,7 +1090,9 @@ export default class AutofillService implements AutofillServiceInterface { // Check the pageUrl against cipher URIs using the configured match detection. // Remember: if we are in this function, the tabUrl already matches a saved URI for the login. // We need to verify the pageUrl also matches. - const equivalentDomains = this.settingsService.getEquivalentDomains(pageUrl); + const equivalentDomains = await firstValueFrom( + this.domainSettingsService.getUrlEquivalentDomains(pageUrl), + ); const matchesUri = options.cipher.login.matchesUri( pageUrl, equivalentDomains, diff --git a/apps/browser/src/autofill/spec/autofill-mocks.ts b/apps/browser/src/autofill/spec/autofill-mocks.ts index b580977a37..708489c57e 100644 --- a/apps/browser/src/autofill/spec/autofill-mocks.ts +++ b/apps/browser/src/autofill/spec/autofill-mocks.ts @@ -1,8 +1,9 @@ import { mock } from "jest-mock-extended"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { ThemeType } from "@bitwarden/common/platform/enums"; -import { UriMatchType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -112,7 +113,7 @@ function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScr allowTotpAutofill: false, cipher: mock(), tabUrl: "https://jest-testing-website.com", - defaultUriMatch: UriMatchType.Domain, + defaultUriMatch: UriMatchStrategy.Domain, ...customFields, }; } diff --git a/apps/browser/src/autofill/types/index.ts b/apps/browser/src/autofill/types/index.ts index d9ae0b16f4..8ed893e733 100644 --- a/apps/browser/src/autofill/types/index.ts +++ b/apps/browser/src/autofill/types/index.ts @@ -1,5 +1,4 @@ import { Region } from "@bitwarden/common/platform/abstractions/environment.service"; -import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { VaultTimeoutAction } from "@bitwarden/common/src/enums/vault-timeout-action.enum"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -32,15 +31,10 @@ export type UserSettings = { utcDate: string; version: string; }; - settings: { - equivalentDomains: string[][]; - }; vaultTimeout: number; vaultTimeoutAction: VaultTimeoutAction; }; -export type GlobalSettings = Pick; - /** * A HTMLElement (usually a form element) with additional custom properties added by this script */ diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index ab407a81de..562b0da129 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -56,6 +56,10 @@ import { BadgeSettingsServiceAbstraction, BadgeSettingsService, } from "@bitwarden/common/autofill/services/badge-settings.service"; +import { + DomainSettingsService, + DefaultDomainSettingsService, +} from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsService, UserNotificationSettingsServiceAbstraction, @@ -198,10 +202,11 @@ import { BrowserEnvironmentService } from "../platform/services/browser-environm import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; import BrowserMessagingPrivateModeBackgroundService from "../platform/services/browser-messaging-private-mode-background.service"; import BrowserMessagingService from "../platform/services/browser-messaging.service"; -import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; import { BrowserStateService } from "../platform/services/browser-state.service"; import I18nService from "../platform/services/i18n.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; +import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service"; +import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; import { BackgroundDerivedStateProvider } from "../platform/state/background-derived-state.provider"; import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; import { BrowserSendService } from "../services/browser-send.service"; @@ -258,6 +263,7 @@ export default class MainBackground { userNotificationSettingsService: UserNotificationSettingsServiceAbstraction; autofillSettingsService: AutofillSettingsServiceAbstraction; badgeSettingsService: BadgeSettingsServiceAbstraction; + domainSettingsService: DomainSettingsService; systemService: SystemServiceAbstraction; eventCollectionService: EventCollectionServiceAbstraction; eventUploadService: EventUploadServiceAbstraction; @@ -438,28 +444,10 @@ export default class MainBackground { migrationRunner, ); this.userNotificationSettingsService = new UserNotificationSettingsService(this.stateProvider); - this.platformUtilsService = new BrowserPlatformUtilsService( + this.platformUtilsService = new BackgroundPlatformUtilsService( this.messagingService, - (clipboardValue, clearMs) => { - if (this.systemService != null) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.systemService.clearClipboard(clipboardValue, clearMs); - } - }, - async () => { - if (this.nativeMessagingBackground != null) { - const promise = this.nativeMessagingBackground.getResponse(); - - try { - await this.nativeMessagingBackground.send({ command: "biometricUnlock" }); - } catch (e) { - return Promise.reject(e); - } - - return promise.then((result) => result.response === "unlocked"); - } - }, + (clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs), + async () => this.biometricUnlock(), self, ); this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider); @@ -475,7 +463,7 @@ export default class MainBackground { this.biometricStateService, ); this.tokenService = new TokenService(this.stateService); - this.appIdService = new AppIdService(this.storageService); + this.appIdService = new AppIdService(this.globalStateProvider); this.apiService = new ApiService( this.tokenService, this.platformUtilsService, @@ -483,6 +471,7 @@ export default class MainBackground { this.appIdService, (expired: boolean) => this.logout(expired), ); + this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); this.settingsService = new BrowserSettingsService(this.stateService); this.fileUploadService = new FileUploadService(this.logService); this.cipherFileUploadService = new CipherFileUploadService( @@ -588,6 +577,7 @@ export default class MainBackground { this.policyService, this.deviceTrustCryptoService, this.authRequestService, + this.globalStateProvider, ); this.ssoLoginService = new SsoLoginService(this.stateProvider); @@ -607,7 +597,7 @@ export default class MainBackground { this.cipherService = new CipherService( this.cryptoService, - this.settingsService, + this.domainSettingsService, this.apiService, this.i18nService, this.searchService, @@ -695,7 +685,7 @@ export default class MainBackground { this.providerService = new ProviderService(this.stateProvider); this.syncService = new SyncService( this.apiService, - this.settingsService, + this.domainSettingsService, this.folderService, this.cipherService, this.cryptoService, @@ -732,7 +722,7 @@ export default class MainBackground { this.totpService, this.eventCollectionService, this.logService, - this.settingsService, + this.domainSettingsService, this.userVerificationService, ); this.auditService = new AuditService(this.cryptoFunctionService, this.apiService); @@ -796,6 +786,7 @@ export default class MainBackground { this.authService, this.stateService, this.vaultSettingsService, + this.domainSettingsService, this.logService, ); @@ -865,6 +856,7 @@ export default class MainBackground { this.folderService, this.stateService, this.userNotificationSettingsService, + this.domainSettingsService, this.environmentService, this.logService, ); @@ -1098,7 +1090,6 @@ export default class MainBackground { await Promise.all([ this.syncService.setLastSync(new Date(0), userId), this.cryptoService.clearKeys(userId), - this.settingsService.clear(userId), this.cipherService.clear(userId), this.folderService.clear(userId), this.collectionService.clear(userId), @@ -1223,6 +1214,23 @@ export default class MainBackground { } } + async clearClipboard(clipboardValue: string, clearMs: number) { + if (this.systemService != null) { + await this.systemService.clearClipboard(clipboardValue, clearMs); + } + } + + async biometricUnlock(): Promise { + if (this.nativeMessagingBackground == null) { + return false; + } + + const responsePromise = this.nativeMessagingBackground.getResponse(); + await this.nativeMessagingBackground.send({ command: "biometricUnlock" }); + const response = await responsePromise; + return response.response === "unlocked"; + } + private async fullSync(override = false) { const syncInternal = 6 * 60 * 60 * 1000; // 6 hours const lastSync = await this.syncService.getLastSync(); diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 2717a7b2b5..620f9735c8 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -122,7 +122,7 @@ export class NativeMessagingBackground { break; case "disconnected": if (this.connecting) { - reject("startDesktop"); + reject(new Error("startDesktop")); } this.connected = false; this.port.disconnect(); @@ -203,7 +203,7 @@ export class NativeMessagingBackground { this.connected = false; const reason = error != null ? "desktopIntegrationDisabled" : null; - reject(reason); + reject(new Error(reason)); }); }); } diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 88d0e3f90a..6c0c0f169a 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -19,7 +19,7 @@ import { AutofillService } from "../autofill/services/abstractions/autofill.serv import { BrowserApi } from "../platform/browser/browser-api"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; -import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; +import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; import { AbortManager } from "../vault/background/abort-manager"; import { Fido2Service } from "../vault/services/abstractions/fido2.service"; @@ -68,6 +68,7 @@ export default class RuntimeBackground { "checkFido2FeatureEnabled", "fido2RegisterCredentialRequest", "fido2GetCredentialRequest", + "biometricUnlock", ]; if (messagesWithResponse.includes(msg.command)) { @@ -305,6 +306,14 @@ export default class RuntimeBackground { ); case "switchAccount": { await this.main.switchAccount(msg.userId); + break; + } + case "clearClipboard": { + await this.main.clearClipboard(msg.clipboardValue, msg.timeoutMs); + break; + } + case "biometricUnlock": { + return await this.main.biometricUnlock(); } } } diff --git a/apps/browser/src/platform/background/service-factories/app-id-service.factory.ts b/apps/browser/src/platform/background/service-factories/app-id-service.factory.ts index af5012798b..c43584cba7 100644 --- a/apps/browser/src/platform/background/service-factories/app-id-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/app-id-service.factory.ts @@ -1,14 +1,15 @@ -import { DiskStorageOptions } from "@koa/multer"; - import { AppIdService as AbstractAppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; -import { diskStorageServiceFactory } from "./storage-service.factory"; +import { + GlobalStateProviderInitOptions, + globalStateProviderFactory, +} from "./global-state-provider.factory"; type AppIdServiceFactoryOptions = FactoryOptions; -export type AppIdServiceInitOptions = AppIdServiceFactoryOptions & DiskStorageOptions; +export type AppIdServiceInitOptions = AppIdServiceFactoryOptions & GlobalStateProviderInitOptions; export function appIdServiceFactory( cache: { appIdService?: AbstractAppIdService } & CachedServices, @@ -18,6 +19,6 @@ export function appIdServiceFactory( cache, "appIdService", opts, - async () => new AppIdService(await diskStorageServiceFactory(cache, opts)), + async () => new AppIdService(await globalStateProviderFactory(cache, opts)), ); } diff --git a/apps/browser/src/platform/background/service-factories/platform-utils-service.factory.ts b/apps/browser/src/platform/background/service-factories/platform-utils-service.factory.ts index d3ac465e4c..6f46d87418 100644 --- a/apps/browser/src/platform/background/service-factories/platform-utils-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/platform-utils-service.factory.ts @@ -1,6 +1,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import BrowserPlatformUtilsService from "../../services/browser-platform-utils.service"; +import { BackgroundPlatformUtilsService } from "../../services/platform-utils/background-platform-utils.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { MessagingServiceInitOptions, messagingServiceFactory } from "./messaging-service.factory"; @@ -25,7 +25,7 @@ export function platformUtilsServiceFactory( "platformUtilsService", opts, async () => - new BrowserPlatformUtilsService( + new BackgroundPlatformUtilsService( await messagingServiceFactory(cache, opts), opts.platformUtilsServiceOptions.clipboardWriteCallback, opts.platformUtilsServiceOptions.biometricCallback, diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 5e490a99f4..362aac1af9 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -3,7 +3,7 @@ import { Observable } from "rxjs"; import { DeviceType } from "@bitwarden/common/enums"; import { TabMessage } from "../../types/tab-messages"; -import BrowserPlatformUtilsService from "../services/browser-platform-utils.service"; +import { BrowserPlatformUtilsService } from "../services/platform-utils/browser-platform-utils.service"; export class BrowserApi { static isWebExtensionsApi: boolean = typeof browser !== "undefined"; diff --git a/apps/browser/src/platform/listeners/update-badge.ts b/apps/browser/src/platform/listeners/update-badge.ts index ee9996a28b..c39cb8c894 100644 --- a/apps/browser/src/platform/listeners/update-badge.ts +++ b/apps/browser/src/platform/listeners/update-badge.ts @@ -17,7 +17,7 @@ import { Account } from "../../models/account"; import IconDetails from "../../vault/background/models/icon-details"; import { cipherServiceFactory } from "../../vault/background/service_factories/cipher-service.factory"; import { BrowserApi } from "../browser/browser-api"; -import BrowserPlatformUtilsService from "../services/browser-platform-utils.service"; +import { BrowserPlatformUtilsService } from "../services/platform-utils/browser-platform-utils.service"; export type BadgeOptions = { tab?: chrome.tabs.Tab; diff --git a/apps/browser/src/platform/services/platform-utils/background-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/background-platform-utils.service.ts new file mode 100644 index 0000000000..27ed3f016b --- /dev/null +++ b/apps/browser/src/platform/services/platform-utils/background-platform-utils.service.ts @@ -0,0 +1,28 @@ +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { BrowserPlatformUtilsService } from "./browser-platform-utils.service"; + +export class BackgroundPlatformUtilsService extends BrowserPlatformUtilsService { + constructor( + private messagingService: MessagingService, + clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, + biometricCallback: () => Promise, + win: Window & typeof globalThis, + ) { + super(clipboardWriteCallback, biometricCallback, win); + } + + override showToast( + type: "error" | "success" | "warning" | "info", + title: string, + text: string | string[], + options?: any, + ): void { + this.messagingService.send("showToast", { + text: text, + title: title, + type: type, + options: options, + }); + } +} diff --git a/apps/browser/src/platform/services/browser-platform-utils.service.spec.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts similarity index 94% rename from apps/browser/src/platform/services/browser-platform-utils.service.spec.ts rename to apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts index e70a641624..cf6816f405 100644 --- a/apps/browser/src/platform/services/browser-platform-utils.service.spec.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts @@ -1,11 +1,26 @@ import { DeviceType } from "@bitwarden/common/enums"; -import { flushPromises } from "../../autofill/spec/testing-utils"; -import { SafariApp } from "../../browser/safariApp"; -import { BrowserApi } from "../browser/browser-api"; +import { flushPromises } from "../../../autofill/spec/testing-utils"; +import { SafariApp } from "../../../browser/safariApp"; +import { BrowserApi } from "../../browser/browser-api"; +import BrowserClipboardService from "../browser-clipboard.service"; -import BrowserClipboardService from "./browser-clipboard.service"; -import BrowserPlatformUtilsService from "./browser-platform-utils.service"; +import { BrowserPlatformUtilsService } from "./browser-platform-utils.service"; + +class TestBrowserPlatformUtilsService extends BrowserPlatformUtilsService { + constructor(clipboardSpy: jest.Mock, win: Window & typeof globalThis) { + super(clipboardSpy, null, win); + } + + showToast( + type: "error" | "success" | "warning" | "info", + title: string, + text: string | string[], + options?: any, + ): void { + throw new Error("Method not implemented."); + } +} describe("Browser Utils Service", () => { let browserPlatformUtilsService: BrowserPlatformUtilsService; @@ -13,10 +28,8 @@ describe("Browser Utils Service", () => { beforeEach(() => { (window as any).matchMedia = jest.fn().mockReturnValueOnce({}); - browserPlatformUtilsService = new BrowserPlatformUtilsService( - null, + browserPlatformUtilsService = new TestBrowserPlatformUtilsService( clipboardWriteCallbackSpy, - null, window, ); }); diff --git a/apps/browser/src/platform/services/browser-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts similarity index 94% rename from apps/browser/src/platform/services/browser-platform-utils.service.ts rename to apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts index a9841db958..e9f7f17d9b 100644 --- a/apps/browser/src/platform/services/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts @@ -1,20 +1,17 @@ import { ClientType, DeviceType } from "@bitwarden/common/enums"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { ClipboardOptions, PlatformUtilsService, } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SafariApp } from "../../browser/safariApp"; -import { BrowserApi } from "../browser/browser-api"; +import { SafariApp } from "../../../browser/safariApp"; +import { BrowserApi } from "../../browser/browser-api"; +import BrowserClipboardService from "../browser-clipboard.service"; -import BrowserClipboardService from "./browser-clipboard.service"; - -export default class BrowserPlatformUtilsService implements PlatformUtilsService { +export abstract class BrowserPlatformUtilsService implements PlatformUtilsService { private static deviceCache: DeviceType = null; constructor( - private messagingService: MessagingService, private clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, private biometricCallback: () => Promise, private globalContext: Window | ServiceWorkerGlobalScope, @@ -193,19 +190,12 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService return true; } - showToast( + abstract showToast( type: "error" | "success" | "warning" | "info", title: string, text: string | string[], options?: any, - ): void { - this.messagingService.send("showToast", { - text: text, - title: title, - type: type, - options: options, - }); - } + ): void; isDev(): boolean { return process.env.ENV === "development"; @@ -279,11 +269,10 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService async supportsBiometric() { const platformInfo = await BrowserApi.getPlatformInfo(); - if (platformInfo.os === "android") { - return false; + if (platformInfo.os === "mac" || platformInfo.os === "win") { + return true; } - - return true; + return false; } authenticateBiometric() { diff --git a/apps/browser/src/platform/services/platform-utils/foreground-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/foreground-platform-utils.service.ts new file mode 100644 index 0000000000..8cf1a8d3e4 --- /dev/null +++ b/apps/browser/src/platform/services/platform-utils/foreground-platform-utils.service.ts @@ -0,0 +1,40 @@ +import { SecurityContext } from "@angular/core"; +import { DomSanitizer } from "@angular/platform-browser"; +import { ToastrService } from "ngx-toastr"; + +import { BrowserPlatformUtilsService } from "./browser-platform-utils.service"; + +export class ForegroundPlatformUtilsService extends BrowserPlatformUtilsService { + constructor( + private sanitizer: DomSanitizer, + private toastrService: ToastrService, + clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, + biometricCallback: () => Promise, + win: Window & typeof globalThis, + ) { + super(clipboardWriteCallback, biometricCallback, win); + } + + override showToast( + type: "error" | "success" | "warning" | "info", + title: string, + text: string | string[], + options?: any, + ): void { + if (typeof text === "string") { + // Already in the correct format + } else if (text.length === 1) { + text = text[0]; + } else { + let message = ""; + text.forEach( + (t: string) => + (message += "

" + this.sanitizer.sanitize(SecurityContext.HTML, t) + "

"), + ); + text = message; + options.enableHtml = true; + } + this.toastrService.show(text, title, options, "toast-" + type); + // noop + } +} diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 53a5812f6d..7f2d8f682a 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -1,27 +1,18 @@ -import { - ChangeDetectorRef, - Component, - NgZone, - OnDestroy, - OnInit, - SecurityContext, -} from "@angular/core"; -import { DomSanitizer } from "@angular/platform-browser"; +import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { IndividualConfig, ToastrService } from "ngx-toastr"; +import { ToastrService } from "ngx-toastr"; import { filter, concatMap, Subject, takeUntil, firstValueFrom } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; import { BrowserApi } from "../platform/browser/browser-api"; import { ZonedMessageListenerService } from "../platform/browser/zoned-message-listener.service"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; +import { ForegroundPlatformUtilsService } from "../platform/services/platform-utils/foreground-platform-utils.service"; import { routerTransition } from "./app-routing.animations"; import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component"; @@ -48,11 +39,9 @@ export class AppComponent implements OnInit, OnDestroy { private router: Router, private stateService: BrowserStateService, private cipherService: CipherService, - private messagingService: MessagingService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, - private sanitizer: DomSanitizer, - private platformUtilsService: PlatformUtilsService, + private platformUtilsService: ForegroundPlatformUtilsService, private dialogService: DialogService, private browserMessagingApi: ZonedMessageListenerService, ) {} @@ -219,31 +208,7 @@ export class AppComponent implements OnInit, OnDestroy { } private showToast(msg: any) { - let message = ""; - - const options: Partial = {}; - - if (typeof msg.text === "string") { - message = msg.text; - } else if (msg.text.length === 1) { - message = msg.text[0]; - } else { - msg.text.forEach( - (t: string) => - (message += "

" + this.sanitizer.sanitize(SecurityContext.HTML, t) + "

"), - ); - options.enableHtml = true; - } - if (msg.options != null) { - if (msg.options.trustedHtml === true) { - options.enableHtml = true; - } - if (msg.options.timeout != null && msg.options.timeout > 0) { - options.timeOut = msg.options.timeout; - } - } - - this.toastrService.show(message, msg.title, options, "toast-" + msg.type); + this.platformUtilsService.showToast(msg.type, msg.title, msg.text, msg.options); } private async showDialog(msg: SimpleDialogOptions) { diff --git a/apps/browser/src/popup/main.ts b/apps/browser/src/popup/main.ts index 05ecfb43e5..c98e30e6bc 100644 --- a/apps/browser/src/popup/main.ts +++ b/apps/browser/src/popup/main.ts @@ -1,7 +1,7 @@ import { enableProdMode } from "@angular/core"; import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; -import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; +import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; require("./scss/popup.scss"); require("./scss/tailwind.css"); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index a7d69df544..332a1db746 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -1,4 +1,6 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; +import { DomSanitizer } from "@angular/platform-browser"; +import { ToastrService } from "ngx-toastr"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; import { ThemingService } from "@bitwarden/angular/platform/services/theming/theming.service"; @@ -44,7 +46,6 @@ import { UserNotificationSettingsService, UserNotificationSettingsServiceAbstraction, } from "@bitwarden/common/autofill/services/user-notification-settings.service"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -118,6 +119,7 @@ import BrowserMessagingPrivateModePopupService from "../../platform/services/bro import BrowserMessagingService from "../../platform/services/browser-messaging.service"; import { BrowserStateService } from "../../platform/services/browser-state.service"; import I18nService from "../../platform/services/i18n.service"; +import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service"; import { ForegroundDerivedStateProvider } from "../../platform/state/foreground-derived-state.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; import { BrowserSendService } from "../../services/browser-send.service"; @@ -291,8 +293,32 @@ function getBgService(service: keyof MainBackground) { }, { provide: PlatformUtilsService, - useFactory: getBgService("platformUtilsService"), - deps: [], + useExisting: ForegroundPlatformUtilsService, + }, + { + provide: ForegroundPlatformUtilsService, + useClass: ForegroundPlatformUtilsService, + useFactory: (sanitizer: DomSanitizer, toastrService: ToastrService) => { + return new ForegroundPlatformUtilsService( + sanitizer, + toastrService, + (clipboardValue: string, clearMs: number) => { + void BrowserApi.sendMessage("clearClipboard", { clipboardValue, clearMs }); + }, + async () => { + const response = await BrowserApi.sendMessageWithResponse<{ + result: boolean; + error: string; + }>("biometricUnlock"); + if (!response.result) { + throw response.error; + } + return response.result; + }, + window, + ); + }, + deps: [DomSanitizer, ToastrService], }, { provide: PasswordStrengthServiceAbstraction, @@ -349,7 +375,6 @@ function getBgService(service: keyof MainBackground) { useClass: BrowserLocalStorageService, deps: [], }, - { provide: AppIdService, useFactory: getBgService("appIdService"), deps: [] }, { provide: AutofillService, useFactory: getBgService("autofillService"), diff --git a/apps/browser/src/popup/settings/excluded-domains.component.ts b/apps/browser/src/popup/settings/excluded-domains.component.ts index a7708c7ce3..6fa28d10ab 100644 --- a/apps/browser/src/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/popup/settings/excluded-domains.component.ts @@ -1,6 +1,8 @@ import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -30,6 +32,7 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { constructor( private stateService: StateService, + private domainSettingsService: DomainSettingsService, private i18nService: I18nService, private router: Router, private broadcasterService: BroadcasterService, @@ -40,7 +43,7 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { } async ngOnInit() { - const savedDomains = await this.stateService.getNeverDomains(); + const savedDomains = await firstValueFrom(this.domainSettingsService.neverDomains$); if (savedDomains) { for (const uri of Object.keys(savedDomains)) { this.excludedDomains.push({ uri: uri, showCurrentUris: false }); @@ -107,7 +110,7 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { } } - await this.stateService.setNeverDomains(savedDomains); + await this.domainSettingsService.setNeverDomains(savedDomains); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(["/tabs/settings"]); diff --git a/apps/browser/src/popup/settings/options.component.ts b/apps/browser/src/popup/settings/options.component.ts index 2af3713171..eca37e3315 100644 --- a/apps/browser/src/popup/settings/options.component.ts +++ b/apps/browser/src/popup/settings/options.component.ts @@ -5,14 +5,18 @@ import { AbstractThemingService } from "@bitwarden/angular/platform/services/the import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { ClearClipboardDelaySetting } from "@bitwarden/common/autofill/types"; +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; -import { UriMatchType } from "@bitwarden/common/vault/enums"; import { enableAccountSwitching } from "../../platform/flags"; @@ -36,7 +40,7 @@ export class OptionsComponent implements OnInit { showClearClipboard = true; theme: ThemeType; themeOptions: any[]; - defaultUriMatch = UriMatchType.Domain; + defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain; uriMatchOptions: any[]; clearClipboard: ClearClipboardDelaySetting; clearClipboardOptions: any[]; @@ -50,6 +54,7 @@ export class OptionsComponent implements OnInit { private stateService: StateService, private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction, + private domainSettingsService: DomainSettingsService, private badgeSettingsService: BadgeSettingsServiceAbstraction, i18nService: I18nService, private themingService: AbstractThemingService, @@ -64,12 +69,12 @@ export class OptionsComponent implements OnInit { { name: i18nService.t("solarizedDark"), value: ThemeType.SolarizedDark }, ]; this.uriMatchOptions = [ - { name: i18nService.t("baseDomain"), value: UriMatchType.Domain }, - { name: i18nService.t("host"), value: UriMatchType.Host }, - { name: i18nService.t("startsWith"), value: UriMatchType.StartsWith }, - { name: i18nService.t("regEx"), value: UriMatchType.RegularExpression }, - { name: i18nService.t("exact"), value: UriMatchType.Exact }, - { name: i18nService.t("never"), value: UriMatchType.Never }, + { name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain }, + { name: i18nService.t("host"), value: UriMatchStrategy.Host }, + { name: i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith }, + { name: i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression }, + { name: i18nService.t("exact"), value: UriMatchStrategy.Exact }, + { name: i18nService.t("never"), value: UriMatchStrategy.Never }, ]; this.clearClipboardOptions = [ { name: i18nService.t("never"), value: null }, @@ -122,8 +127,10 @@ export class OptionsComponent implements OnInit { this.theme = await this.stateService.getTheme(); - const defaultUriMatch = await this.stateService.getDefaultUriMatch(); - this.defaultUriMatch = defaultUriMatch == null ? UriMatchType.Domain : defaultUriMatch; + const defaultUriMatch = await firstValueFrom( + this.domainSettingsService.defaultUriMatchStrategy$, + ); + this.defaultUriMatch = defaultUriMatch == null ? UriMatchStrategy.Domain : defaultUriMatch; this.clearClipboard = await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$); } @@ -182,10 +189,6 @@ export class OptionsComponent implements OnInit { await this.themingService.updateConfiguredTheme(this.theme); } - async saveDefaultUriMatch() { - await this.stateService.setDefaultUriMatch(this.defaultUriMatch); - } - async saveClearClipboard() { await this.autofillSettingsService.setClearClipboardDelay(this.clearClipboard); } diff --git a/apps/browser/src/popup/settings/settings.component.ts b/apps/browser/src/popup/settings/settings.component.ts index c83c7b5e72..2fcd258885 100644 --- a/apps/browser/src/popup/settings/settings.component.ts +++ b/apps/browser/src/popup/settings/settings.component.ts @@ -397,7 +397,7 @@ export class SettingsComponent implements OnInit { // Handle connection errors this.form.controls.biometric.setValue(false); - const error = BiometricErrors[e as BiometricErrorTypes]; + const error = BiometricErrors[e.message as BiometricErrorTypes]; // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/apps/browser/src/services/browser-settings.service.ts b/apps/browser/src/services/browser-settings.service.ts index 89378bcc74..50c27ce4f6 100644 --- a/apps/browser/src/services/browser-settings.service.ts +++ b/apps/browser/src/services/browser-settings.service.ts @@ -1,15 +1,11 @@ import { BehaviorSubject } from "rxjs"; -import { AccountSettingsSettings } from "@bitwarden/common/platform/models/domain/account"; import { SettingsService } from "@bitwarden/common/services/settings.service"; import { browserSession, sessionSync } from "../platform/decorators/session-sync-observable"; @browserSession export class BrowserSettingsService extends SettingsService { - @sessionSync({ initializer: (obj: string[][]) => obj }) - protected _settings: BehaviorSubject; - @sessionSync({ initializer: (b: boolean) => b }) protected _disableFavicon: BehaviorSubject; } diff --git a/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts b/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts index ae21947843..57366ea8c0 100644 --- a/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts +++ b/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts @@ -5,6 +5,10 @@ import { AutofillSettingsServiceInitOptions, autofillSettingsServiceFactory, } from "../../../autofill/background/service_factories/autofill-settings-service.factory"; +import { + DomainSettingsServiceInitOptions, + domainSettingsServiceFactory, +} from "../../../autofill/background/service_factories/domain-settings-service.factory"; import { CipherFileUploadServiceInitOptions, cipherFileUploadServiceFactory, @@ -13,10 +17,6 @@ import { searchServiceFactory, SearchServiceInitOptions, } from "../../../background/service-factories/search-service.factory"; -import { - SettingsServiceInitOptions, - settingsServiceFactory, -} from "../../../background/service-factories/settings-service.factory"; import { apiServiceFactory, ApiServiceInitOptions, @@ -52,13 +52,13 @@ type CipherServiceFactoryOptions = FactoryOptions; export type CipherServiceInitOptions = CipherServiceFactoryOptions & CryptoServiceInitOptions & - SettingsServiceInitOptions & ApiServiceInitOptions & CipherFileUploadServiceInitOptions & I18nServiceInitOptions & SearchServiceInitOptions & StateServiceInitOptions & AutofillSettingsServiceInitOptions & + DomainSettingsServiceInitOptions & EncryptServiceInitOptions & ConfigServiceInitOptions; @@ -73,7 +73,7 @@ export function cipherServiceFactory( async () => new CipherService( await cryptoServiceFactory(cache, opts), - await settingsServiceFactory(cache, opts), + await domainSettingsServiceFactory(cache, opts), await apiServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await searchServiceFactory(cache, opts), diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts index ce22787f09..9ea0914d87 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts @@ -3,6 +3,7 @@ import { ConnectedPosition } from "@angular/cdk/overlay"; import { Component } from "@angular/core"; import { firstValueFrom } from "rxjs"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -52,6 +53,7 @@ export class Fido2UseBrowserLinkComponent { constructor( private stateService: StateService, + private domainSettingsService: DomainSettingsService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, ) {} @@ -89,7 +91,7 @@ export class Fido2UseBrowserLinkComponent { * @param uri - The domain uri to exclude from future FIDO2 prompts. */ private async handleDomainExclusion(uri: string) { - const exisitingDomains = await this.stateService.getNeverDomains(); + const exisitingDomains = await firstValueFrom(this.domainSettingsService.neverDomains$); const validDomain = Utils.getHostname(uri); const savedDomains: { [name: string]: unknown } = { @@ -97,9 +99,7 @@ export class Fido2UseBrowserLinkComponent { }; savedDomains[validDomain] = null; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.stateService.setNeverDomains(savedDomains); + await this.domainSettingsService.setNeverDomains(savedDomains); this.platformUtilsService.showToast( "success", diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts index 6cd5046826..81d1b88fd8 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts @@ -5,6 +5,7 @@ import { combineLatest, concatMap, filter, + firstValueFrom, map, Observable, Subject, @@ -13,7 +14,7 @@ import { } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -72,7 +73,7 @@ export class Fido2Component implements OnInit, OnDestroy { private cipherService: CipherService, private passwordRepromptService: PasswordRepromptService, private platformUtilsService: PlatformUtilsService, - private settingsService: SettingsService, + private domainSettingsService: DomainSettingsService, private searchService: SearchService, private logService: LogService, private dialogService: DialogService, @@ -133,7 +134,9 @@ export class Fido2Component implements OnInit, OnDestroy { concatMap(async (message) => { switch (message.type) { case "ConfirmNewCredentialRequest": { - const equivalentDomains = this.settingsService.getEquivalentDomains(this.url); + const equivalentDomains = await firstValueFrom( + this.domainSettingsService.getUrlEquivalentDomains(this.url), + ); this.ciphers = (await this.cipherService.getAllDecrypted()).filter( (cipher) => cipher.type === CipherType.Login && !cipher.isDeleted, @@ -317,7 +320,9 @@ export class Fido2Component implements OnInit, OnDestroy { this.ciphers, ); } else { - const equivalentDomains = this.settingsService.getEquivalentDomains(this.url); + const equivalentDomains = await firstValueFrom( + this.domainSettingsService.getUrlEquivalentDomains(this.url), + ); this.displayedCiphers = this.ciphers.filter((cipher) => cipher.login.matchesUri(this.url, equivalentDomains), ); diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 3f712d9c10..3b5724b198 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -1,6 +1,5 @@ const path = require("path"); const webpack = require("webpack"); -const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const CopyWebpackPlugin = require("copy-webpack-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); @@ -138,9 +137,6 @@ const plugins = [ entryModule: "src/popup/app.module#AppModule", sourceMap: true, }), - new CleanWebpackPlugin({ - cleanAfterEveryBuildPatterns: ["!popup/fonts/**/*"], - }), new webpack.ProvidePlugin({ process: "process/browser.js", }), @@ -244,6 +240,7 @@ const mainConfig = { output: { filename: "[name].js", path: path.resolve(__dirname, "build"), + clean: true, }, module: { noParse: /\.wasm$/, diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index ae0949252a..75e6479dc0 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -273,8 +273,8 @@ export class LoginCommand { selectedProvider.type === TwoFactorProviderType.Email ) { const emailReq = new TwoFactorEmailRequest(); - emailReq.email = this.loginStrategyService.email; - emailReq.masterPasswordHash = this.loginStrategyService.masterPasswordHash; + emailReq.email = await this.loginStrategyService.getEmail(); + emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); await this.apiService.postTwoFactorEmail(emailReq); } diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 2485af89e1..dcb87c70fa 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -35,6 +35,10 @@ import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.ser import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { + DefaultDomainSettingsService, + DomainSettingsService, +} from "@bitwarden/common/autofill/services/domain-settings.service"; import { ClientType } from "@bitwarden/common/enums"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -190,6 +194,7 @@ export class Main { pinCryptoService: PinCryptoServiceAbstraction; stateService: StateService; autofillSettingsService: AutofillSettingsServiceAbstraction; + domainSettingsService: DomainSettingsService; organizationService: OrganizationService; providerService: ProviderService; twoFactorService: TwoFactorService; @@ -333,7 +338,7 @@ export class Main { this.stateProvider, ); - this.appIdService = new AppIdService(this.storageService); + this.appIdService = new AppIdService(this.globalStateProvider); this.tokenService = new TokenService(this.stateService); const customUserAgent = @@ -358,6 +363,7 @@ export class Main { this.containerService = new ContainerService(this.cryptoService, this.encryptService); this.settingsService = new SettingsService(this.stateService); + this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); this.fileUploadService = new FileUploadService(this.logService); @@ -458,6 +464,7 @@ export class Main { this.policyService, this.deviceTrustCryptoService, this.authRequestService, + this.globalStateProvider, ); this.authService = new AuthService( @@ -480,7 +487,7 @@ export class Main { this.cipherService = new CipherService( this.cryptoService, - this.settingsService, + this.domainSettingsService, this.apiService, this.i18nService, this.searchService, @@ -551,7 +558,7 @@ export class Main { this.syncService = new SyncService( this.apiService, - this.settingsService, + this.domainSettingsService, this.folderService, this.cipherService, this.cryptoService, @@ -647,7 +654,6 @@ export class Main { await Promise.all([ this.syncService.setLastSync(new Date(0)), this.cryptoService.clearKeys(), - this.settingsService.clear(userId), this.cipherService.clear(userId), this.folderService.clear(userId), this.collectionService.clear(userId as UserId), diff --git a/apps/cli/webpack.config.js b/apps/cli/webpack.config.js index 5acbf538d0..2b9c53bac6 100644 --- a/apps/cli/webpack.config.js +++ b/apps/cli/webpack.config.js @@ -1,6 +1,5 @@ const path = require("path"); const webpack = require("webpack"); -const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const CopyWebpackPlugin = require("copy-webpack-plugin"); const nodeExternals = require("webpack-node-externals"); const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); @@ -23,7 +22,6 @@ const moduleRules = [ ]; const plugins = [ - new CleanWebpackPlugin(), new CopyWebpackPlugin({ patterns: [{ from: "./src/locales", to: "locales" }], }), @@ -71,6 +69,7 @@ const webpackConfig = { output: { filename: "[name].js", path: path.resolve(__dirname, "build"), + clean: true, }, module: { rules: moduleRules }, plugins: plugins, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d2df3e9a7b..6b01918d6b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2024.3.0", + "version": "2024.3.1", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index cbee441fa3..a59b07241d 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -577,7 +577,6 @@ export class AppComponent implements OnInit, OnDestroy { await this.eventUploadService.uploadEvents(userBeingLoggedOut); await this.syncService.setLastSync(new Date(0), userBeingLoggedOut); await this.cryptoService.clearKeys(userBeingLoggedOut); - await this.settingsService.clear(userBeingLoggedOut); await this.cipherService.clear(userBeingLoggedOut); await this.folderService.clear(userBeingLoggedOut); await this.collectionService.clear(userBeingLoggedOut); diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 3c98a7cf7c..2862977ca2 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2024.3.0", + "version": "2024.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2024.3.0", + "version": "2024.3.1", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-native": "file:../desktop_native" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 4cd6941655..4c748c4a05 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2024.3.0", + "version": "2024.3.1", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/webpack.main.js b/apps/desktop/webpack.main.js index 9d683457d9..83577fcd62 100644 --- a/apps/desktop/webpack.main.js +++ b/apps/desktop/webpack.main.js @@ -1,7 +1,6 @@ const path = require("path"); const { merge } = require("webpack-merge"); const CopyWebpackPlugin = require("copy-webpack-plugin"); -const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); const configurator = require("./config/config"); const { EnvironmentPlugin } = require("webpack"); diff --git a/apps/desktop/webpack.preload.js b/apps/desktop/webpack.preload.js index 721d0567ca..0e285eaa5f 100644 --- a/apps/desktop/webpack.preload.js +++ b/apps/desktop/webpack.preload.js @@ -1,7 +1,6 @@ const path = require("path"); const { merge } = require("webpack-merge"); const CopyWebpackPlugin = require("copy-webpack-plugin"); -const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); const configurator = require("./config/config"); const { EnvironmentPlugin } = require("webpack"); diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 73721de789..83900cd85d 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -275,7 +275,6 @@ export class AppComponent implements OnDestroy, OnInit { await Promise.all([ this.syncService.setLastSync(new Date(0)), this.cryptoService.clearKeys(), - this.settingsService.clear(userId), this.cipherService.clear(userId), this.folderService.clear(userId), this.collectionService.clear(userId), diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index 6ac384cb82..aeaf4c577c 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -2,7 +2,6 @@ const fs = require("fs"); const path = require("path"); const { AngularWebpackPlugin } = require("@ngtools/webpack"); -const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const CopyWebpackPlugin = require("copy-webpack-plugin"); const HtmlWebpackInjector = require("html-webpack-injector"); const HtmlWebpackPlugin = require("html-webpack-plugin"); @@ -87,7 +86,6 @@ const moduleRules = [ ]; const plugins = [ - new CleanWebpackPlugin(), new HtmlWebpackPlugin({ template: "./src/index.html", filename: "index.html", @@ -371,6 +369,7 @@ const webpackConfig = { output: { filename: "[name].[contenthash].js", path: path.resolve(__dirname, "build"), + clean: true, }, module: { noParse: /\.wasm$/, diff --git a/libs/angular/src/auth/components/login-via-auth-request.component.ts b/libs/angular/src/auth/components/login-via-auth-request.component.ts index b9bfe902ea..45d7f563f7 100644 --- a/libs/angular/src/auth/components/login-via-auth-request.component.ts +++ b/libs/angular/src/auth/components/login-via-auth-request.component.ts @@ -99,8 +99,7 @@ export class LoginViaAuthRequestComponent } //gets signalR push notification - this.loginStrategyService - .getPushNotificationObs$() + this.loginStrategyService.authRequestPushNotification$ .pipe(takeUntil(this.destroy$)) .subscribe((id) => { // Only fires on approval currently diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor.component.ts index e5f3230689..c697d9ceb0 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor.component.ts @@ -1,6 +1,7 @@ import { Directive, Inject, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, NavigationExtras, Router } from "@angular/router"; import * as DuoWebSDK from "duo_web_sdk"; +import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; // eslint-disable-next-line no-restricted-imports @@ -10,6 +11,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -92,7 +94,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI } async ngOnInit() { - if (!this.authing || this.twoFactorService.getProviders() == null) { + if (!(await this.authing()) || this.twoFactorService.getProviders() == null) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate([this.loginRoute]); @@ -105,7 +107,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI } }); - if (this.needsLock) { + if (await this.needsLock()) { this.successRoute = "lock"; } @@ -426,7 +428,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI return; } - if (this.loginStrategyService.email == null) { + if ((await this.loginStrategyService.getEmail()) == null) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), @@ -437,12 +439,13 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI try { const request = new TwoFactorEmailRequest(); - request.email = this.loginStrategyService.email; - request.masterPasswordHash = this.loginStrategyService.masterPasswordHash; - request.ssoEmail2FaSessionToken = this.loginStrategyService.ssoEmail2FaSessionToken; + request.email = await this.loginStrategyService.getEmail(); + request.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); + request.ssoEmail2FaSessionToken = + await this.loginStrategyService.getSsoEmail2FaSessionToken(); request.deviceIdentifier = await this.appIdService.getAppId(); - request.authRequestAccessCode = this.loginStrategyService.accessCode; - request.authRequestId = this.loginStrategyService.authRequestId; + request.authRequestAccessCode = await this.loginStrategyService.getAccessCode(); + request.authRequestId = await this.loginStrategyService.getAuthRequestId(); this.emailPromise = this.apiService.postTwoFactorEmail(request); await this.emailPromise; if (doToast) { @@ -476,20 +479,13 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI } } - get authing(): boolean { - return ( - this.loginStrategyService.authingWithPassword() || - this.loginStrategyService.authingWithSso() || - this.loginStrategyService.authingWithUserApiKey() || - this.loginStrategyService.authingWithPasswordless() - ); + private async authing(): Promise { + return (await firstValueFrom(this.loginStrategyService.currentAuthType$)) !== null; } - get needsLock(): boolean { - return ( - this.loginStrategyService.authingWithSso() || - this.loginStrategyService.authingWithUserApiKey() - ); + private async needsLock(): Promise { + const authType = await firstValueFrom(this.loginStrategyService.currentAuthType$); + return authType == AuthenticationType.Sso || authType == AuthenticationType.UserApiKey; } // implemented in clients diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 8535f65266..12fb55f08a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -90,6 +90,10 @@ import { BadgeSettingsServiceAbstraction, BadgeSettingsService, } from "@bitwarden/common/autofill/services/badge-settings.service"; +import { + DomainSettingsService, + DefaultDomainSettingsService, +} from "@bitwarden/common/autofill/services/domain-settings.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service"; import { PaymentMethodWarningsServiceAbstraction } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; @@ -290,7 +294,7 @@ import { ModalService } from "./modal.service"; { provide: AppIdServiceAbstraction, useClass: AppIdService, - deps: [AbstractStorageService], + deps: [GlobalStateProvider], }, { provide: AuditServiceAbstraction, @@ -328,6 +332,7 @@ import { ModalService } from "./modal.service"; PolicyServiceAbstraction, DeviceTrustCryptoServiceAbstraction, AuthRequestServiceAbstraction, + GlobalStateProvider, ], }, { @@ -350,6 +355,7 @@ import { ModalService } from "./modal.service"; searchService: SearchServiceAbstraction, stateService: StateServiceAbstraction, autofillSettingsService: AutofillSettingsServiceAbstraction, + domainSettingsService: DomainSettingsService, encryptService: EncryptService, fileUploadService: CipherFileUploadServiceAbstraction, configService: ConfigServiceAbstraction, @@ -357,7 +363,7 @@ import { ModalService } from "./modal.service"; ) => new CipherService( cryptoService, - settingsService, + domainSettingsService, apiService, i18nService, searchService, @@ -739,7 +745,7 @@ import { ModalService } from "./modal.service"; useClass: PasswordResetEnrollmentServiceImplementation, deps: [ OrganizationApiServiceAbstraction, - StateServiceAbstraction, + AccountServiceAbstraction, CryptoServiceAbstraction, OrganizationUserService, I18nServiceAbstraction, @@ -964,6 +970,11 @@ import { ModalService } from "./modal.service"; useClass: BadgeSettingsService, deps: [StateProvider], }, + { + provide: DomainSettingsService, + useClass: DefaultDomainSettingsService, + deps: [StateProvider], + }, { provide: BiometricStateService, useClass: DefaultBiometricStateService, diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 02dcca9c07..ee7830ea08 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -13,6 +13,7 @@ import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin- import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -24,7 +25,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { CipherType, SecureNoteType, UriMatchType } from "@bitwarden/common/vault/enums"; +import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; @@ -164,12 +165,12 @@ export class AddEditComponent implements OnInit, OnDestroy { ]; this.uriMatchOptions = [ { name: i18nService.t("defaultMatchDetection"), value: null }, - { name: i18nService.t("baseDomain"), value: UriMatchType.Domain }, - { name: i18nService.t("host"), value: UriMatchType.Host }, - { name: i18nService.t("startsWith"), value: UriMatchType.StartsWith }, - { name: i18nService.t("regEx"), value: UriMatchType.RegularExpression }, - { name: i18nService.t("exact"), value: UriMatchType.Exact }, - { name: i18nService.t("never"), value: UriMatchType.Never }, + { name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain }, + { name: i18nService.t("host"), value: UriMatchStrategy.Host }, + { name: i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith }, + { name: i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression }, + { name: i18nService.t("exact"), value: UriMatchStrategy.Exact }, + { name: i18nService.t("never"), value: UriMatchStrategy.Never }, ]; this.autofillOnPageLoadOptions = [ { name: i18nService.t("autoFillOnPageLoadUseDefault"), value: null }, diff --git a/libs/auth/jest.config.js b/libs/auth/jest.config.js index 3db83db07a..8bc834c7da 100644 --- a/libs/auth/jest.config.js +++ b/libs/auth/jest.config.js @@ -10,7 +10,11 @@ module.exports = { displayName: "libs/auth tests", preset: "jest-preset-angular", setupFilesAfterEnv: ["/test.setup.ts"], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/", - }), + moduleNameMapper: pathsToModuleNameMapper( + // lets us use @bitwarden/common/spec in tests + { "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) }, + { + prefix: "/", + }, + ), }; diff --git a/libs/auth/src/common/abstractions/login-strategy.service.ts b/libs/auth/src/common/abstractions/login-strategy.service.ts index c02337367c..e3ed63c737 100644 --- a/libs/auth/src/common/abstractions/login-strategy.service.ts +++ b/libs/auth/src/common/abstractions/login-strategy.service.ts @@ -1,7 +1,9 @@ import { Observable } from "rxjs"; +import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { MasterKey } from "@bitwarden/common/types/key"; @@ -14,12 +16,45 @@ import { } from "../models/domain/login-credentials"; export abstract class LoginStrategyServiceAbstraction { - masterPasswordHash: string; - email: string; - accessCode: string; - authRequestId: string; - ssoEmail2FaSessionToken: string; + /** + * The current strategy being used to authenticate. + * Emits null if the session has timed out. + */ + currentAuthType$: Observable; + /** + * Emits when an auth request has been approved. + */ + authRequestPushNotification$: Observable; + /** + * If the login strategy uses the email address of the user, this + * will return it. Otherwise, it will return null. + */ + getEmail: () => Promise; + /** + * If the user is logging in with a master password, this will return + * the master password hash. Otherwise, it will return null. + */ + getMasterPasswordHash: () => Promise; + /** + * If the user is logging in with SSO, this will return + * the email auth token. Otherwise, it will return null. + * @see {@link SsoLoginStrategyData.ssoEmail2FaSessionToken} + */ + getSsoEmail2FaSessionToken: () => Promise; + /** + * Returns the access code if the user is logging in with an + * Auth Request. Otherwise, it will return null. + */ + getAccessCode: () => Promise; + /** + * Returns the auth request ID if the user is logging in with an + * Auth Request. Otherwise, it will return null. + */ + getAuthRequestId: () => Promise; + /** + * Sends a token request to the server using the provided credentials. + */ logIn: ( credentials: | UserApiLoginCredentials @@ -28,15 +63,30 @@ export abstract class LoginStrategyServiceAbstraction { | AuthRequestLoginCredentials | WebAuthnLoginCredentials, ) => Promise; + /** + * Sends a token request to the server with the provided two factor token + * and captcha response. This uses data stored from {@link LoginStrategyServiceAbstraction.logIn}, + * so that must be called first. + * Returns an error if no session data is found. + */ logInTwoFactor: ( twoFactor: TokenTwoFactorRequest, captchaResponse: string, ) => Promise; + /** + * Creates a master key from the provided master password and email. + */ makePreloginKey: (masterPassword: string, email: string) => Promise; - authingWithUserApiKey: () => boolean; - authingWithSso: () => boolean; - authingWithPassword: () => boolean; - authingWithPasswordless: () => boolean; - authResponsePushNotification: (notification: AuthRequestPushNotification) => Promise; - getPushNotificationObs$: () => Observable; + /** + * Sends a notification to {@link LoginStrategyServiceAbstraction.authRequestPushNotification} + */ + sendAuthRequestPushNotification: (notification: AuthRequestPushNotification) => Promise; + /** + * Sends a response to an auth request. + */ + passwordlessLogin: ( + id: string, + key: string, + requestApproved: boolean, + ) => Promise; } diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index 607366646a..dd046195aa 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -18,10 +18,15 @@ import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { AuthRequestLoginCredentials } from "../models/domain/login-credentials"; -import { AuthRequestLoginStrategy } from "./auth-request-login.strategy"; +import { + AuthRequestLoginStrategy, + AuthRequestLoginStrategyData, +} from "./auth-request-login.strategy"; import { identityTokenResponseFactory } from "./login.strategy.spec"; describe("AuthRequestLoginStrategy", () => { + let cache: AuthRequestLoginStrategyData; + let cryptoService: MockProxy; let apiService: MockProxy; let tokenService: MockProxy; @@ -65,6 +70,7 @@ describe("AuthRequestLoginStrategy", () => { tokenService.decodeToken.mockResolvedValue({}); authRequestLoginStrategy = new AuthRequestLoginStrategy( + cache, cryptoService, apiService, tokenService, diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index 9da2e15259..acf21219c2 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -1,3 +1,6 @@ +import { Observable, map, BehaviorSubject } from "rxjs"; +import { Jsonify } from "type-fest"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; @@ -14,26 +17,33 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AuthRequestLoginCredentials } from "../models/domain/login-credentials"; +import { CacheData } from "../services/login-strategies/login-strategy.state"; -import { LoginStrategy } from "./login.strategy"; +import { LoginStrategy, LoginStrategyData } from "./login.strategy"; + +export class AuthRequestLoginStrategyData implements LoginStrategyData { + tokenRequest: PasswordTokenRequest; + captchaBypassToken: string; + authRequestCredentials: AuthRequestLoginCredentials; + + static fromJSON(obj: Jsonify): AuthRequestLoginStrategyData { + const data = Object.assign(new AuthRequestLoginStrategyData(), obj, { + tokenRequest: PasswordTokenRequest.fromJSON(obj.tokenRequest), + authRequestCredentials: AuthRequestLoginCredentials.fromJSON(obj.authRequestCredentials), + }); + return data; + } +} export class AuthRequestLoginStrategy extends LoginStrategy { - get email() { - return this.tokenRequest.email; - } + email$: Observable; + accessCode$: Observable; + authRequestId$: Observable; - get accessCode() { - return this.authRequestCredentials.accessCode; - } - - get authRequestId() { - return this.authRequestCredentials.authRequestId; - } - - tokenRequest: PasswordTokenRequest; - private authRequestCredentials: AuthRequestLoginCredentials; + protected cache: BehaviorSubject; constructor( + data: AuthRequestLoginStrategyData, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -56,22 +66,26 @@ export class AuthRequestLoginStrategy extends LoginStrategy { stateService, twoFactorService, ); + + this.cache = new BehaviorSubject(data); + this.email$ = this.cache.pipe(map((data) => data.tokenRequest.email)); + this.accessCode$ = this.cache.pipe(map((data) => data.authRequestCredentials.accessCode)); + this.authRequestId$ = this.cache.pipe(map((data) => data.authRequestCredentials.authRequestId)); } override async logIn(credentials: AuthRequestLoginCredentials) { - // NOTE: To avoid DeadObject references on Firefox, do not set the credentials object directly - // Use deep copy in future if objects are added that were created in popup - this.authRequestCredentials = { ...credentials }; - - this.tokenRequest = new PasswordTokenRequest( + const data = new AuthRequestLoginStrategyData(); + data.tokenRequest = new PasswordTokenRequest( credentials.email, credentials.accessCode, null, await this.buildTwoFactor(credentials.twoFactor), await this.buildDeviceRequest(), ); + data.tokenRequest.setAuthRequestAccessCode(credentials.authRequestId); + data.authRequestCredentials = credentials; + this.cache.next(data); - this.tokenRequest.setAuthRequestAccessCode(credentials.authRequestId); const [authResult] = await this.startLogIn(); return authResult; } @@ -80,27 +94,32 @@ export class AuthRequestLoginStrategy extends LoginStrategy { twoFactor: TokenTwoFactorRequest, captchaResponse: string, ): Promise { - this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken; + const data = this.cache.value; + data.tokenRequest.captchaResponse = captchaResponse ?? data.captchaBypassToken; + this.cache.next(data); + return super.logInTwoFactor(twoFactor); } protected override async setMasterKey(response: IdentityTokenResponse) { + const authRequestCredentials = this.cache.value.authRequestCredentials; if ( - this.authRequestCredentials.decryptedMasterKey && - this.authRequestCredentials.decryptedMasterKeyHash + authRequestCredentials.decryptedMasterKey && + authRequestCredentials.decryptedMasterKeyHash ) { - await this.cryptoService.setMasterKey(this.authRequestCredentials.decryptedMasterKey); - await this.cryptoService.setMasterKeyHash(this.authRequestCredentials.decryptedMasterKeyHash); + await this.cryptoService.setMasterKey(authRequestCredentials.decryptedMasterKey); + await this.cryptoService.setMasterKeyHash(authRequestCredentials.decryptedMasterKeyHash); } } protected override async setUserKey(response: IdentityTokenResponse): Promise { + const authRequestCredentials = this.cache.value.authRequestCredentials; // User now may or may not have a master password // but set the master key encrypted user key if it exists regardless await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); - if (this.authRequestCredentials.decryptedUserKey) { - await this.cryptoService.setUserKey(this.authRequestCredentials.decryptedUserKey); + if (authRequestCredentials.decryptedUserKey) { + await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey); } else { await this.trySetUserKeyWithMasterKey(); // Establish trust if required after setting user key @@ -121,4 +140,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy { response.privateKey ?? (await this.createKeyPairForOldAccount()), ); } + + exportCache(): CacheData { + return { + authRequest: this.cache.value, + }; + } } diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 3639ed096a..5771cb2543 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -40,7 +40,7 @@ import { UserKey, MasterKey, DeviceKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions/login-strategy.service"; import { PasswordLoginCredentials } from "../models/domain/login-credentials"; -import { PasswordLoginStrategy } from "./password-login.strategy"; +import { PasswordLoginStrategy, PasswordLoginStrategyData } from "./password-login.strategy"; const email = "hello@world.com"; const masterPassword = "password"; @@ -94,6 +94,8 @@ export function identityTokenResponseFactory( // TODO: add tests for latest changes to base class for TDE describe("LoginStrategy", () => { + let cache: PasswordLoginStrategyData; + let loginStrategyService: MockProxy; let cryptoService: MockProxy; let apiService: MockProxy; @@ -129,6 +131,7 @@ describe("LoginStrategy", () => { // The base class is abstract so we test it via PasswordLoginStrategy passwordLoginStrategy = new PasswordLoginStrategy( + cache, cryptoService, apiService, tokenService, @@ -377,11 +380,23 @@ describe("LoginStrategy", () => { it("sends 2FA token provided by user to server (two-step)", async () => { // Simulate a partially completed login - passwordLoginStrategy.tokenRequest = new PasswordTokenRequest( - email, - masterPasswordHash, - null, - null, + cache = new PasswordLoginStrategyData(); + cache.tokenRequest = new PasswordTokenRequest(email, masterPasswordHash, null, null); + + passwordLoginStrategy = new PasswordLoginStrategy( + cache, + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService, + passwordStrengthService, + policyService, + loginStrategyService, ); apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 2c8d9c94e4..e6ff1c68a3 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -1,3 +1,5 @@ +import { BehaviorSubject } from "rxjs"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; @@ -36,16 +38,21 @@ import { AuthRequestLoginCredentials, WebAuthnLoginCredentials, } from "../models/domain/login-credentials"; +import { CacheData } from "../services/login-strategies/login-strategy.state"; type IdentityResponse = IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse; -export abstract class LoginStrategy { - protected abstract tokenRequest: +export abstract class LoginStrategyData { + tokenRequest: | UserApiTokenRequest | PasswordTokenRequest | SsoTokenRequest | WebAuthnLoginTokenRequest; - protected captchaBypassToken: string = null; + captchaBypassToken?: string; +} + +export abstract class LoginStrategy { + protected abstract cache: BehaviorSubject; constructor( protected cryptoService: CryptoService, @@ -59,6 +66,8 @@ export abstract class LoginStrategy { protected twoFactorService: TwoFactorService, ) {} + abstract exportCache(): CacheData; + abstract logIn( credentials: | UserApiLoginCredentials @@ -72,7 +81,9 @@ export abstract class LoginStrategy { twoFactor: TokenTwoFactorRequest, captchaResponse: string = null, ): Promise { - this.tokenRequest.setTwoFactor(twoFactor); + const data = this.cache.value; + data.tokenRequest.setTwoFactor(twoFactor); + this.cache.next(data); const [authResult] = await this.startLogIn(); return authResult; } @@ -80,7 +91,8 @@ export abstract class LoginStrategy { protected async startLogIn(): Promise<[AuthResult, IdentityResponse]> { this.twoFactorService.clearSelectedProvider(); - const response = await this.apiService.postIdentityToken(this.tokenRequest); + const tokenRequest = this.cache.value.tokenRequest; + const response = await this.apiService.postIdentityToken(tokenRequest); if (response instanceof IdentityTwoFactorResponse) { return [await this.processTwoFactorResponse(response), response]; @@ -195,9 +207,7 @@ export abstract class LoginStrategy { // The keys comes from different sources depending on the login strategy protected abstract setMasterKey(response: IdentityTokenResponse): Promise; - protected abstract setUserKey(response: IdentityTokenResponse): Promise; - protected abstract setPrivateKey(response: IdentityTokenResponse): Promise; // Old accounts used master key for encryption. We are forcing migrations but only need to @@ -221,7 +231,7 @@ export abstract class LoginStrategy { result.twoFactorProviders = response.twoFactorProviders2; this.twoFactorService.setProviders(response); - this.captchaBypassToken = response.captchaToken ?? null; + this.cache.next({ ...this.cache.value, captchaBypassToken: response.captchaToken ?? null }); result.ssoEmail2FaSessionToken = response.ssoEmail2faSessionToken; result.email = response.email; return result; diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index aa7a530fbb..77ef6792ba 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -29,7 +29,7 @@ import { LoginStrategyServiceAbstraction } from "../abstractions"; import { PasswordLoginCredentials } from "../models/domain/login-credentials"; import { identityTokenResponseFactory } from "./login.strategy.spec"; -import { PasswordLoginStrategy } from "./password-login.strategy"; +import { PasswordLoginStrategy, PasswordLoginStrategyData } from "./password-login.strategy"; const email = "hello@world.com"; const masterPassword = "password"; @@ -47,6 +47,8 @@ const masterPasswordPolicy = new MasterPasswordPolicyResponse({ }); describe("PasswordLoginStrategy", () => { + let cache: PasswordLoginStrategyData; + let loginStrategyService: MockProxy; let cryptoService: MockProxy; let apiService: MockProxy; @@ -93,6 +95,7 @@ describe("PasswordLoginStrategy", () => { policyService.evaluateMasterPassword.mockReturnValue(true); passwordLoginStrategy = new PasswordLoginStrategy( + cache, cryptoService, apiService, tokenService, diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index ddbd47465e..c12eb28204 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -1,3 +1,6 @@ +import { BehaviorSubject, map, Observable } from "rxjs"; +import { Jsonify } from "type-fest"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; @@ -17,35 +20,56 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { HashPurpose } from "@bitwarden/common/platform/enums"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { MasterKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions"; import { PasswordLoginCredentials } from "../models/domain/login-credentials"; +import { CacheData } from "../services/login-strategies/login-strategy.state"; -import { LoginStrategy } from "./login.strategy"; +import { LoginStrategy, LoginStrategyData } from "./login.strategy"; + +export class PasswordLoginStrategyData implements LoginStrategyData { + tokenRequest: PasswordTokenRequest; + captchaBypassToken?: string; + /** + * The local version of the user's master key hash + */ + localMasterKeyHash: string; + /** + * The user's master key + */ + masterKey: MasterKey; + /** + * Tracks if the user needs to update their password due to + * a password that does not meet an organization's master password policy. + */ + forcePasswordResetReason: ForceSetPasswordReason = ForceSetPasswordReason.None; + + static fromJSON(obj: Jsonify): PasswordLoginStrategyData { + const data = Object.assign(new PasswordLoginStrategyData(), obj, { + tokenRequest: PasswordTokenRequest.fromJSON(obj.tokenRequest), + masterKey: SymmetricCryptoKey.fromJSON(obj.masterKey), + }); + return data; + } +} export class PasswordLoginStrategy extends LoginStrategy { - get email() { - return this.tokenRequest.email; - } - - get masterPasswordHash() { - return this.tokenRequest.masterPasswordHash; - } - - tokenRequest: PasswordTokenRequest; - - private localMasterKeyHash: string; - private masterKey: MasterKey; - /** - * Options to track if the user needs to update their password due to a password that does not meet an organization's - * master password policy. + * The email address of the user attempting to log in. */ - private forcePasswordResetReason: ForceSetPasswordReason = ForceSetPasswordReason.None; + email$: Observable; + /** + * The master key hash of the user attempting to log in. + */ + masterKeyHash$: Observable; + + protected cache: BehaviorSubject; constructor( + data: PasswordLoginStrategyData, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -70,42 +94,27 @@ export class PasswordLoginStrategy extends LoginStrategy { stateService, twoFactorService, ); - } - override async logInTwoFactor( - twoFactor: TokenTwoFactorRequest, - captchaResponse: string, - ): Promise { - this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken; - const result = await super.logInTwoFactor(twoFactor); - - // 2FA was successful, save the force update password options with the state service if defined - if ( - !result.requiresTwoFactor && - !result.requiresCaptcha && - this.forcePasswordResetReason != ForceSetPasswordReason.None - ) { - await this.stateService.setForceSetPasswordReason(this.forcePasswordResetReason); - result.forcePasswordReset = this.forcePasswordResetReason; - } - - return result; + this.cache = new BehaviorSubject(data); + this.email$ = this.cache.pipe(map((state) => state.tokenRequest.email)); + this.masterKeyHash$ = this.cache.pipe(map((state) => state.localMasterKeyHash)); } override async logIn(credentials: PasswordLoginCredentials) { const { email, masterPassword, captchaToken, twoFactor } = credentials; - this.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email); + const data = new PasswordLoginStrategyData(); + data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email); // Hash the password early (before authentication) so we don't persist it in memory in plaintext - this.localMasterKeyHash = await this.cryptoService.hashMasterKey( + data.localMasterKeyHash = await this.cryptoService.hashMasterKey( masterPassword, - this.masterKey, + data.masterKey, HashPurpose.LocalAuthorization, ); - const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, this.masterKey); + const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, data.masterKey); - this.tokenRequest = new PasswordTokenRequest( + data.tokenRequest = new PasswordTokenRequest( email, masterKeyHash, captchaToken, @@ -113,6 +122,8 @@ export class PasswordLoginStrategy extends LoginStrategy { await this.buildDeviceRequest(), ); + this.cache.next(data); + const [authResult, identityResponse] = await this.startLogIn(); const masterPasswordPolicyOptions = @@ -129,7 +140,10 @@ export class PasswordLoginStrategy extends LoginStrategy { if (!meetsRequirements) { if (authResult.requiresCaptcha || authResult.requiresTwoFactor) { // Save the flag to this strategy for later use as the master password is about to pass out of scope - this.forcePasswordResetReason = ForceSetPasswordReason.WeakMasterPassword; + this.cache.next({ + ...this.cache.value, + forcePasswordResetReason: ForceSetPasswordReason.WeakMasterPassword, + }); } else { // Authentication was successful, save the force update password options with the state service await this.stateService.setForceSetPasswordReason( @@ -142,9 +156,34 @@ export class PasswordLoginStrategy extends LoginStrategy { return authResult; } + override async logInTwoFactor( + twoFactor: TokenTwoFactorRequest, + captchaResponse: string, + ): Promise { + this.cache.next({ + ...this.cache.value, + captchaBypassToken: captchaResponse ?? this.cache.value.captchaBypassToken, + }); + const result = await super.logInTwoFactor(twoFactor); + + // 2FA was successful, save the force update password options with the state service if defined + const forcePasswordResetReason = this.cache.value.forcePasswordResetReason; + if ( + !result.requiresTwoFactor && + !result.requiresCaptcha && + forcePasswordResetReason != ForceSetPasswordReason.None + ) { + await this.stateService.setForceSetPasswordReason(forcePasswordResetReason); + result.forcePasswordReset = forcePasswordResetReason; + } + + return result; + } + protected override async setMasterKey(response: IdentityTokenResponse) { - await this.cryptoService.setMasterKey(this.masterKey); - await this.cryptoService.setMasterKeyHash(this.localMasterKeyHash); + const { masterKey, localMasterKeyHash } = this.cache.value; + await this.cryptoService.setMasterKey(masterKey); + await this.cryptoService.setMasterKeyHash(localMasterKeyHash); } protected override async setUserKey(response: IdentityTokenResponse): Promise { @@ -191,4 +230,10 @@ export class PasswordLoginStrategy extends LoginStrategy { return this.policyService.evaluateMasterPassword(passwordStrength, masterPassword, options); } + + exportCache(): CacheData { + return { + password: this.cache.value, + }; + } } diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index 99e1445179..b6cf6db58a 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -5,8 +5,11 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable"; +import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -25,9 +28,6 @@ import { SsoLoginCredentials } from "../models/domain/login-credentials"; import { identityTokenResponseFactory } from "./login.strategy.spec"; import { SsoLoginStrategy } from "./sso-login.strategy"; -// TODO: Add tests for new trySetUserKeyWithApprovedAdminRequestIfExists logic -// https://bitwarden.atlassian.net/browse/PM-3339 - describe("SsoLoginStrategy", () => { let cryptoService: MockProxy; let apiService: MockProxy; @@ -74,6 +74,7 @@ describe("SsoLoginStrategy", () => { tokenService.decodeToken.mockResolvedValue({}); ssoLoginStrategy = new SsoLoginStrategy( + null, cryptoService, apiService, tokenService, @@ -258,6 +259,114 @@ describe("SsoLoginStrategy", () => { // Assert expect(cryptoService.setUserKey).not.toHaveBeenCalled(); }); + + describe("AdminAuthRequest", () => { + let tokenResponse: IdentityTokenResponse; + + beforeEach(() => { + tokenResponse = identityTokenResponseFactory(null, { + HasMasterPassword: true, + TrustedDeviceOption: { + HasAdminApproval: true, + HasLoginApprovingDevice: false, + HasManageResetPasswordPermission: false, + EncryptedPrivateKey: mockEncDevicePrivateKey, + EncryptedUserKey: mockEncUserKey, + }, + }); + + const adminAuthRequest = { + id: "1", + privateKey: "PRIVATE" as any, + } as AdminAuthRequestStorable; + stateService.getAdminAuthRequest.mockResolvedValue( + new AdminAuthRequestStorable(adminAuthRequest), + ); + }); + + it("sets the user key using master key and hash from approved admin request if exists", async () => { + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + cryptoService.hasUserKey.mockResolvedValue(true); + const adminAuthResponse = { + id: "1", + publicKey: "PRIVATE" as any, + key: "KEY" as any, + masterPasswordHash: "HASH" as any, + requestApproved: true, + }; + apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse); + + await ssoLoginStrategy.logIn(credentials); + + expect(authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash).toHaveBeenCalled(); + expect(deviceTrustCryptoService.decryptUserKeyWithDeviceKey).not.toHaveBeenCalled(); + }); + + it("sets the user key from approved admin request if exists", async () => { + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + cryptoService.hasUserKey.mockResolvedValue(true); + const adminAuthResponse = { + id: "1", + publicKey: "PRIVATE" as any, + key: "KEY" as any, + requestApproved: true, + }; + apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse); + + await ssoLoginStrategy.logIn(credentials); + + expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).toHaveBeenCalled(); + expect(deviceTrustCryptoService.decryptUserKeyWithDeviceKey).not.toHaveBeenCalled(); + }); + + it("attempts to establish a trusted device if successful", async () => { + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + cryptoService.hasUserKey.mockResolvedValue(true); + const adminAuthResponse = { + id: "1", + publicKey: "PRIVATE" as any, + key: "KEY" as any, + requestApproved: true, + }; + apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse); + + await ssoLoginStrategy.logIn(credentials); + + expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).toHaveBeenCalled(); + expect(deviceTrustCryptoService.trustDeviceIfRequired).toHaveBeenCalled(); + }); + + it("clears the admin auth request if server returns a 404, meaning it was deleted", async () => { + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + apiService.getAuthRequest.mockRejectedValue(new ErrorResponse(null, 404)); + + await ssoLoginStrategy.logIn(credentials); + + expect(stateService.setAdminAuthRequest).toHaveBeenCalledWith(null); + expect( + authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash, + ).not.toHaveBeenCalled(); + expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).not.toHaveBeenCalled(); + expect(deviceTrustCryptoService.trustDeviceIfRequired).not.toHaveBeenCalled(); + }); + + it("attempts to login with a trusted device if admin auth request isn't successful", async () => { + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + const adminAuthResponse = { + id: "1", + publicKey: "PRIVATE" as any, + key: "KEY" as any, + requestApproved: true, + }; + apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse); + cryptoService.hasUserKey.mockResolvedValue(false); + deviceTrustCryptoService.getDeviceKey.mockResolvedValue("DEVICE_KEY" as any); + + await ssoLoginStrategy.logIn(credentials); + + expect(deviceTrustCryptoService.decryptUserKeyWithDeviceKey).toHaveBeenCalled(); + }); + }); }); describe("Key Connector", () => { diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 4fef55e37f..a5ef922204 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -1,3 +1,6 @@ +import { Observable, map, BehaviorSubject } from "rxjs"; +import { Jsonify } from "type-fest"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; @@ -19,20 +22,54 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { AuthRequestServiceAbstraction } from "../abstractions"; import { SsoLoginCredentials } from "../models/domain/login-credentials"; +import { CacheData } from "../services/login-strategies/login-strategy.state"; -import { LoginStrategy } from "./login.strategy"; +import { LoginStrategyData, LoginStrategy } from "./login.strategy"; + +export class SsoLoginStrategyData implements LoginStrategyData { + captchaBypassToken: string; + tokenRequest: SsoTokenRequest; + /** + * User email address. Only available after authentication. + */ + email?: string; + /** + * The organization ID that the user is logging into. Used for Key Connector + * purposes after authentication. + */ + orgId: string; + /** + * A token provided by the server as an authentication factor for sending + * email OTPs to the user's configured 2FA email address. This is required + * as we don't have a master password hash or other verifiable secret when using SSO. + */ + ssoEmail2FaSessionToken?: string; + + static fromJSON(obj: Jsonify): SsoLoginStrategyData { + return Object.assign(new SsoLoginStrategyData(), obj, { + tokenRequest: SsoTokenRequest.fromJSON(obj.tokenRequest), + }); + } +} export class SsoLoginStrategy extends LoginStrategy { - tokenRequest: SsoTokenRequest; - orgId: string; + /** + * @see {@link SsoLoginStrategyData.email} + */ + email$: Observable; + /** + * @see {@link SsoLoginStrategyData.orgId} + */ + orgId$: Observable; + /** + * @see {@link SsoLoginStrategyData.ssoEmail2FaSessionToken} + */ + ssoEmail2FaSessionToken$: Observable; - // A session token server side to serve as an authentication factor for the user - // in order to send email OTPs to the user's configured 2FA email address - // as we don't have a master password hash or other verifiable secret when using SSO. - ssoEmail2FaSessionToken?: string; - email?: string; // email not preserved through SSO process so get from server + protected cache: BehaviorSubject; constructor( + data: SsoLoginStrategyData, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -58,11 +95,17 @@ export class SsoLoginStrategy extends LoginStrategy { stateService, twoFactorService, ); + + this.cache = new BehaviorSubject(data); + this.email$ = this.cache.pipe(map((state) => state.email)); + this.orgId$ = this.cache.pipe(map((state) => state.orgId)); + this.ssoEmail2FaSessionToken$ = this.cache.pipe(map((state) => state.ssoEmail2FaSessionToken)); } async logIn(credentials: SsoLoginCredentials) { - this.orgId = credentials.orgId; - this.tokenRequest = new SsoTokenRequest( + const data = new SsoLoginStrategyData(); + data.orgId = credentials.orgId; + data.tokenRequest = new SsoTokenRequest( credentials.code, credentials.codeVerifier, credentials.redirectUrl, @@ -70,16 +113,24 @@ export class SsoLoginStrategy extends LoginStrategy { await this.buildDeviceRequest(), ); + this.cache.next(data); + const [ssoAuthResult] = await this.startLogIn(); - this.email = ssoAuthResult.email; - this.ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken; + const email = ssoAuthResult.email; + const ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken; // Auth guard currently handles redirects for this. if (ssoAuthResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) { await this.stateService.setForceSetPasswordReason(ssoAuthResult.forcePasswordReset); } + this.cache.next({ + ...this.cache.value, + email, + ssoEmail2FaSessionToken, + }); + return ssoAuthResult; } @@ -92,7 +143,10 @@ export class SsoLoginStrategy extends LoginStrategy { // The presence of a masterKeyEncryptedUserKey indicates that the user has already been provisioned in Key Connector. const newSsoUser = tokenResponse.key == null; if (newSsoUser) { - await this.keyConnectorService.convertNewSsoUserToKeyConnector(tokenResponse, this.orgId); + await this.keyConnectorService.convertNewSsoUserToKeyConnector( + tokenResponse, + this.cache.value.orgId, + ); } else { const keyConnectorUrl = this.getKeyConnectorUrl(tokenResponse); await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl); @@ -272,4 +326,10 @@ export class SsoLoginStrategy extends LoginStrategy { ); } } + + exportCache(): CacheData { + return { + sso: this.cache.value, + }; + } } diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index eb240a6a02..d50d2883c7 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -19,9 +19,11 @@ import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { UserApiLoginCredentials } from "../models/domain/login-credentials"; import { identityTokenResponseFactory } from "./login.strategy.spec"; -import { UserApiLoginStrategy } from "./user-api-login.strategy"; +import { UserApiLoginStrategy, UserApiLoginStrategyData } from "./user-api-login.strategy"; describe("UserApiLoginStrategy", () => { + let cache: UserApiLoginStrategyData; + let cryptoService: MockProxy; let apiService: MockProxy; let tokenService: MockProxy; @@ -60,6 +62,7 @@ describe("UserApiLoginStrategy", () => { tokenService.decodeToken.mockResolvedValue({}); apiLogInStrategy = new UserApiLoginStrategy( + cache, cryptoService, apiService, tokenService, diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index a9fbf045b1..a26fb41ae9 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -1,3 +1,6 @@ +import { BehaviorSubject } from "rxjs"; +import { Jsonify } from "type-fest"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; @@ -13,13 +16,26 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { UserApiLoginCredentials } from "../models/domain/login-credentials"; +import { CacheData } from "../services/login-strategies/login-strategy.state"; -import { LoginStrategy } from "./login.strategy"; +import { LoginStrategy, LoginStrategyData } from "./login.strategy"; + +export class UserApiLoginStrategyData implements LoginStrategyData { + tokenRequest: UserApiTokenRequest; + captchaBypassToken: string; + + static fromJSON(obj: Jsonify): UserApiLoginStrategyData { + return Object.assign(new UserApiLoginStrategyData(), obj, { + tokenRequest: UserApiTokenRequest.fromJSON(obj.tokenRequest), + }); + } +} export class UserApiLoginStrategy extends LoginStrategy { - tokenRequest: UserApiTokenRequest; + protected cache: BehaviorSubject; constructor( + data: UserApiLoginStrategyData, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -43,15 +59,18 @@ export class UserApiLoginStrategy extends LoginStrategy { stateService, twoFactorService, ); + this.cache = new BehaviorSubject(data); } override async logIn(credentials: UserApiLoginCredentials) { - this.tokenRequest = new UserApiTokenRequest( + const data = new UserApiLoginStrategyData(); + data.tokenRequest = new UserApiTokenRequest( credentials.clientId, credentials.clientSecret, await this.buildTwoFactor(), await this.buildDeviceRequest(), ); + this.cache.next(data); const [authResult] = await this.startLogIn(); return authResult; @@ -84,7 +103,15 @@ export class UserApiLoginStrategy extends LoginStrategy { protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { await super.saveAccountInformation(tokenResponse); - await this.stateService.setApiKeyClientId(this.tokenRequest.clientId); - await this.stateService.setApiKeyClientSecret(this.tokenRequest.clientSecret); + + const tokenRequest = this.cache.value.tokenRequest; + await this.stateService.setApiKeyClientId(tokenRequest.clientId); + await this.stateService.setApiKeyClientSecret(tokenRequest.clientSecret); + } + + exportCache(): CacheData { + return { + userApiKey: this.cache.value, + }; } } diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index cbe7084ccc..17933a3dcb 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -20,9 +20,11 @@ import { PrfKey, UserKey } from "@bitwarden/common/types/key"; import { WebAuthnLoginCredentials } from "../models/domain/login-credentials"; import { identityTokenResponseFactory } from "./login.strategy.spec"; -import { WebAuthnLoginStrategy } from "./webauthn-login.strategy"; +import { WebAuthnLoginStrategy, WebAuthnLoginStrategyData } from "./webauthn-login.strategy"; describe("WebAuthnLoginStrategy", () => { + let cache: WebAuthnLoginStrategyData; + let cryptoService!: MockProxy; let apiService!: MockProxy; let tokenService!: MockProxy; @@ -72,6 +74,7 @@ describe("WebAuthnLoginStrategy", () => { tokenService.decodeToken.mockResolvedValue({}); webAuthnLoginStrategy = new WebAuthnLoginStrategy( + cache, cryptoService, apiService, tokenService, @@ -286,7 +289,7 @@ function randomBytes(length: number): Uint8Array { // AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts // so we need to mock them and assign them to the global object to make them available // for the tests -class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse { +export class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse { clientDataJSON: ArrayBuffer = randomBytes(32).buffer; authenticatorData: ArrayBuffer = randomBytes(196).buffer; signature: ArrayBuffer = randomBytes(72).buffer; @@ -298,7 +301,7 @@ class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionRespon userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle); } -class MockPublicKeyCredential implements PublicKeyCredential { +export class MockPublicKeyCredential implements PublicKeyCredential { authenticatorAttachment = "cross-platform"; id = "mockCredentialId"; type = "public-key"; diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index c3fbf28b8e..c42e6d6574 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -1,16 +1,86 @@ +import { BehaviorSubject } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserKey } from "@bitwarden/common/types/key"; import { WebAuthnLoginCredentials } from "../models/domain/login-credentials"; +import { CacheData } from "../services/login-strategies/login-strategy.state"; -import { LoginStrategy } from "./login.strategy"; +import { LoginStrategy, LoginStrategyData } from "./login.strategy"; + +export class WebAuthnLoginStrategyData implements LoginStrategyData { + tokenRequest: WebAuthnLoginTokenRequest; + captchaBypassToken?: string; + credentials: WebAuthnLoginCredentials; + + static fromJSON(obj: Jsonify): WebAuthnLoginStrategyData { + return Object.assign(new WebAuthnLoginStrategyData(), obj, { + tokenRequest: WebAuthnLoginTokenRequest.fromJSON(obj.tokenRequest), + credentials: WebAuthnLoginCredentials.fromJSON(obj.credentials), + }); + } +} export class WebAuthnLoginStrategy extends LoginStrategy { - tokenRequest: WebAuthnLoginTokenRequest; - private credentials: WebAuthnLoginCredentials; + protected cache: BehaviorSubject; + + constructor( + data: WebAuthnLoginStrategyData, + cryptoService: CryptoService, + apiService: ApiService, + tokenService: TokenService, + appIdService: AppIdService, + platformUtilsService: PlatformUtilsService, + messagingService: MessagingService, + logService: LogService, + stateService: StateService, + twoFactorService: TwoFactorService, + ) { + super( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService, + ); + + this.cache = new BehaviorSubject(data); + } + + async logIn(credentials: WebAuthnLoginCredentials) { + const data = new WebAuthnLoginStrategyData(); + data.credentials = credentials; + data.tokenRequest = new WebAuthnLoginTokenRequest( + credentials.token, + credentials.deviceResponse, + await this.buildDeviceRequest(), + ); + this.cache.next(data); + + const [authResult] = await this.startLogIn(); + return authResult; + } + + async logInTwoFactor(): Promise { + throw new Error("2FA not supported yet for WebAuthn Login."); + } protected override async setMasterKey() { return Promise.resolve(); @@ -29,15 +99,16 @@ export class WebAuthnLoginStrategy extends LoginStrategy { if (userDecryptionOptions?.webAuthnPrfOption) { const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption; + const credentials = this.cache.value.credentials; // confirm we still have the prf key - if (!this.credentials.prfKey) { + if (!credentials.prfKey) { return; } // decrypt prf encrypted private key const privateKey = await this.cryptoService.decryptToBytes( webAuthnPrfOption.encryptedPrivateKey, - this.credentials.prfKey, + credentials.prfKey, ); // decrypt user key with private key @@ -58,22 +129,9 @@ export class WebAuthnLoginStrategy extends LoginStrategy { ); } - async logInTwoFactor(): Promise { - throw new Error("2FA not supported yet for WebAuthn Login."); - } - - async logIn(credentials: WebAuthnLoginCredentials) { - // NOTE: To avoid DeadObject references on Firefox, do not set the credentials object directly - // Use deep copy in future if objects are added that were created in popup - this.credentials = { ...credentials }; - - this.tokenRequest = new WebAuthnLoginTokenRequest( - credentials.token, - credentials.deviceResponse, - await this.buildDeviceRequest(), - ); - - const [authResult] = await this.startLogIn(); - return authResult; + exportCache(): CacheData { + return { + webAuthn: this.cache.value, + }; } } diff --git a/libs/auth/src/common/models/domain/login-credentials.ts b/libs/auth/src/common/models/domain/login-credentials.ts index 93cb07e2b1..a56d8e0097 100644 --- a/libs/auth/src/common/models/domain/login-credentials.ts +++ b/libs/auth/src/common/models/domain/login-credentials.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; @@ -28,7 +30,7 @@ export class SsoLoginCredentials { } export class UserApiLoginCredentials { - readonly type = AuthenticationType.UserApi; + readonly type = AuthenticationType.UserApiKey; constructor( public clientId: string, @@ -48,6 +50,30 @@ export class AuthRequestLoginCredentials { public decryptedMasterKeyHash: string, public twoFactor?: TokenTwoFactorRequest, ) {} + + static fromJSON(json: Jsonify) { + return Object.assign( + new AuthRequestLoginCredentials( + json.email, + json.accessCode, + json.authRequestId, + null, + null, + json.decryptedMasterKeyHash, + json.twoFactor + ? new TokenTwoFactorRequest( + json.twoFactor.provider, + json.twoFactor.token, + json.twoFactor.remember, + ) + : json.twoFactor, + ), + { + decryptedUserKey: SymmetricCryptoKey.fromJSON(json.decryptedUserKey) as UserKey, + decryptedMasterKey: SymmetricCryptoKey.fromJSON(json.decryptedMasterKey) as MasterKey, + }, + ); + } } export class WebAuthnLoginCredentials { @@ -58,4 +84,15 @@ export class WebAuthnLoginCredentials { public deviceResponse: WebAuthnLoginAssertionResponseRequest, public prfKey?: SymmetricCryptoKey, ) {} + + static fromJSON(json: Jsonify) { + return new WebAuthnLoginCredentials( + json.token, + Object.assign( + Object.create(WebAuthnLoginAssertionResponseRequest.prototype), + json.deviceResponse, + ), + SymmetricCryptoKey.fromJSON(json.prfKey), + ); + } } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts new file mode 100644 index 0000000000..21509eb83c --- /dev/null +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts @@ -0,0 +1,201 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; +import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { KdfType } from "@bitwarden/common/platform/enums"; +import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/common/spec"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; + +import { AuthRequestServiceAbstraction } from "../../abstractions"; +import { PasswordLoginCredentials } from "../../models"; + +import { LoginStrategyService } from "./login-strategy.service"; +import { CACHE_EXPIRATION_KEY } from "./login-strategy.state"; + +describe("LoginStrategyService", () => { + let sut: LoginStrategyService; + + let cryptoService: MockProxy; + let apiService: MockProxy; + let tokenService: MockProxy; + let appIdService: MockProxy; + let platformUtilsService: MockProxy; + let messagingService: MockProxy; + let logService: MockProxy; + let keyConnectorService: MockProxy; + let environmentService: MockProxy; + let stateService: MockProxy; + let twoFactorService: MockProxy; + let i18nService: MockProxy; + let encryptService: MockProxy; + let passwordStrengthService: MockProxy; + let policyService: MockProxy; + let deviceTrustCryptoService: MockProxy; + let authRequestService: MockProxy; + + let stateProvider: FakeGlobalStateProvider; + let loginStrategyCacheExpirationState: FakeGlobalState; + + beforeEach(() => { + cryptoService = mock(); + apiService = mock(); + tokenService = mock(); + appIdService = mock(); + platformUtilsService = mock(); + messagingService = mock(); + logService = mock(); + keyConnectorService = mock(); + environmentService = mock(); + stateService = mock(); + twoFactorService = mock(); + i18nService = mock(); + encryptService = mock(); + passwordStrengthService = mock(); + policyService = mock(); + deviceTrustCryptoService = mock(); + authRequestService = mock(); + stateProvider = new FakeGlobalStateProvider(); + + sut = new LoginStrategyService( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + keyConnectorService, + environmentService, + stateService, + twoFactorService, + i18nService, + encryptService, + passwordStrengthService, + policyService, + deviceTrustCryptoService, + authRequestService, + stateProvider, + ); + + loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY); + }); + + it("should return an AuthResult on successful login", async () => { + const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD"); + apiService.postIdentityToken.mockResolvedValue( + new IdentityTokenResponse({ + ForcePasswordReset: false, + Kdf: KdfType.Argon2id, + Key: "KEY", + PrivateKey: "PRIVATE_KEY", + ResetMasterPassword: false, + access_token: "ACCESS_TOKEN", + expires_in: 3600, + refresh_token: "REFRESH_TOKEN", + scope: "api offline_access", + token_type: "Bearer", + }), + ); + tokenService.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ + sub: "USER_ID", + name: "NAME", + email: "EMAIL", + premium: false, + }); + + const result = await sut.logIn(credentials); + + expect(result).toBeInstanceOf(AuthResult); + }); + + it("should return an AuthResult on successful 2fa login", async () => { + const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD"); + apiService.postIdentityToken.mockResolvedValueOnce( + new IdentityTwoFactorResponse({ + TwoFactorProviders: ["0"], + TwoFactorProviders2: { 0: null }, + error: "invalid_grant", + error_description: "Two factor required.", + email: undefined, + ssoEmail2faSessionToken: undefined, + }), + ); + + await sut.logIn(credentials); + + const twoFactorToken = new TokenTwoFactorRequest( + TwoFactorProviderType.Authenticator, + "TWO_FACTOR_TOKEN", + true, + ); + apiService.postIdentityToken.mockResolvedValue( + new IdentityTokenResponse({ + ForcePasswordReset: false, + Kdf: KdfType.Argon2id, + Key: "KEY", + PrivateKey: "PRIVATE_KEY", + ResetMasterPassword: false, + access_token: "ACCESS_TOKEN", + expires_in: 3600, + refresh_token: "REFRESH_TOKEN", + scope: "api offline_access", + token_type: "Bearer", + }), + ); + + tokenService.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ + sub: "USER_ID", + name: "NAME", + email: "EMAIL", + premium: false, + }); + + const result = await sut.logInTwoFactor(twoFactorToken, "CAPTCHA"); + + expect(result).toBeInstanceOf(AuthResult); + }); + + it("should clear the cache if more than 2 mins have passed since expiration date", async () => { + const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD"); + apiService.postIdentityToken.mockResolvedValue( + new IdentityTwoFactorResponse({ + TwoFactorProviders: ["0"], + TwoFactorProviders2: { 0: null }, + error: "invalid_grant", + error_description: "Two factor required.", + email: undefined, + ssoEmail2faSessionToken: undefined, + }), + ); + + await sut.logIn(credentials); + + loginStrategyCacheExpirationState.stateSubject.next(new Date(Date.now() - 1000 * 60 * 5)); + + const twoFactorToken = new TokenTwoFactorRequest( + TwoFactorProviderType.Authenticator, + "TWO_FACTOR_TOKEN", + true, + ); + + await expect(sut.logInTwoFactor(twoFactorToken, "CAPTCHA")).rejects.toThrow(); + }); +}); diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index f8a51118af..7ef8432aa5 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -1,4 +1,12 @@ -import { Observable, Subject } from "rxjs"; +import { + combineLatestWith, + distinctUntilChanged, + filter, + firstValueFrom, + map, + Observable, + shareReplay, +} from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -10,6 +18,8 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication- import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request"; +import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { PreloginRequest } from "@bitwarden/common/models/request/prelogin.request"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; @@ -23,6 +33,8 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { KdfType } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { MasterKey } from "@bitwarden/common/types/key"; @@ -40,54 +52,35 @@ import { WebAuthnLoginCredentials, } from "../../models"; +import { + AUTH_REQUEST_PUSH_NOTIFICATION_KEY, + CURRENT_LOGIN_STRATEGY_KEY, + CacheData, + CACHE_EXPIRATION_KEY, + CACHE_KEY, +} from "./login-strategy.state"; + const sessionTimeoutLength = 2 * 60 * 1000; // 2 minutes export class LoginStrategyService implements LoginStrategyServiceAbstraction { - get email(): string { - if ( - this.logInStrategy instanceof PasswordLoginStrategy || - this.logInStrategy instanceof AuthRequestLoginStrategy || - this.logInStrategy instanceof SsoLoginStrategy - ) { - return this.logInStrategy.email; - } + private sessionTimeout: unknown; + private currentAuthnTypeState: GlobalState; + private loginStrategyCacheState: GlobalState; + private loginStrategyCacheExpirationState: GlobalState; + private authRequestPushNotificationState: GlobalState; - return null; - } - - get masterPasswordHash(): string { - return this.logInStrategy instanceof PasswordLoginStrategy - ? this.logInStrategy.masterPasswordHash - : null; - } - - get accessCode(): string { - return this.logInStrategy instanceof AuthRequestLoginStrategy - ? this.logInStrategy.accessCode - : null; - } - - get authRequestId(): string { - return this.logInStrategy instanceof AuthRequestLoginStrategy - ? this.logInStrategy.authRequestId - : null; - } - - get ssoEmail2FaSessionToken(): string { - return this.logInStrategy instanceof SsoLoginStrategy - ? this.logInStrategy.ssoEmail2FaSessionToken - : null; - } - - private logInStrategy: + private loginStrategy$: Observable< | UserApiLoginStrategy | PasswordLoginStrategy | SsoLoginStrategy | AuthRequestLoginStrategy - | WebAuthnLoginStrategy; - private sessionTimeout: any; + | WebAuthnLoginStrategy + | null + >; - private pushNotificationSubject = new Subject(); + currentAuthType$: Observable; + // TODO: move to auth request service + authRequestPushNotification$: Observable; constructor( protected cryptoService: CryptoService, @@ -107,7 +100,71 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { protected policyService: PolicyService, protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, protected authRequestService: AuthRequestServiceAbstraction, - ) {} + protected stateProvider: GlobalStateProvider, + ) { + this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY); + this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY); + this.loginStrategyCacheExpirationState = this.stateProvider.get(CACHE_EXPIRATION_KEY); + this.authRequestPushNotificationState = this.stateProvider.get( + AUTH_REQUEST_PUSH_NOTIFICATION_KEY, + ); + + this.currentAuthType$ = this.currentAuthnTypeState.state$; + this.authRequestPushNotification$ = this.authRequestPushNotificationState.state$.pipe( + filter((id) => id != null), + ); + this.loginStrategy$ = this.currentAuthnTypeState.state$.pipe( + distinctUntilChanged(), + combineLatestWith(this.loginStrategyCacheState.state$), + this.initializeLoginStrategy.bind(this), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + } + + async getEmail(): Promise { + const strategy = await firstValueFrom(this.loginStrategy$); + + if ("email$" in strategy) { + return await firstValueFrom(strategy.email$); + } + return null; + } + + async getMasterPasswordHash(): Promise { + const strategy = await firstValueFrom(this.loginStrategy$); + + if ("masterKeyHash$" in strategy) { + return await firstValueFrom(strategy.masterKeyHash$); + } + return null; + } + + async getSsoEmail2FaSessionToken(): Promise { + const strategy = await firstValueFrom(this.loginStrategy$); + + if ("ssoEmail2FaSessionToken$" in strategy) { + return await firstValueFrom(strategy.ssoEmail2FaSessionToken$); + } + return null; + } + + async getAccessCode(): Promise { + const strategy = await firstValueFrom(this.loginStrategy$); + + if ("accessCode$" in strategy) { + return await firstValueFrom(strategy.accessCode$); + } + return null; + } + + async getAuthRequestId(): Promise { + const strategy = await firstValueFrom(this.loginStrategy$); + + if ("authRequestId$" in strategy) { + return await firstValueFrom(strategy.authRequestId$); + } + return null; + } async logIn( credentials: @@ -117,99 +174,27 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { | AuthRequestLoginCredentials | WebAuthnLoginCredentials, ): Promise { - this.clearState(); + await this.clearCache(); - let strategy: - | UserApiLoginStrategy - | PasswordLoginStrategy - | SsoLoginStrategy - | AuthRequestLoginStrategy - | WebAuthnLoginStrategy; + await this.currentAuthnTypeState.update((_) => credentials.type); - switch (credentials.type) { - case AuthenticationType.Password: - strategy = new PasswordLoginStrategy( - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.passwordStrengthService, - this.policyService, - this, - ); - break; - case AuthenticationType.Sso: - strategy = new SsoLoginStrategy( - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.keyConnectorService, - this.deviceTrustCryptoService, - this.authRequestService, - this.i18nService, - ); - break; - case AuthenticationType.UserApi: - strategy = new UserApiLoginStrategy( - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.environmentService, - this.keyConnectorService, - ); - break; - case AuthenticationType.AuthRequest: - strategy = new AuthRequestLoginStrategy( - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.deviceTrustCryptoService, - ); - break; - case AuthenticationType.WebAuthn: - strategy = new WebAuthnLoginStrategy( - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - ); - break; - } + const strategy = await firstValueFrom(this.loginStrategy$); - // Note: Do not set the credentials object directly on the strategy. They are + // Note: We aren't passing the credentials directly to the strategy since they are // created in the popup and can cause DeadObject references on Firefox. - const result = await strategy.logIn(credentials as any); + // This is a shallow copy, but use deep copy in future if objects are added to credentials + // that were created in popup. + // If the popup uses its own instance of this service, this can be removed. + const ownedCredentials = { ...credentials }; - if (result?.requiresTwoFactor) { - this.saveState(strategy); + const result = await strategy.logIn(ownedCredentials as any); + + if (result != null && !result.requiresTwoFactor) { + await this.clearCache(); + } else { + // Cache the strategy data so we can attempt again later with 2fa. Cache supports different contexts + await this.loginStrategyCacheState.update((_) => strategy.exportCache()); + await this.startSessionTimeout(); } return result; @@ -219,43 +204,32 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { twoFactor: TokenTwoFactorRequest, captchaResponse: string, ): Promise { - if (this.logInStrategy == null) { + if (!(await this.isSessionValid())) { throw new Error(this.i18nService.t("sessionTimeout")); } - try { - const result = await this.logInStrategy.logInTwoFactor(twoFactor, captchaResponse); + const strategy = await firstValueFrom(this.loginStrategy$); + if (strategy == null) { + throw new Error("No login strategy found."); + } - // Only clear state if 2FA token has been accepted, otherwise we need to be able to try again - if (!result.requiresTwoFactor && !result.requiresCaptcha) { - this.clearState(); + try { + const result = await strategy.logInTwoFactor(twoFactor, captchaResponse); + + // Only clear cache if 2FA token has been accepted, otherwise we need to be able to try again + if (result != null && !result.requiresTwoFactor && !result.requiresCaptcha) { + await this.clearCache(); } return result; } catch (e) { - // API exceptions are okay, but if there are any unhandled client-side errors then clear state to be safe + // API exceptions are okay, but if there are any unhandled client-side errors then clear cache to be safe if (!(e instanceof ErrorResponse)) { - this.clearState(); + await this.clearCache(); } throw e; } } - authingWithUserApiKey(): boolean { - return this.logInStrategy instanceof UserApiLoginStrategy; - } - - authingWithSso(): boolean { - return this.logInStrategy instanceof SsoLoginStrategy; - } - - authingWithPassword(): boolean { - return this.logInStrategy instanceof PasswordLoginStrategy; - } - - authingWithPasswordless(): boolean { - return this.logInStrategy instanceof AuthRequestLoginStrategy; - } - async makePreloginKey(masterPassword: string, email: string): Promise { email = email.trim().toLowerCase(); let kdf: KdfType = null; @@ -278,39 +252,171 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { return await this.cryptoService.makeMasterKey(masterPassword, email, kdf, kdfConfig); } - async authResponsePushNotification(notification: AuthRequestPushNotification): Promise { - this.pushNotificationSubject.next(notification.id); - } - - getPushNotificationObs$(): Observable { - return this.pushNotificationSubject.asObservable(); - } - - private saveState( - strategy: - | UserApiLoginStrategy - | PasswordLoginStrategy - | SsoLoginStrategy - | AuthRequestLoginStrategy - | WebAuthnLoginStrategy, - ) { - this.logInStrategy = strategy; - this.startSessionTimeout(); - } - - private clearState() { - this.logInStrategy = null; - this.clearSessionTimeout(); - } - - private startSessionTimeout() { - this.clearSessionTimeout(); - this.sessionTimeout = setTimeout(() => this.clearState(), sessionTimeoutLength); - } - - private clearSessionTimeout() { - if (this.sessionTimeout != null) { - clearTimeout(this.sessionTimeout); + // TODO move to auth request service + async sendAuthRequestPushNotification(notification: AuthRequestPushNotification): Promise { + if (notification.id != null) { + await this.authRequestPushNotificationState.update((_) => notification.id); } } + + // TODO: move to auth request service + async passwordlessLogin( + id: string, + key: string, + requestApproved: boolean, + ): Promise { + const pubKey = Utils.fromB64ToArray(key); + + const masterKey = await this.cryptoService.getMasterKey(); + let keyToEncrypt; + let encryptedMasterKeyHash = null; + + if (masterKey) { + keyToEncrypt = masterKey.encKey; + + // Only encrypt the master password hash if masterKey exists as + // we won't have a masterKeyHash without a masterKey + const masterKeyHash = await this.stateService.getKeyHash(); + if (masterKeyHash != null) { + encryptedMasterKeyHash = await this.cryptoService.rsaEncrypt( + Utils.fromUtf8ToArray(masterKeyHash), + pubKey, + ); + } + } else { + const userKey = await this.cryptoService.getUserKey(); + keyToEncrypt = userKey.key; + } + + const encryptedKey = await this.cryptoService.rsaEncrypt(keyToEncrypt, pubKey); + + const request = new PasswordlessAuthRequest( + encryptedKey.encryptedString, + encryptedMasterKeyHash?.encryptedString, + await this.appIdService.getAppId(), + requestApproved, + ); + return await this.apiService.putAuthRequest(id, request); + } + + private async clearCache(): Promise { + await this.currentAuthnTypeState.update((_) => null); + await this.loginStrategyCacheState.update((_) => null); + await this.clearSessionTimeout(); + } + + private async startSessionTimeout(): Promise { + await this.clearSessionTimeout(); + await this.loginStrategyCacheExpirationState.update( + (_) => new Date(Date.now() + sessionTimeoutLength), + ); + this.sessionTimeout = setTimeout(() => this.clearCache(), sessionTimeoutLength); + } + + private async clearSessionTimeout(): Promise { + await this.loginStrategyCacheExpirationState.update((_) => null); + this.sessionTimeout = null; + } + + private async isSessionValid(): Promise { + const cache = await firstValueFrom(this.loginStrategyCacheState.state$); + if (cache == null) { + return false; + } + const expiration = await firstValueFrom(this.loginStrategyCacheExpirationState.state$); + if (expiration != null && expiration < new Date()) { + await this.clearCache(); + return false; + } + return true; + } + + private initializeLoginStrategy( + source: Observable<[AuthenticationType | null, CacheData | null]>, + ) { + return source.pipe( + map(([strategy, data]) => { + if (strategy == null) { + return null; + } + switch (strategy) { + case AuthenticationType.Password: + return new PasswordLoginStrategy( + data?.password, + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this.passwordStrengthService, + this.policyService, + this, + ); + case AuthenticationType.Sso: + return new SsoLoginStrategy( + data?.sso, + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this.keyConnectorService, + this.deviceTrustCryptoService, + this.authRequestService, + this.i18nService, + ); + case AuthenticationType.UserApiKey: + return new UserApiLoginStrategy( + data?.userApiKey, + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this.environmentService, + this.keyConnectorService, + ); + case AuthenticationType.AuthRequest: + return new AuthRequestLoginStrategy( + data?.authRequest, + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this.deviceTrustCryptoService, + ); + case AuthenticationType.WebAuthn: + return new WebAuthnLoginStrategy( + data?.webAuthn, + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + ); + } + }), + ); + } } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.state.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.state.spec.ts new file mode 100644 index 0000000000..ea9d1f80e3 --- /dev/null +++ b/libs/auth/src/common/services/login-strategies/login-strategy.state.spec.ts @@ -0,0 +1,155 @@ +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { DeviceRequest } from "@bitwarden/common/auth/models/request/identity-token/device.request"; +import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request"; +import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request"; +import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; +import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request"; +import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; +import { DeviceType } from "@bitwarden/common/enums"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { MasterKey, PrfKey, UserKey } from "@bitwarden/common/types/key"; + +import { AuthRequestLoginStrategyData } from "../../login-strategies/auth-request-login.strategy"; +import { PasswordLoginStrategyData } from "../../login-strategies/password-login.strategy"; +import { SsoLoginStrategyData } from "../../login-strategies/sso-login.strategy"; +import { UserApiLoginStrategyData } from "../../login-strategies/user-api-login.strategy"; +import { WebAuthnLoginStrategyData } from "../../login-strategies/webauthn-login.strategy"; +import { + MockAuthenticatorAssertionResponse, + MockPublicKeyCredential, +} from "../../login-strategies/webauthn-login.strategy.spec"; +import { AuthRequestLoginCredentials, WebAuthnLoginCredentials } from "../../models"; + +import { CACHE_KEY } from "./login-strategy.state"; + +describe("LOGIN_STRATEGY_CACHE_KEY", () => { + const sut = CACHE_KEY; + + let deviceRequest: DeviceRequest; + let twoFactorRequest: TokenTwoFactorRequest; + + beforeEach(() => { + deviceRequest = Object.assign(Object.create(DeviceRequest.prototype), { + type: DeviceType.ChromeBrowser, + name: "DEVICE_NAME", + identifier: "DEVICE_IDENTIFIER", + pushToken: "PUSH_TOKEN", + }); + + twoFactorRequest = new TokenTwoFactorRequest(TwoFactorProviderType.Email, "TOKEN", false); + }); + + it("should correctly deserialize PasswordLoginStrategyData", () => { + const actual = { + password: new PasswordLoginStrategyData(), + }; + actual.password.tokenRequest = new PasswordTokenRequest( + "EMAIL", + "LOCAL_PASSWORD_HASH", + "CAPTCHA_TOKEN", + twoFactorRequest, + deviceRequest, + ); + actual.password.masterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey; + actual.password.localMasterKeyHash = "LOCAL_MASTER_KEY_HASH"; + + const result = sut.deserializer(JSON.parse(JSON.stringify(actual))); + + expect(result.password).toBeInstanceOf(PasswordLoginStrategyData); + verifyPropertyPrototypes(result, actual); + }); + + it("should correctly deserialize SsoLoginStrategyData", () => { + const actual = { sso: new SsoLoginStrategyData() }; + actual.sso.tokenRequest = new SsoTokenRequest( + "CODE", + "CODE_VERIFIER", + "REDIRECT_URI", + twoFactorRequest, + deviceRequest, + ); + + const result = sut.deserializer(JSON.parse(JSON.stringify(actual))); + + expect(result.sso).toBeInstanceOf(SsoLoginStrategyData); + verifyPropertyPrototypes(result, actual); + }); + + it("should correctly deserialize UserApiLoginStrategyData", () => { + const actual = { userApiKey: new UserApiLoginStrategyData() }; + actual.userApiKey.tokenRequest = new UserApiTokenRequest("CLIENT_ID", "CLIENT_SECRET", null); + + const result = sut.deserializer(JSON.parse(JSON.stringify(actual))); + + expect(result.userApiKey).toBeInstanceOf(UserApiLoginStrategyData); + verifyPropertyPrototypes(result, actual); + }); + + it("should correctly deserialize AuthRequestLoginStrategyData", () => { + const actual = { authRequest: new AuthRequestLoginStrategyData() }; + actual.authRequest.tokenRequest = new PasswordTokenRequest("EMAIL", "ACCESS_CODE", null, null); + actual.authRequest.authRequestCredentials = new AuthRequestLoginCredentials( + "EMAIL", + "ACCESS_CODE", + "AUTH_REQUEST_ID", + new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, + new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey, + "MASTER_KEY_HASH", + ); + + const result = sut.deserializer(JSON.parse(JSON.stringify(actual))); + + expect(result.authRequest).toBeInstanceOf(AuthRequestLoginStrategyData); + verifyPropertyPrototypes(result, actual); + }); + + it("should correctly deserialize WebAuthnLoginStrategyData", () => { + global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse; + const actual = { webAuthn: new WebAuthnLoginStrategyData() }; + const publicKeyCredential = new MockPublicKeyCredential(); + const deviceResponse = new WebAuthnLoginAssertionResponseRequest(publicKeyCredential); + const prfKey = new SymmetricCryptoKey(new Uint8Array(64)) as PrfKey; + actual.webAuthn.credentials = new WebAuthnLoginCredentials("TOKEN", deviceResponse, prfKey); + actual.webAuthn.tokenRequest = new WebAuthnLoginTokenRequest( + "TOKEN", + deviceResponse, + deviceRequest, + ); + actual.webAuthn.captchaBypassToken = "CAPTCHA_BYPASS_TOKEN"; + actual.webAuthn.tokenRequest.setTwoFactor( + new TokenTwoFactorRequest(TwoFactorProviderType.Email, "TOKEN", false), + ); + + const result = sut.deserializer(JSON.parse(JSON.stringify(actual))); + + expect(result.webAuthn).toBeInstanceOf(WebAuthnLoginStrategyData); + verifyPropertyPrototypes(result, actual); + }); +}); + +/** + * Recursively verifies the prototypes of all objects in the deserialized object. + * It is important that the concrete object has the correct prototypes for + * comparison. + * @param deserialized the deserialized object + * @param concrete the object stored in state + */ +function verifyPropertyPrototypes(deserialized: object, concrete: object) { + for (const key of Object.keys(deserialized)) { + const deserializedProperty = (deserialized as any)[key]; + if (deserializedProperty === undefined) { + continue; + } + const realProperty = (concrete as any)[key]; + if (realProperty === undefined) { + throw new Error(`Expected ${key} to be defined in ${concrete.constructor.name}`); + } + // we only care about checking prototypes of objects + if (typeof realProperty === "object" && realProperty !== null) { + const realProto = Object.getPrototypeOf(realProperty); + expect(deserializedProperty).toBeInstanceOf(realProto.constructor); + verifyPropertyPrototypes(deserializedProperty, realProperty); + } + } +} diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.state.ts b/libs/auth/src/common/services/login-strategies/login-strategy.state.ts new file mode 100644 index 0000000000..90fcd89913 --- /dev/null +++ b/libs/auth/src/common/services/login-strategies/login-strategy.state.ts @@ -0,0 +1,80 @@ +import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; +import { KeyDefinition, LOGIN_STRATEGY_MEMORY } from "@bitwarden/common/platform/state"; + +import { AuthRequestLoginStrategyData } from "../../login-strategies/auth-request-login.strategy"; +import { PasswordLoginStrategyData } from "../../login-strategies/password-login.strategy"; +import { SsoLoginStrategyData } from "../../login-strategies/sso-login.strategy"; +import { UserApiLoginStrategyData } from "../../login-strategies/user-api-login.strategy"; +import { WebAuthnLoginStrategyData } from "../../login-strategies/webauthn-login.strategy"; + +/** + * The current login strategy in use. + */ +export const CURRENT_LOGIN_STRATEGY_KEY = new KeyDefinition( + LOGIN_STRATEGY_MEMORY, + "currentLoginStrategy", + { + deserializer: (data) => data, + }, +); + +/** + * The expiration date for the login strategy cache. + * Used as a backup to the timer set on the service. + */ +export const CACHE_EXPIRATION_KEY = new KeyDefinition( + LOGIN_STRATEGY_MEMORY, + "loginStrategyCacheExpiration", + { + deserializer: (data) => (data ? null : new Date(data)), + }, +); + +/** + * Auth Request notification for all instances of the login strategy service. + * Note: this isn't an ideal approach, but allows both a background and + * foreground instance to send out the notification. + * TODO: Move to Auth Request service. + */ +export const AUTH_REQUEST_PUSH_NOTIFICATION_KEY = new KeyDefinition( + LOGIN_STRATEGY_MEMORY, + "authRequestPushNotification", + { + deserializer: (data) => data, + }, +); + +export type CacheData = { + password?: PasswordLoginStrategyData; + sso?: SsoLoginStrategyData; + userApiKey?: UserApiLoginStrategyData; + authRequest?: AuthRequestLoginStrategyData; + webAuthn?: WebAuthnLoginStrategyData; +}; + +/** + * A cache for login strategies to use for data persistence through + * the login process. + */ +export const CACHE_KEY = new KeyDefinition( + LOGIN_STRATEGY_MEMORY, + "loginStrategyCache", + { + deserializer: (data) => { + if (data == null) { + return null; + } + return { + password: data.password ? PasswordLoginStrategyData.fromJSON(data.password) : undefined, + sso: data.sso ? SsoLoginStrategyData.fromJSON(data.sso) : undefined, + userApiKey: data.userApiKey + ? UserApiLoginStrategyData.fromJSON(data.userApiKey) + : undefined, + authRequest: data.authRequest + ? AuthRequestLoginStrategyData.fromJSON(data.authRequest) + : undefined, + webAuthn: data.webAuthn ? WebAuthnLoginStrategyData.fromJSON(data.webAuthn) : undefined, + }; + }, + }, +); diff --git a/libs/common/src/abstractions/settings.service.ts b/libs/common/src/abstractions/settings.service.ts index 78ed7183c8..e9d8c8b683 100644 --- a/libs/common/src/abstractions/settings.service.ts +++ b/libs/common/src/abstractions/settings.service.ts @@ -1,14 +1,8 @@ import { Observable } from "rxjs"; -import { AccountSettingsSettings } from "../platform/models/domain/account"; - export abstract class SettingsService { - settings$: Observable; disableFavicon$: Observable; - setEquivalentDomains: (equivalentDomains: string[][]) => Promise; - getEquivalentDomains: (url: string) => Set; setDisableFavicon: (value: boolean) => Promise; getDisableFavicon: () => boolean; - clear: (userId?: string) => Promise; } diff --git a/libs/common/src/auth/enums/authentication-type.ts b/libs/common/src/auth/enums/authentication-type.ts index 23ca9ace76..35b50e6400 100644 --- a/libs/common/src/auth/enums/authentication-type.ts +++ b/libs/common/src/auth/enums/authentication-type.ts @@ -1,7 +1,7 @@ export enum AuthenticationType { Password = 0, Sso = 1, - UserApi = 2, + UserApiKey = 2, AuthRequest = 3, WebAuthn = 4, } diff --git a/libs/common/src/auth/models/request/identity-token/device.request.ts b/libs/common/src/auth/models/request/identity-token/device.request.ts index 49b40f11d0..944ac8c789 100644 --- a/libs/common/src/auth/models/request/identity-token/device.request.ts +++ b/libs/common/src/auth/models/request/identity-token/device.request.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { DeviceType } from "../../../../enums"; import { PlatformUtilsService } from "../../../../platform/abstractions/platform-utils.service"; @@ -13,4 +15,8 @@ export class DeviceRequest { this.identifier = appId; this.pushToken = null; } + + static fromJSON(json: Jsonify) { + return Object.assign(Object.create(DeviceRequest.prototype), json); + } } diff --git a/libs/common/src/auth/models/request/identity-token/password-token.request.ts b/libs/common/src/auth/models/request/identity-token/password-token.request.ts index 8f11f5c097..456e058a23 100644 --- a/libs/common/src/auth/models/request/identity-token/password-token.request.ts +++ b/libs/common/src/auth/models/request/identity-token/password-token.request.ts @@ -34,4 +34,13 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect alterIdentityTokenHeaders(headers: Headers) { headers.set("Auth-Email", Utils.fromUtf8ToUrlB64(this.email)); } + + static fromJSON(json: any) { + return Object.assign(Object.create(PasswordTokenRequest.prototype), json, { + device: json.device ? DeviceRequest.fromJSON(json.device) : undefined, + twoFactor: json.twoFactor + ? Object.assign(new TokenTwoFactorRequest(), json.twoFactor) + : undefined, + }); + } } diff --git a/libs/common/src/auth/models/request/identity-token/sso-token.request.ts b/libs/common/src/auth/models/request/identity-token/sso-token.request.ts index 48c6087bdc..97df2c6b7f 100644 --- a/libs/common/src/auth/models/request/identity-token/sso-token.request.ts +++ b/libs/common/src/auth/models/request/identity-token/sso-token.request.ts @@ -23,4 +23,13 @@ export class SsoTokenRequest extends TokenRequest { return obj; } + + static fromJSON(json: any) { + return Object.assign(Object.create(SsoTokenRequest.prototype), json, { + device: json.device ? DeviceRequest.fromJSON(json.device) : undefined, + twoFactor: json.twoFactor + ? Object.assign(new TokenTwoFactorRequest(), json.twoFactor) + : undefined, + }); + } } diff --git a/libs/common/src/auth/models/request/identity-token/user-api-token.request.ts b/libs/common/src/auth/models/request/identity-token/user-api-token.request.ts index bd43450e5c..d6483582d5 100644 --- a/libs/common/src/auth/models/request/identity-token/user-api-token.request.ts +++ b/libs/common/src/auth/models/request/identity-token/user-api-token.request.ts @@ -21,4 +21,13 @@ export class UserApiTokenRequest extends TokenRequest { return obj; } + + static fromJSON(json: any) { + return Object.assign(Object.create(UserApiTokenRequest.prototype), json, { + device: json.device ? DeviceRequest.fromJSON(json.device) : undefined, + twoFactor: json.twoFactor + ? Object.assign(new TokenTwoFactorRequest(), json.twoFactor) + : undefined, + }); + } } diff --git a/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts b/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts index 1203b66ad2..66cc96c9c7 100644 --- a/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts +++ b/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts @@ -1,6 +1,7 @@ import { WebAuthnLoginAssertionResponseRequest } from "../../../services/webauthn-login/request/webauthn-login-assertion-response.request"; import { DeviceRequest } from "./device.request"; +import { TokenTwoFactorRequest } from "./token-two-factor.request"; import { TokenRequest } from "./token.request"; export class WebAuthnLoginTokenRequest extends TokenRequest { @@ -22,4 +23,14 @@ export class WebAuthnLoginTokenRequest extends TokenRequest { return obj; } + + static fromJSON(json: any) { + return Object.assign(Object.create(WebAuthnLoginTokenRequest.prototype), json, { + deviceResponse: WebAuthnLoginAssertionResponseRequest.fromJSON(json.deviceResponse), + device: json.device ? DeviceRequest.fromJSON(json.device) : undefined, + twoFactor: json.twoFactor + ? Object.assign(new TokenTwoFactorRequest(), json.twoFactor) + : undefined, + }); + } } diff --git a/libs/common/src/auth/services/anonymous-hub.service.ts b/libs/common/src/auth/services/anonymous-hub.service.ts index 4d5e060531..3abe42a5f5 100644 --- a/libs/common/src/auth/services/anonymous-hub.service.ts +++ b/libs/common/src/auth/services/anonymous-hub.service.ts @@ -54,7 +54,7 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction { } private async ProcessNotification(notification: NotificationResponse) { - await this.loginStrategyService.authResponsePushNotification( + await this.loginStrategyService.sendAuthRequestPushNotification( notification.payload as AuthRequestPushNotification, ); } diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts index bdbbfea94b..408ed33c97 100644 --- a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts @@ -1,17 +1,22 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; +import { UserId } from "../../../../common/src/types/guid"; import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationUserService } from "../../admin-console/abstractions/organization-user/organization-user.service"; import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; -import { StateService } from "../../platform/abstractions/state.service"; +import { AccountInfo, AccountService } from "../abstractions/account.service"; +import { AuthenticationStatus } from "../enums/authentication-status"; import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation"; describe("PasswordResetEnrollmentServiceImplementation", () => { + const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null); + let organizationApiService: MockProxy; - let stateService: MockProxy; + let accountService: MockProxy; let cryptoService: MockProxy; let organizationUserService: MockProxy; let i18nService: MockProxy; @@ -19,13 +24,14 @@ describe("PasswordResetEnrollmentServiceImplementation", () => { beforeEach(() => { organizationApiService = mock(); - stateService = mock(); + accountService = mock(); + accountService.activeAccount$ = activeAccountSubject; cryptoService = mock(); organizationUserService = mock(); i18nService = mock(); service = new PasswordResetEnrollmentServiceImplementation( organizationApiService, - stateService, + accountService, cryptoService, organizationUserService, i18nService, @@ -81,7 +87,14 @@ describe("PasswordResetEnrollmentServiceImplementation", () => { }; const encryptedKey = { encryptedString: "encryptedString" }; organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any); - stateService.getUserId.mockResolvedValue("userId"); + + const user1AccountInfo: AccountInfo = { + name: "Test User 1", + email: "test1@email.com", + status: AuthenticationStatus.Unlocked, + }; + activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId })); + cryptoService.getUserKey.mockResolvedValue({ key: "key" } as any); cryptoService.rsaEncrypt.mockResolvedValue(encryptedKey as any); diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts index 8904605acc..aeb978bfd9 100644 --- a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts @@ -1,11 +1,13 @@ +import { firstValueFrom, map } from "rxjs"; + import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationUserService } from "../../admin-console/abstractions/organization-user/organization-user.service"; import { OrganizationUserResetPasswordEnrollmentRequest } from "../../admin-console/abstractions/organization-user/requests"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; -import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; import { UserKey } from "../../types/key"; +import { AccountService } from "../abstractions/account.service"; import { PasswordResetEnrollmentServiceAbstraction } from "../abstractions/password-reset-enrollment.service.abstraction"; export class PasswordResetEnrollmentServiceImplementation @@ -13,7 +15,7 @@ export class PasswordResetEnrollmentServiceImplementation { constructor( protected organizationApiService: OrganizationApiServiceAbstraction, - protected stateService: StateService, + protected accountService: AccountService, protected cryptoService: CryptoService, protected organizationUserService: OrganizationUserService, protected i18nService: I18nService, @@ -38,7 +40,8 @@ export class PasswordResetEnrollmentServiceImplementation const orgPublicKey = Utils.fromB64ToArray(orgKeyResponse.publicKey); - userId = userId ?? (await this.stateService.getUserId()); + userId = + userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)))); userKey = userKey ?? (await this.cryptoService.getUserKey(userId)); // RSA Encrypt user's userKey.key with organization public key const encryptedKey = await this.cryptoService.rsaEncrypt(userKey.key, orgPublicKey); diff --git a/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts index 2f7eddb2d6..c524b958e3 100644 --- a/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts +++ b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { Utils } from "../../../../platform/misc/utils"; import { WebAuthnLoginResponseRequest } from "./webauthn-login-response.request"; @@ -27,4 +29,8 @@ export class WebAuthnLoginAssertionResponseRequest extends WebAuthnLoginResponse userHandle: Utils.fromBufferToUrlB64(credential.response.userHandle), }; } + + static fromJSON(json: Jsonify) { + return Object.assign(Object.create(WebAuthnLoginAssertionResponseRequest.prototype), json); + } } diff --git a/libs/common/src/autofill/services/domain-settings.service.spec.ts b/libs/common/src/autofill/services/domain-settings.service.spec.ts new file mode 100644 index 0000000000..24e3763eb4 --- /dev/null +++ b/libs/common/src/autofill/services/domain-settings.service.spec.ts @@ -0,0 +1,53 @@ +import { firstValueFrom, of } from "rxjs"; + +import { FakeStateProvider, FakeAccountService, mockAccountServiceWith } from "../../../spec"; +import { Utils } from "../../platform/misc/utils"; +import { UserId } from "../../types/guid"; + +import { DefaultDomainSettingsService, DomainSettingsService } from "./domain-settings.service"; + +describe("DefaultDomainSettingsService", () => { + let domainSettingsService: DomainSettingsService; + const mockUserId = Utils.newGuid() as UserId; + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); + + const mockEquivalentDomains = [ + ["example.com", "exampleapp.com", "example.co.uk", "ejemplo.es"], + ["bitwarden.com", "bitwarden.co.uk", "sm-bitwarden.com"], + ["example.co.uk", "exampleapp.co.uk"], + ]; + + beforeEach(() => { + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + + jest.spyOn(domainSettingsService, "getUrlEquivalentDomains"); + domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); + }); + + describe("getUrlEquivalentDomains", () => { + it("returns all equivalent domains for a URL", async () => { + const expected = new Set([ + "example.com", + "exampleapp.com", + "example.co.uk", + "ejemplo.es", + "exampleapp.co.uk", + ]); + + const actual = await firstValueFrom( + domainSettingsService.getUrlEquivalentDomains("example.co.uk"), + ); + + expect(domainSettingsService.getUrlEquivalentDomains).toHaveBeenCalledWith("example.co.uk"); + expect(actual).toEqual(expected); + }); + + it("returns an empty set if there are no equivalent domains", async () => { + const actual = await firstValueFrom(domainSettingsService.getUrlEquivalentDomains("asdf")); + + expect(domainSettingsService.getUrlEquivalentDomains).toHaveBeenCalledWith("asdf"); + expect(actual).toEqual(new Set()); + }); + }); +}); diff --git a/libs/common/src/autofill/services/domain-settings.service.ts b/libs/common/src/autofill/services/domain-settings.service.ts new file mode 100644 index 0000000000..3131b9c50b --- /dev/null +++ b/libs/common/src/autofill/services/domain-settings.service.ts @@ -0,0 +1,97 @@ +import { map, Observable } from "rxjs"; + +import { + NeverDomains, + EquivalentDomains, + UriMatchStrategySetting, + UriMatchStrategy, +} from "../../models/domain/domain-service"; +import { Utils } from "../../platform/misc/utils"; +import { + DOMAIN_SETTINGS_DISK, + ActiveUserState, + GlobalState, + KeyDefinition, + StateProvider, + UserKeyDefinition, +} from "../../platform/state"; + +const NEVER_DOMAINS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "neverDomains", { + deserializer: (value: NeverDomains) => value ?? null, +}); + +const EQUIVALENT_DOMAINS = new UserKeyDefinition(DOMAIN_SETTINGS_DISK, "equivalentDomains", { + deserializer: (value: EquivalentDomains) => value ?? null, + clearOn: ["logout"], +}); + +const DEFAULT_URI_MATCH_STRATEGY = new KeyDefinition( + DOMAIN_SETTINGS_DISK, + "defaultUriMatchStrategy", + { + deserializer: (value: UriMatchStrategySetting) => value ?? UriMatchStrategy.Domain, + }, +); + +export abstract class DomainSettingsService { + neverDomains$: Observable; + setNeverDomains: (newValue: NeverDomains) => Promise; + equivalentDomains$: Observable; + setEquivalentDomains: (newValue: EquivalentDomains) => Promise; + defaultUriMatchStrategy$: Observable; + setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise; + getUrlEquivalentDomains: (url: string) => Observable>; +} + +export class DefaultDomainSettingsService implements DomainSettingsService { + private neverDomainsState: GlobalState; + readonly neverDomains$: Observable; + + private equivalentDomainsState: ActiveUserState; + readonly equivalentDomains$: Observable; + + private defaultUriMatchStrategyState: ActiveUserState; + readonly defaultUriMatchStrategy$: Observable; + + constructor(private stateProvider: StateProvider) { + this.neverDomainsState = this.stateProvider.getGlobal(NEVER_DOMAINS); + this.neverDomains$ = this.neverDomainsState.state$.pipe(map((x) => x ?? null)); + + this.equivalentDomainsState = this.stateProvider.getActive(EQUIVALENT_DOMAINS); + this.equivalentDomains$ = this.equivalentDomainsState.state$.pipe(map((x) => x ?? null)); + + this.defaultUriMatchStrategyState = this.stateProvider.getActive(DEFAULT_URI_MATCH_STRATEGY); + this.defaultUriMatchStrategy$ = this.defaultUriMatchStrategyState.state$.pipe( + map((x) => x ?? UriMatchStrategy.Domain), + ); + } + + async setNeverDomains(newValue: NeverDomains): Promise { + await this.neverDomainsState.update(() => newValue); + } + + async setEquivalentDomains(newValue: EquivalentDomains): Promise { + await this.equivalentDomainsState.update(() => newValue); + } + + async setDefaultUriMatchStrategy(newValue: UriMatchStrategySetting): Promise { + await this.defaultUriMatchStrategyState.update(() => newValue); + } + + getUrlEquivalentDomains(url: string): Observable> { + const domains$ = this.equivalentDomains$.pipe( + map((equivalentDomains) => { + const domain = Utils.getDomain(url); + if (domain == null || equivalentDomains == null) { + return new Set() as Set; + } + + const equivalents = equivalentDomains.filter((ed) => ed.includes(domain)).flat(); + + return new Set(equivalents); + }), + ); + + return domains$; + } +} diff --git a/libs/common/src/models/domain/domain-service.ts b/libs/common/src/models/domain/domain-service.ts new file mode 100644 index 0000000000..d5247765fc --- /dev/null +++ b/libs/common/src/models/domain/domain-service.ts @@ -0,0 +1,24 @@ +/* + See full documentation at: + https://bitwarden.com/help/uri-match-detection/#match-detection-options + + Domain: "the top-level domain and second-level domain of the URI match the detected resource", + Host: "the hostname and (if specified) port of the URI matches the detected resource", + StartsWith: "the detected resource starts with the URI, regardless of what follows it", + Exact: "the URI matches the detected resource exactly", + RegularExpression: "the detected resource matches a specified regular expression", + Never: "never offer auto-fill for the item", +*/ +export const UriMatchStrategy = { + Domain: 0, + Host: 1, + StartsWith: 2, + Exact: 3, + RegularExpression: 4, + Never: 5, +} as const; + +export type UriMatchStrategySetting = (typeof UriMatchStrategy)[keyof typeof UriMatchStrategy]; + +export type NeverDomains = { [id: string]: unknown }; +export type EquivalentDomains = string[][]; diff --git a/libs/common/src/models/export/login-uri.export.ts b/libs/common/src/models/export/login-uri.export.ts index 8a21c3ff97..83a7d25eff 100644 --- a/libs/common/src/models/export/login-uri.export.ts +++ b/libs/common/src/models/export/login-uri.export.ts @@ -1,5 +1,5 @@ +import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { EncString } from "../../platform/models/domain/enc-string"; -import { UriMatchType } from "../../vault/enums"; import { LoginUri as LoginUriDomain } from "../../vault/models/domain/login-uri"; import { LoginUriView } from "../../vault/models/view/login-uri.view"; @@ -26,7 +26,7 @@ export class LoginUriExport { uri: string; uriChecksum: string | undefined; - match: UriMatchType = null; + match: UriMatchStrategySetting = null; constructor(o?: LoginUriView | LoginUriDomain) { if (o == null) { diff --git a/libs/common/src/platform/abstractions/app-id.service.ts b/libs/common/src/platform/abstractions/app-id.service.ts index 99bc6c9eae..c1414dd01f 100644 --- a/libs/common/src/platform/abstractions/app-id.service.ts +++ b/libs/common/src/platform/abstractions/app-id.service.ts @@ -1,4 +1,8 @@ +import { Observable } from "rxjs"; + export abstract class AppIdService { + appId$: Observable; + anonymousAppId$: Observable; getAppId: () => Promise; getAnonymousAppId: () => Promise; } diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 3a62b92916..7cf78934c8 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -14,17 +14,12 @@ import { SendData } from "../../tools/send/models/data/send.data"; import { SendView } from "../../tools/send/models/view/send.view"; import { UserId } from "../../types/guid"; import { DeviceKey, MasterKey } from "../../types/key"; -import { UriMatchType } from "../../vault/enums"; import { CipherData } from "../../vault/models/data/cipher.data"; import { LocalData } from "../../vault/models/data/local.data"; import { CipherView } from "../../vault/models/view/cipher.view"; import { KdfType, ThemeType } from "../enums"; import { ServerConfigData } from "../models/data/server-config.data"; -import { - Account, - AccountDecryptionOptions, - AccountSettingsSettings, -} from "../models/domain/account"; +import { Account, AccountDecryptionOptions } from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; import { StorageOptions } from "../models/domain/storage-options"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; @@ -184,8 +179,6 @@ export abstract class StateService { * @deprecated Do not call this directly, use SendService */ setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise; - getDefaultUriMatch: (options?: StorageOptions) => Promise; - setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise; /** * @deprecated Do not call this, use SettingsService */ @@ -272,8 +265,6 @@ export abstract class StateService { * @deprecated Do not call this directly, use SendService */ setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise; - getEquivalentDomains: (options?: StorageOptions) => Promise; - setEquivalentDomains: (value: string, options?: StorageOptions) => Promise; getEventCollection: (options?: StorageOptions) => Promise; setEventCollection: (value: EventData[], options?: StorageOptions) => Promise; getEverBeenUnlocked: (options?: StorageOptions) => Promise; @@ -307,8 +298,6 @@ export abstract class StateService { setMainWindowSize: (value: number, options?: StorageOptions) => Promise; getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise; setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise; - getNeverDomains: (options?: StorageOptions) => Promise<{ [id: string]: unknown }>; - setNeverDomains: (value: { [id: string]: unknown }, options?: StorageOptions) => Promise; getOpenAtLogin: (options?: StorageOptions) => Promise; setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise; getOrganizationInvitation: (options?: StorageOptions) => Promise; @@ -350,14 +339,6 @@ export abstract class StateService { setRememberedEmail: (value: string, options?: StorageOptions) => Promise; getSecurityStamp: (options?: StorageOptions) => Promise; setSecurityStamp: (value: string, options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use SettingsService - */ - getSettings: (options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use SettingsService - */ - setSettings: (value: AccountSettingsSettings, options?: StorageOptions) => Promise; getTheme: (options?: StorageOptions) => Promise; setTheme: (value: ThemeType, options?: StorageOptions) => Promise; getTwoFactorToken: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index 1fb9fa3541..83a2da5709 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -253,11 +253,10 @@ export class Utils { }); } + static guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + static isGuid(id: string) { - return RegExp( - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - "i", - ).test(id); + return RegExp(Utils.guidRegex, "i").test(id); } static getHostname(uriString: string): string { diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 32b1de9c8f..2c3c2eab67 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -7,6 +7,7 @@ import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/us import { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option"; import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { EventData } from "../../../models/data/event.data"; +import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { GeneratorOptions } from "../../../tools/generator/generator-options"; import { GeneratedPasswordHistory, @@ -17,7 +18,6 @@ import { SendData } from "../../../tools/send/models/data/send.data"; import { SendView } from "../../../tools/send/models/view/send.view"; import { DeepJsonify } from "../../../types/deep-jsonify"; import { MasterKey } from "../../../types/key"; -import { UriMatchType } from "../../../vault/enums"; import { CipherData } from "../../../vault/models/data/cipher.data"; import { CipherView } from "../../../vault/models/view/cipher.view"; import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info"; @@ -196,13 +196,12 @@ export class AccountProfile { export class AccountSettings { autoConfirmFingerPrints?: boolean; - defaultUriMatch?: UriMatchType; + defaultUriMatch?: UriMatchStrategySetting; disableGa?: boolean; dontShowCardsCurrentTab?: boolean; dontShowIdentitiesCurrentTab?: boolean; enableAlwaysOnTop?: boolean; enableBiometric?: boolean; - equivalentDomains?: any; minimizeOnCopyToClipboard?: boolean; passwordGenerationOptions?: PasswordGeneratorOptions; usernameGenerationOptions?: UsernameGeneratorOptions; @@ -210,7 +209,6 @@ export class AccountSettings { pinKeyEncryptedUserKey?: EncryptedString; pinKeyEncryptedUserKeyEphemeral?: EncryptedString; protectedPin?: string; - settings?: AccountSettingsSettings; // TODO: Merge whatever is going on here into the AccountSettings model properly vaultTimeout?: number; vaultTimeoutAction?: string = "lock"; serverConfig?: ServerConfigData; @@ -236,10 +234,6 @@ export class AccountSettings { } } -export type AccountSettingsSettings = { - equivalentDomains?: string[][]; -}; - export class AccountTokens { accessToken?: string; refreshToken?: string; diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts index b27482fa6a..0b018aa36b 100644 --- a/libs/common/src/platform/models/domain/global-state.ts +++ b/libs/common/src/platform/models/domain/global-state.ts @@ -25,6 +25,5 @@ export class GlobalState { enableBrowserIntegration?: boolean; enableBrowserIntegrationFingerprint?: boolean; enableDuckDuckGoBrowserIntegration?: boolean; - neverDomains?: { [id: string]: unknown }; deepLinkRedirectUrl?: string; } diff --git a/libs/common/src/platform/services/app-id.service.spec.ts b/libs/common/src/platform/services/app-id.service.spec.ts new file mode 100644 index 0000000000..ae44bc95e0 --- /dev/null +++ b/libs/common/src/platform/services/app-id.service.spec.ts @@ -0,0 +1,101 @@ +import { FakeGlobalStateProvider } from "../../../spec"; +import { Utils } from "../misc/utils"; + +import { ANONYMOUS_APP_ID_KEY, APP_ID_KEY, AppIdService } from "./app-id.service"; + +describe("AppIdService", () => { + const globalStateProvider = new FakeGlobalStateProvider(); + const appIdState = globalStateProvider.getFake(APP_ID_KEY); + const anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY); + let sut: AppIdService; + + beforeEach(() => { + sut = new AppIdService(globalStateProvider); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("getAppId", () => { + it("returns the existing appId when it exists", async () => { + appIdState.stateSubject.next("existingAppId"); + + const appId = await sut.getAppId(); + + expect(appId).toBe("existingAppId"); + }); + + it.each([null, undefined])( + "uses the util function to create a new id when it AppId does not exist", + async (value) => { + appIdState.stateSubject.next(value); + const spy = jest.spyOn(Utils, "newGuid"); + + await sut.getAppId(); + + expect(spy).toHaveBeenCalledTimes(1); + }, + ); + + it.each([null, undefined])("returns a new appId when it does not exist", async (value) => { + appIdState.stateSubject.next(value); + + const appId = await sut.getAppId(); + + expect(appId).toMatch(Utils.guidRegex); + }); + + it.each([null, undefined])( + "stores the new guid when it an existing one is not found", + async (value) => { + appIdState.stateSubject.next(value); + + const appId = await sut.getAppId(); + + expect(appIdState.nextMock).toHaveBeenCalledWith(appId); + }, + ); + }); + + describe("getAnonymousAppId", () => { + it("returns the existing appId when it exists", async () => { + anonymousAppIdState.stateSubject.next("existingAppId"); + + const appId = await sut.getAnonymousAppId(); + + expect(appId).toBe("existingAppId"); + }); + + it.each([null, undefined])( + "uses the util function to create a new id when it AppId does not exist", + async (value) => { + anonymousAppIdState.stateSubject.next(value); + const spy = jest.spyOn(Utils, "newGuid"); + + await sut.getAnonymousAppId(); + + expect(spy).toHaveBeenCalledTimes(1); + }, + ); + + it.each([null, undefined])("returns a new appId when it does not exist", async (value) => { + anonymousAppIdState.stateSubject.next(value); + + const appId = await sut.getAnonymousAppId(); + + expect(appId).toMatch(Utils.guidRegex); + }); + + it.each([null, undefined])( + "stores the new guid when it an existing one is not found", + async (value) => { + anonymousAppIdState.stateSubject.next(value); + + const appId = await sut.getAnonymousAppId(); + + expect(anonymousAppIdState.nextMock).toHaveBeenCalledWith(appId); + }, + ); + }); +}); diff --git a/libs/common/src/platform/services/app-id.service.ts b/libs/common/src/platform/services/app-id.service.ts index fa3d943ede..630e629749 100644 --- a/libs/common/src/platform/services/app-id.service.ts +++ b/libs/common/src/platform/services/app-id.service.ts @@ -1,31 +1,46 @@ +import { Observable, filter, firstValueFrom, tap } from "rxjs"; + import { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service"; -import { AbstractStorageService } from "../abstractions/storage.service"; -import { HtmlStorageLocation } from "../enums"; import { Utils } from "../misc/utils"; +import { APPLICATION_ID_DISK, GlobalStateProvider, KeyDefinition } from "../state"; + +export const APP_ID_KEY = new KeyDefinition(APPLICATION_ID_DISK, "appId", { + deserializer: (value: string) => value, +}); +export const ANONYMOUS_APP_ID_KEY = new KeyDefinition(APPLICATION_ID_DISK, "anonymousAppId", { + deserializer: (value: string) => value, +}); export class AppIdService implements AppIdServiceAbstraction { - constructor(private storageService: AbstractStorageService) {} + appId$: Observable; + anonymousAppId$: Observable; - getAppId(): Promise { - return this.makeAndGetAppId("appId"); + constructor(globalStateProvider: GlobalStateProvider) { + const appIdState = globalStateProvider.get(APP_ID_KEY); + const anonymousAppIdState = globalStateProvider.get(ANONYMOUS_APP_ID_KEY); + this.appId$ = appIdState.state$.pipe( + tap(async (appId) => { + if (!appId) { + await appIdState.update(() => Utils.newGuid()); + } + }), + filter((appId) => !!appId), + ); + this.anonymousAppId$ = anonymousAppIdState.state$.pipe( + tap(async (appId) => { + if (!appId) { + await anonymousAppIdState.update(() => Utils.newGuid()); + } + }), + filter((appId) => !!appId), + ); } - getAnonymousAppId(): Promise { - return this.makeAndGetAppId("anonymousAppId"); + async getAppId(): Promise { + return await firstValueFrom(this.appId$); } - private async makeAndGetAppId(key: string) { - const existingId = await this.storageService.get(key, { - htmlStorageLocation: HtmlStorageLocation.Local, - }); - if (existingId != null) { - return existingId; - } - - const guid = Utils.newGuid(); - await this.storageService.save(key, guid, { - htmlStorageLocation: HtmlStorageLocation.Local, - }); - return guid; + async getAnonymousAppId(): Promise { + return await firstValueFrom(this.anonymousAppId$); } } diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 7cc55ad6a0..b0b8953b90 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -18,7 +18,6 @@ import { SendData } from "../../tools/send/models/data/send.data"; import { SendView } from "../../tools/send/models/view/send.view"; import { UserId } from "../../types/guid"; import { DeviceKey, MasterKey } from "../../types/key"; -import { UriMatchType } from "../../vault/enums"; import { CipherData } from "../../vault/models/data/cipher.data"; import { LocalData } from "../../vault/models/data/local.data"; import { CipherView } from "../../vault/models/view/cipher.view"; @@ -41,7 +40,6 @@ import { AccountData, AccountDecryptionOptions, AccountSettings, - AccountSettingsSettings, } from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; import { GlobalState } from "../models/domain/global-state"; @@ -786,23 +784,6 @@ export class StateService< ); } - async getDefaultUriMatch(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.settings?.defaultUriMatch; - } - - async setDefaultUriMatch(value: UriMatchType, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.settings.defaultUriMatch = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getDisableFavicon(options?: StorageOptions): Promise { return ( ( @@ -1304,23 +1285,6 @@ export class StateService< ); } - async getEquivalentDomains(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.settings?.equivalentDomains; - } - - async setEquivalentDomains(value: string, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.settings.equivalentDomains = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - @withPrototypeForArrayMembers(EventData) async getEventCollection(options?: StorageOptions): Promise { return ( @@ -1580,23 +1544,6 @@ export class StateService< ); } - async getNeverDomains(options?: StorageOptions): Promise<{ [id: string]: unknown }> { - return ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.neverDomains; - } - - async setNeverDomains(value: { [id: string]: unknown }, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.neverDomains = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getOpenAtLogin(options?: StorageOptions): Promise { return ( (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) @@ -1778,23 +1725,6 @@ export class StateService< ); } - async getSettings(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) - )?.settings?.settings; - } - - async setSettings(value: AccountSettingsSettings, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - account.settings.settings = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - } - async getTheme(options?: StorageOptions): Promise { return ( await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) diff --git a/libs/common/src/platform/state/global-state.ts b/libs/common/src/platform/state/global-state.ts index 6c6efdfad8..b0f19c53fa 100644 --- a/libs/common/src/platform/state/global-state.ts +++ b/libs/common/src/platform/state/global-state.ts @@ -15,6 +15,7 @@ export interface GlobalState { * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. * @returns A promise that must be awaited before your next action to ensure the update has been written to state. + * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. */ update: ( configureState: (state: T, dependency: TCombine) => T, diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index d0267a9d89..1a83a9ca0e 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -27,6 +27,7 @@ export const PROVIDERS_DISK = new StateDefinition("providers", "disk"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); +export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory"); // Autofill @@ -38,6 +39,8 @@ export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition( // Billing +export const DOMAIN_SETTINGS_DISK = new StateDefinition("domainSettings", "disk"); + export const AUTOFILL_SETTINGS_DISK = new StateDefinition("autofillSettings", "disk"); export const AUTOFILL_SETTINGS_DISK_LOCAL = new StateDefinition("autofillSettingsLocal", "disk", { web: "disk-local", @@ -52,6 +55,9 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne // Platform +export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", { + web: "disk-local", +}); export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk"); export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); diff --git a/libs/common/src/platform/state/user-state.ts b/libs/common/src/platform/state/user-state.ts index caef1792a7..dc994cf9fd 100644 --- a/libs/common/src/platform/state/user-state.ts +++ b/libs/common/src/platform/state/user-state.ts @@ -32,7 +32,8 @@ export interface ActiveUserState extends UserState { * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. - * @returns The new state + * @returns A promise that must be awaited before your next action to ensure the update has been written to state. + * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. */ readonly update: ( configureState: (state: T, dependencies: TCombine) => T, @@ -50,7 +51,8 @@ export interface SingleUserState extends UserState { * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. - * @returns The new state + * @returns A promise that must be awaited before your next action to ensure the update has been written to state. + * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. */ readonly update: ( configureState: (state: T, dependencies: TCombine) => T, diff --git a/libs/common/src/services/search.service.ts b/libs/common/src/services/search.service.ts index 467a890950..773d51297a 100644 --- a/libs/common/src/services/search.service.ts +++ b/libs/common/src/services/search.service.ts @@ -1,10 +1,11 @@ import * as lunr from "lunr"; import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service"; +import { UriMatchStrategy } from "../models/domain/domain-service"; import { I18nService } from "../platform/abstractions/i18n.service"; import { LogService } from "../platform/abstractions/log.service"; import { SendView } from "../tools/send/models/view/send.view"; -import { FieldType, UriMatchType } from "../vault/enums"; +import { FieldType } from "../vault/enums"; import { CipherType } from "../vault/enums/cipher-type"; import { CipherView } from "../vault/models/view/cipher.view"; @@ -288,7 +289,7 @@ export class SearchService implements SearchServiceAbstraction { return; } let uri = u.uri; - if (u.match !== UriMatchType.RegularExpression) { + if (u.match !== UriMatchStrategy.RegularExpression) { const protocolIndex = uri.indexOf("://"); if (protocolIndex > -1) { uri = uri.substr(protocolIndex + 3); diff --git a/libs/common/src/services/settings.service.spec.ts b/libs/common/src/services/settings.service.spec.ts deleted file mode 100644 index 6f5aced590..0000000000 --- a/libs/common/src/services/settings.service.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; - -import { CryptoService } from "../platform/abstractions/crypto.service"; -import { EncryptService } from "../platform/abstractions/encrypt.service"; -import { StateService } from "../platform/abstractions/state.service"; -import { ContainerService } from "../platform/services/container.service"; - -import { SettingsService } from "./settings.service"; - -describe("SettingsService", () => { - let settingsService: SettingsService; - - let cryptoService: MockProxy; - let encryptService: MockProxy; - let stateService: MockProxy; - let activeAccount: BehaviorSubject; - let activeAccountUnlocked: BehaviorSubject; - - const mockEquivalentDomains = [ - ["example.com", "exampleapp.com", "example.co.uk", "ejemplo.es"], - ["bitwarden.com", "bitwarden.co.uk", "sm-bitwarden.com"], - ["example.co.uk", "exampleapp.co.uk"], - ]; - - beforeEach(() => { - cryptoService = mock(); - encryptService = mock(); - stateService = mock(); - activeAccount = new BehaviorSubject("123"); - activeAccountUnlocked = new BehaviorSubject(true); - - stateService.getSettings.mockResolvedValue({ equivalentDomains: mockEquivalentDomains }); - stateService.activeAccount$ = activeAccount; - stateService.activeAccountUnlocked$ = activeAccountUnlocked; - (window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService); - - settingsService = new SettingsService(stateService); - }); - - afterEach(() => { - activeAccount.complete(); - activeAccountUnlocked.complete(); - }); - - describe("getEquivalentDomains", () => { - it("returns all equivalent domains for a URL", async () => { - const actual = settingsService.getEquivalentDomains("example.co.uk"); - const expected = new Set([ - "example.com", - "exampleapp.com", - "example.co.uk", - "ejemplo.es", - "exampleapp.co.uk", - ]); - expect(actual).toEqual(expected); - }); - - it("returns an empty set if there are no equivalent domains", () => { - const actual = settingsService.getEquivalentDomains("asdf"); - expect(actual).toEqual(new Set()); - }); - }); - - it("setEquivalentDomains", async () => { - await settingsService.setEquivalentDomains([["test2"], ["domains2"]]); - - expect(stateService.setSettings).toBeCalledTimes(1); - - expect((await firstValueFrom(settingsService.settings$)).equivalentDomains).toEqual([ - ["test2"], - ["domains2"], - ]); - }); - - it("clear", async () => { - await settingsService.clear(); - - expect(stateService.setSettings).toBeCalledTimes(1); - - expect(await firstValueFrom(settingsService.settings$)).toEqual({}); - }); -}); diff --git a/libs/common/src/services/settings.service.ts b/libs/common/src/services/settings.service.ts index d20efc80c1..9a4d04a147 100644 --- a/libs/common/src/services/settings.service.ts +++ b/libs/common/src/services/settings.service.ts @@ -3,13 +3,10 @@ import { BehaviorSubject, concatMap } from "rxjs"; import { SettingsService as SettingsServiceAbstraction } from "../abstractions/settings.service"; import { StateService } from "../platform/abstractions/state.service"; import { Utils } from "../platform/misc/utils"; -import { AccountSettingsSettings } from "../platform/models/domain/account"; export class SettingsService implements SettingsServiceAbstraction { - protected _settings: BehaviorSubject = new BehaviorSubject({}); protected _disableFavicon = new BehaviorSubject(null); - settings$ = this._settings.asObservable(); disableFavicon$ = this._disableFavicon.asObservable(); constructor(private stateService: StateService) { @@ -21,50 +18,17 @@ export class SettingsService implements SettingsServiceAbstraction { } if (!unlocked) { - this._settings.next({}); return; } - const data = await this.stateService.getSettings(); const disableFavicon = await this.stateService.getDisableFavicon(); - this._settings.next(data); this._disableFavicon.next(disableFavicon); }), ) .subscribe(); } - async setEquivalentDomains(equivalentDomains: string[][]): Promise { - const settings = this._settings.getValue() ?? {}; - - settings.equivalentDomains = equivalentDomains; - - this._settings.next(settings); - await this.stateService.setSettings(settings); - } - - getEquivalentDomains(url: string): Set { - const domain = Utils.getDomain(url); - if (domain == null) { - return new Set(); - } - - const settings = this._settings.getValue(); - - let result: string[] = []; - - if (settings?.equivalentDomains != null) { - settings.equivalentDomains - .filter((ed) => ed.length > 0 && ed.includes(domain)) - .forEach((ed) => { - result = result.concat(ed); - }); - } - - return new Set(result); - } - async setDisableFavicon(value: boolean) { this._disableFavicon.next(value); await this.stateService.setDisableFavicon(value); @@ -73,12 +37,4 @@ export class SettingsService implements SettingsServiceAbstraction { getDisableFavicon(): boolean { return this._disableFavicon.getValue(); } - - async clear(userId?: string): Promise { - if (userId == null || userId == (await this.stateService.getUserId())) { - this._settings.next({}); - } - - await this.stateService.setSettings(null, { userId: userId }); - } } diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index b124ba7585..6c97bec0aa 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -28,7 +28,9 @@ import { FixPremiumMigrator } from "./migrations/3-fix-premium"; import { PolicyMigrator } from "./migrations/30-move-policy-state-to-state-provider"; import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-menu-to-autofill-settings-state-provider"; import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language"; -import { LocalDataMigrator } from "./migrations/33-move-local-data-to-state-provider"; +import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers"; +import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to-state-providers"; +import { LocalDataMigrator } from "./migrations/35-move-local-data-to-state-provider"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; @@ -38,7 +40,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 2; -export const CURRENT_VERSION = 33; +export const CURRENT_VERSION = 35; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -74,7 +76,9 @@ export function createMigrationBuilder() { .with(PolicyMigrator, 29, 30) .with(EnableContextMenuMigrator, 30, 31) .with(PreferredLanguageMigrator, 31, 32) - .with(LocalDataMigrator, 32, CURRENT_VERSION); + .with(AppIdMigrator, 32, 33) + .with(DomainSettingsMigrator, 33, 34) + .with(LocalDataMigrator, 34, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts new file mode 100644 index 0000000000..b0a17011e0 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts @@ -0,0 +1,213 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + ANONYMOUS_APP_ID_KEY, + APP_ID_KEY, + AppIdMigrator, +} from "./33-move-app-id-to-state-providers"; + +function exampleJSON() { + return { + appId: "appId", + anonymousAppId: "anonymousAppId", + otherStuff: "otherStuff1", + }; +} + +function missingAppIdJSON() { + return { + anonymousAppId: "anonymousAppId", + otherStuff: "otherStuff1", + }; +} + +function missingAnonymousAppIdJSON() { + return { + appId: "appId", + otherStuff: "otherStuff1", + }; +} + +function missingBothJSON() { + return { + otherStuff: "otherStuff1", + }; +} + +function rollbackJSON() { + return { + global_applicationId_appId: "appId", + global_applicationId_anonymousAppId: "anonymousAppId", + otherStuff: "otherStuff1", + }; +} + +describe("AppIdMigrator", () => { + let helper: MockProxy; + let sut: AppIdMigrator; + + describe("migrate with both ids", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 32); + sut = new AppIdMigrator(32, 33); + }); + + it("removes appId", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("appId", null); + }); + + it("removes anonymousAppId", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("anonymousAppId", null); + }); + + it("sets appId", async () => { + await sut.migrate(helper); + expect(helper.setToGlobal).toHaveBeenCalledWith(APP_ID_KEY, "appId"); + }); + + it("sets anonymousAppId", async () => { + await sut.migrate(helper); + expect(helper.setToGlobal).toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, "anonymousAppId"); + }); + }); + + describe("migrate with missing appId", () => { + beforeEach(() => { + helper = mockMigrationHelper(missingAppIdJSON(), 32); + sut = new AppIdMigrator(32, 33); + }); + + it("does not set appId", async () => { + await sut.migrate(helper); + expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any()); + }); + + it("removes anonymousAppId", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("anonymousAppId", null); + }); + + it("does not set appId", async () => { + await sut.migrate(helper); + expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any()); + }); + + it("sets anonymousAppId", async () => { + await sut.migrate(helper); + expect(helper.setToGlobal).toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, "anonymousAppId"); + }); + }); + + describe("migrate with missing anonymousAppId", () => { + beforeEach(() => { + helper = mockMigrationHelper(missingAnonymousAppIdJSON(), 32); + sut = new AppIdMigrator(32, 33); + }); + + it("sets appId", async () => { + await sut.migrate(helper); + expect(helper.setToGlobal).toHaveBeenCalledWith(APP_ID_KEY, "appId"); + }); + + it("does not set anonymousAppId", async () => { + await sut.migrate(helper); + expect(helper.setToGlobal).not.toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, any()); + }); + + it("removes appId", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("appId", null); + }); + + it("does not remove anonymousAppId", async () => { + await sut.migrate(helper); + expect(helper.set).not.toHaveBeenCalledWith("anonymousAppId", any()); + }); + }); + + describe("migrate with missing appId and anonymousAppId", () => { + beforeEach(() => { + helper = mockMigrationHelper(missingBothJSON(), 32); + sut = new AppIdMigrator(32, 33); + }); + + it("does not set appId", async () => { + await sut.migrate(helper); + expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any()); + }); + + it("does not set anonymousAppId", async () => { + await sut.migrate(helper); + expect(helper.setToGlobal).not.toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, any()); + }); + + it("does not remove appId", async () => { + await sut.migrate(helper); + expect(helper.set).not.toHaveBeenCalledWith("appId", any()); + }); + + it("does not remove anonymousAppId", async () => { + await sut.migrate(helper); + expect(helper.set).not.toHaveBeenCalledWith("anonymousAppId", any()); + }); + }); + + describe("rollback with both Ids", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 33); + sut = new AppIdMigrator(32, 33); + }); + + it("removes appId", async () => { + await sut.rollback(helper); + expect(helper.setToGlobal).toHaveBeenCalledWith(APP_ID_KEY, null); + }); + + it("sets appId", async () => { + await sut.rollback(helper); + expect(helper.set).toHaveBeenCalledWith("appId", "appId"); + }); + + it("removes anonymousAppId", async () => { + await sut.rollback(helper); + expect(helper.setToGlobal).toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, null); + }); + + it("sets anonymousAppId", async () => { + await sut.rollback(helper); + expect(helper.set).toHaveBeenCalledWith("anonymousAppId", "anonymousAppId"); + }); + }); + + describe("rollback missing both Ids", () => { + beforeEach(() => { + helper = mockMigrationHelper(missingBothJSON(), 33); + sut = new AppIdMigrator(32, 33); + }); + + it("does not set appId for providers", async () => { + await sut.rollback(helper); + expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any()); + }); + + it("does not set anonymousAppId for providers", async () => { + await sut.rollback(helper); + expect(helper.setToGlobal).not.toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, any()); + }); + + it("does not revert appId", async () => { + await sut.rollback(helper); + expect(helper.set).not.toHaveBeenCalledWith("appId", any()); + }); + + it("does not revert anonymousAppId", async () => { + await sut.rollback(helper); + expect(helper.set).not.toHaveBeenCalledWith("anonymousAppId", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts b/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts new file mode 100644 index 0000000000..1dc763c19e --- /dev/null +++ b/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts @@ -0,0 +1,46 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +export const APP_ID_STORAGE_KEY = "appId"; +export const ANONYMOUS_APP_ID_STORAGE_KEY = "anonymousAppId"; + +export const APP_ID_KEY: KeyDefinitionLike = { + key: APP_ID_STORAGE_KEY, + stateDefinition: { name: "applicationId" }, +}; + +export const ANONYMOUS_APP_ID_KEY: KeyDefinitionLike = { + key: ANONYMOUS_APP_ID_STORAGE_KEY, + stateDefinition: { name: "applicationId" }, +}; + +export class AppIdMigrator extends Migrator<32, 33> { + async migrate(helper: MigrationHelper): Promise { + const appId = await helper.get(APP_ID_STORAGE_KEY); + const anonymousAppId = await helper.get(ANONYMOUS_APP_ID_STORAGE_KEY); + + if (appId != null) { + await helper.setToGlobal(APP_ID_KEY, appId); + await helper.set(APP_ID_STORAGE_KEY, null); + } + + if (anonymousAppId != null) { + await helper.setToGlobal(ANONYMOUS_APP_ID_KEY, anonymousAppId); + await helper.set(ANONYMOUS_APP_ID_STORAGE_KEY, null); + } + } + + async rollback(helper: MigrationHelper): Promise { + const appId = await helper.getFromGlobal(APP_ID_KEY); + const anonymousAppId = await helper.getFromGlobal(ANONYMOUS_APP_ID_KEY); + + if (appId != null) { + await helper.set(APP_ID_STORAGE_KEY, appId); + await helper.setToGlobal(APP_ID_KEY, null); + } + if (anonymousAppId != null) { + await helper.set(ANONYMOUS_APP_ID_STORAGE_KEY, anonymousAppId); + await helper.setToGlobal(ANONYMOUS_APP_ID_KEY, null); + } + } +} diff --git a/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts new file mode 100644 index 0000000000..7498769194 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts @@ -0,0 +1,255 @@ +import { any, MockProxy } from "jest-mock-extended"; + +import { StateDefinitionLike, MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { DomainSettingsMigrator } from "./34-move-domain-settings-to-state-providers"; + +const mockNeverDomains = { "bitwarden.test": null, locahost: null, "www.example.com": null } as { + [key: string]: null; +}; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + neverDomains: mockNeverDomains, + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + settings: { + defaultUriMatch: 3, + settings: { + equivalentDomains: [] as string[][], + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + settings: { + settings: { + equivalentDomains: [["apple.com", "icloud.com"]], + }, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + "user-3": { + settings: { + defaultUriMatch: 1, + otherStuff: "otherStuff6", + }, + otherStuff: "otherStuff7", + }, + "user-4": { + settings: { + otherStuff: "otherStuff8", + }, + otherStuff: "otherStuff9", + }, + }; +} + +function rollbackJSON() { + return { + global_domainSettings_neverDomains: mockNeverDomains, + "user_user-1_domainSettings_defaultUriMatchStrategy": 3, + "user_user-1_domainSettings_equivalentDomains": [] as string[][], + "user_user-2_domainSettings_equivalentDomains": [["apple.com", "icloud.com"]], + "user_user-3_domainSettings_defaultUriMatchStrategy": 1, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + "user-3": { + settings: { + otherStuff: "otherStuff6", + }, + otherStuff: "otherStuff7", + }, + "user-4": { + settings: { + otherStuff: "otherStuff8", + }, + otherStuff: "otherStuff9", + }, + }; +} + +const domainSettingsStateDefinition: { + stateDefinition: StateDefinitionLike; +} = { + stateDefinition: { + name: "domainSettings", + }, +}; + +describe("DomainSettingsMigrator", () => { + let helper: MockProxy; + let sut: DomainSettingsMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 33); + sut = new DomainSettingsMigrator(33, 34); + }); + + it("should remove global neverDomains and defaultUriMatch and equivalentDomains settings from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledTimes(4); + expect(helper.set).toHaveBeenCalledWith("global", { + otherStuff: "otherStuff1", + }); + expect(helper.set).toHaveBeenCalledWith("user-1", { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("user-1", { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("user-2", { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + expect(helper.set).toHaveBeenCalledWith("user-3", { + settings: { + otherStuff: "otherStuff6", + }, + otherStuff: "otherStuff7", + }); + }); + + it("should set global neverDomains and defaultUriMatchStrategy and equivalentDomains setting values for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + expect(helper.setToGlobal).toHaveBeenCalledWith( + { ...domainSettingsStateDefinition, key: "neverDomains" }, + mockNeverDomains, + ); + + expect(helper.setToUser).toHaveBeenCalledTimes(4); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + { ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" }, + 3, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + { ...domainSettingsStateDefinition, key: "equivalentDomains" }, + [], + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-2", + { ...domainSettingsStateDefinition, key: "equivalentDomains" }, + [["apple.com", "icloud.com"]], + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-3", + { ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" }, + 1, + ); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 34); + sut = new DomainSettingsMigrator(33, 34); + }); + + it("should null out new values globally and for each account", async () => { + await sut.rollback(helper); + + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + expect(helper.setToGlobal).toHaveBeenCalledWith( + { ...domainSettingsStateDefinition, key: "neverDomains" }, + null, + ); + + expect(helper.setToUser).toHaveBeenCalledTimes(4); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + { ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" }, + null, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + { ...domainSettingsStateDefinition, key: "equivalentDomains" }, + null, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-2", + { ...domainSettingsStateDefinition, key: "equivalentDomains" }, + null, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-3", + { ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" }, + null, + ); + }); + + it("should add explicit value back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledTimes(4); + expect(helper.set).toHaveBeenCalledWith("global", { + neverDomains: mockNeverDomains, + otherStuff: "otherStuff1", + }); + expect(helper.set).toHaveBeenCalledWith("user-1", { + settings: { + defaultUriMatch: 3, + settings: { + equivalentDomains: [] as string[][], + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("user-2", { + settings: { + settings: { + equivalentDomains: [["apple.com", "icloud.com"]], + }, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + expect(helper.set).toHaveBeenCalledWith("user-3", { + settings: { + defaultUriMatch: 1, + otherStuff: "otherStuff6", + }, + otherStuff: "otherStuff7", + }); + }); + + it("should not try to restore values to missing accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("user-4", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts b/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts new file mode 100644 index 0000000000..1c5681f2aa --- /dev/null +++ b/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts @@ -0,0 +1,167 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +const UriMatchStrategy = { + Domain: 0, + Host: 1, + StartsWith: 2, + Exact: 3, + RegularExpression: 4, + Never: 5, +} as const; + +type UriMatchStrategySetting = (typeof UriMatchStrategy)[keyof typeof UriMatchStrategy]; + +type ExpectedAccountState = { + settings?: { + defaultUriMatch?: UriMatchStrategySetting; + settings?: { + equivalentDomains?: string[][]; + }; + }; +}; + +type ExpectedGlobalState = { + neverDomains?: { [key: string]: null }; +}; + +const defaultUriMatchStrategyDefinition: KeyDefinitionLike = { + stateDefinition: { + name: "domainSettings", + }, + key: "defaultUriMatchStrategy", +}; + +const equivalentDomainsDefinition: KeyDefinitionLike = { + stateDefinition: { + name: "domainSettings", + }, + key: "equivalentDomains", +}; + +const neverDomainsDefinition: KeyDefinitionLike = { + stateDefinition: { + name: "domainSettings", + }, + key: "neverDomains", +}; + +export class DomainSettingsMigrator extends Migrator<33, 34> { + async migrate(helper: MigrationHelper): Promise { + let updateAccount = false; + + // global state ("neverDomains") + const globalState = await helper.get("global"); + + if (globalState?.neverDomains != null) { + await helper.setToGlobal(neverDomainsDefinition, globalState.neverDomains); + + // delete `neverDomains` from state global + delete globalState.neverDomains; + + await helper.set("global", globalState); + } + + // account state ("defaultUriMatch" and "settings.equivalentDomains") + const accounts = await helper.getAccounts(); + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + + // migrate account state + async function migrateAccount(userId: string, account: ExpectedAccountState): Promise { + const accountSettings = account?.settings; + + if (accountSettings?.defaultUriMatch != undefined) { + await helper.setToUser( + userId, + defaultUriMatchStrategyDefinition, + accountSettings.defaultUriMatch, + ); + delete account.settings.defaultUriMatch; + + updateAccount = true; + } + + if (accountSettings?.settings?.equivalentDomains != undefined) { + await helper.setToUser( + userId, + equivalentDomainsDefinition, + accountSettings.settings.equivalentDomains, + ); + delete account.settings.settings.equivalentDomains; + delete account.settings.settings; + + updateAccount = true; + } + + if (updateAccount) { + // update the state account settings with the migrated values deleted + await helper.set(userId, account); + } + } + } + + async rollback(helper: MigrationHelper): Promise { + let updateAccount = false; + + // global state ("neverDomains") + const globalState = (await helper.get("global")) || {}; + const neverDomains: { [key: string]: null } = + await helper.getFromGlobal(neverDomainsDefinition); + + if (neverDomains != null) { + await helper.set("global", { + ...globalState, + neverDomains: neverDomains, + }); + + // remove the global state provider framework key for `neverDomains` + await helper.setToGlobal(neverDomainsDefinition, null); + } + + // account state ("defaultUriMatchStrategy" and "equivalentDomains") + const accounts = await helper.getAccounts(); + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + + // rollback account state + async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise { + let settings = account?.settings || {}; + + const defaultUriMatchStrategy: UriMatchStrategySetting = await helper.getFromUser( + userId, + defaultUriMatchStrategyDefinition, + ); + + const equivalentDomains: string[][] = await helper.getFromUser( + userId, + equivalentDomainsDefinition, + ); + + // update new settings and remove the account state provider framework keys for the rolled back values + if (defaultUriMatchStrategy != null) { + settings = { ...settings, defaultUriMatch: defaultUriMatchStrategy }; + + await helper.setToUser(userId, defaultUriMatchStrategyDefinition, null); + + updateAccount = true; + } + + if (equivalentDomains != null) { + settings = { ...settings, settings: { equivalentDomains } }; + + await helper.setToUser(userId, equivalentDomainsDefinition, null); + + updateAccount = true; + } + + // commit updated settings to state + if (updateAccount) { + await helper.set(userId, { + ...account, + settings, + }); + } + } + } +} diff --git a/libs/common/src/state-migrations/migrations/33-move-local-data-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/35-move-local-data-to-state-provider.spec.ts similarity index 97% rename from libs/common/src/state-migrations/migrations/33-move-local-data-to-state-provider.spec.ts rename to libs/common/src/state-migrations/migrations/35-move-local-data-to-state-provider.spec.ts index e5a81a47e9..1000f14caa 100644 --- a/libs/common/src/state-migrations/migrations/33-move-local-data-to-state-provider.spec.ts +++ b/libs/common/src/state-migrations/migrations/35-move-local-data-to-state-provider.spec.ts @@ -3,7 +3,7 @@ import { MockProxy } from "jest-mock-extended"; import { MigrationHelper } from "../migration-helper"; import { mockMigrationHelper } from "../migration-helper.spec"; -import { LocalDataMigrator } from "./33-move-local-data-to-state-provider"; +import { LocalDataMigrator } from "./35-move-local-data-to-state-provider"; function exampleJSON() { return { diff --git a/libs/common/src/state-migrations/migrations/33-move-local-data-to-state-provider.ts b/libs/common/src/state-migrations/migrations/35-move-local-data-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/33-move-local-data-to-state-provider.ts rename to libs/common/src/state-migrations/migrations/35-move-local-data-to-state-provider.ts diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 7fe606174c..39c2f760a9 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -1,5 +1,5 @@ +import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; -import { UriMatchType } from "../enums"; import { CipherType } from "../enums/cipher-type"; import { CipherData } from "../models/data/cipher.data"; import { Cipher } from "../models/domain/cipher"; @@ -25,7 +25,7 @@ export abstract class CipherService { getAllDecryptedForUrl: ( url: string, includeOtherTypes?: CipherType[], - defaultMatch?: UriMatchType, + defaultMatch?: UriMatchStrategySetting, ) => Promise; getAllFromApiForOrganization: (organizationId: string) => Promise; /** diff --git a/libs/common/src/vault/enums/index.ts b/libs/common/src/vault/enums/index.ts index e7715f6c04..d7d1d06d2b 100644 --- a/libs/common/src/vault/enums/index.ts +++ b/libs/common/src/vault/enums/index.ts @@ -3,4 +3,3 @@ export * from "./cipher-type"; export * from "./field-type.enum"; export * from "./linked-id-type.enum"; export * from "./secure-note-type.enum"; -export * from "./uri-match-type.enum"; diff --git a/libs/common/src/vault/enums/uri-match-type.enum.ts b/libs/common/src/vault/enums/uri-match-type.enum.ts deleted file mode 100644 index 4a4193ba46..0000000000 --- a/libs/common/src/vault/enums/uri-match-type.enum.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum UriMatchType { - Domain = 0, - Host = 1, - StartsWith = 2, - Exact = 3, - RegularExpression = 4, - Never = 5, -} diff --git a/libs/common/src/vault/models/api/login-uri.api.ts b/libs/common/src/vault/models/api/login-uri.api.ts index ace8a31700..853f181654 100644 --- a/libs/common/src/vault/models/api/login-uri.api.ts +++ b/libs/common/src/vault/models/api/login-uri.api.ts @@ -1,10 +1,10 @@ +import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { BaseResponse } from "../../../models/response/base.response"; -import { UriMatchType } from "../../enums"; export class LoginUriApi extends BaseResponse { uri: string; uriChecksum: string; - match: UriMatchType = null; + match: UriMatchStrategySetting = null; constructor(data: any = null) { super(data); diff --git a/libs/common/src/vault/models/data/login-uri.data.ts b/libs/common/src/vault/models/data/login-uri.data.ts index 973470ffc7..99d2f9a9eb 100644 --- a/libs/common/src/vault/models/data/login-uri.data.ts +++ b/libs/common/src/vault/models/data/login-uri.data.ts @@ -1,10 +1,10 @@ -import { UriMatchType } from "../../enums"; +import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { LoginUriApi } from "../api/login-uri.api"; export class LoginUriData { uri: string; uriChecksum: string; - match: UriMatchType = null; + match: UriMatchStrategySetting = null; constructor(data?: LoginUriApi) { if (data == null) { diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index 856d6d3887..a75d645831 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -2,13 +2,14 @@ import { mock } from "jest-mock-extended"; import { Jsonify } from "type-fest"; import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils"; +import { UriMatchStrategy } from "../../../models/domain/domain-service"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { EncString } from "../../../platform/models/domain/enc-string"; import { ContainerService } from "../../../platform/services/container.service"; import { InitializerKey } from "../../../platform/services/cryptography/initializer-key"; import { CipherService } from "../../abstractions/cipher.service"; -import { FieldType, SecureNoteType, UriMatchType } from "../../enums"; +import { FieldType, SecureNoteType } from "../../enums"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherData } from "../../models/data/cipher.data"; @@ -76,7 +77,11 @@ describe("Cipher DTO", () => { key: "EncryptedString", login: { uris: [ - { uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchType.Domain }, + { + uri: "EncryptedString", + uriChecksum: "EncryptedString", + match: UriMatchStrategy.Domain, + }, ], username: "EncryptedString", password: "EncryptedString", diff --git a/libs/common/src/vault/models/domain/login-uri.spec.ts b/libs/common/src/vault/models/domain/login-uri.spec.ts index 1c432cf8d7..c42b0cc9d1 100644 --- a/libs/common/src/vault/models/domain/login-uri.spec.ts +++ b/libs/common/src/vault/models/domain/login-uri.spec.ts @@ -2,9 +2,9 @@ import { MockProxy, mock } from "jest-mock-extended"; import { Jsonify } from "type-fest"; import { mockEnc, mockFromJson } from "../../../../spec"; +import { UriMatchStrategy } from "../../../models/domain/domain-service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { EncString } from "../../../platform/models/domain/enc-string"; -import { UriMatchType } from "../../enums"; import { LoginUriData } from "../data/login-uri.data"; import { LoginUri } from "./login-uri"; @@ -16,7 +16,7 @@ describe("LoginUri", () => { data = { uri: "encUri", uriChecksum: "encUriChecksum", - match: UriMatchType.Domain, + match: UriMatchStrategy.Domain, }; }); @@ -48,7 +48,7 @@ describe("LoginUri", () => { it("Decrypt", async () => { const loginUri = new LoginUri(); - loginUri.match = UriMatchType.Exact; + loginUri.match = UriMatchStrategy.Exact; loginUri.uri = mockEnc("uri"); const view = await loginUri.decrypt(null); @@ -103,13 +103,13 @@ describe("LoginUri", () => { const actual = LoginUri.fromJSON({ uri: "myUri", uriChecksum: "myUriChecksum", - match: UriMatchType.Domain, + match: UriMatchStrategy.Domain, } as Jsonify); expect(actual).toEqual({ uri: "myUri_fromJSON", uriChecksum: "myUriChecksum_fromJSON", - match: UriMatchType.Domain, + match: UriMatchStrategy.Domain, }); expect(actual).toBeInstanceOf(LoginUri); }); diff --git a/libs/common/src/vault/models/domain/login-uri.ts b/libs/common/src/vault/models/domain/login-uri.ts index dcdd1de294..0a0c5765a3 100644 --- a/libs/common/src/vault/models/domain/login-uri.ts +++ b/libs/common/src/vault/models/domain/login-uri.ts @@ -1,17 +1,17 @@ import { Jsonify } from "type-fest"; +import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { Utils } from "../../../platform/misc/utils"; import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { UriMatchType } from "../../enums"; import { LoginUriData } from "../data/login-uri.data"; import { LoginUriView } from "../view/login-uri.view"; export class LoginUri extends Domain { uri: EncString; uriChecksum: EncString | undefined; - match: UriMatchType; + match: UriMatchStrategySetting; constructor(obj?: LoginUriData) { super(); diff --git a/libs/common/src/vault/models/domain/login.spec.ts b/libs/common/src/vault/models/domain/login.spec.ts index 232ca035aa..e420a953e6 100644 --- a/libs/common/src/vault/models/domain/login.spec.ts +++ b/libs/common/src/vault/models/domain/login.spec.ts @@ -1,8 +1,8 @@ import { MockProxy, mock } from "jest-mock-extended"; import { mockEnc, mockFromJson } from "../../../../spec"; +import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; -import { UriMatchType } from "../../enums"; import { LoginData } from "../../models/data/login.data"; import { Login } from "../../models/domain/login"; import { LoginUri } from "../../models/domain/login-uri"; @@ -30,7 +30,7 @@ describe("Login DTO", () => { it("Convert from full LoginData", () => { const fido2CredentialData = initializeFido2Credential(new Fido2CredentialData()); const data: LoginData = { - uris: [{ uri: "uri", uriChecksum: "checksum", match: UriMatchType.Domain }], + uris: [{ uri: "uri", uriChecksum: "checksum", match: UriMatchStrategy.Domain }], username: "username", password: "password", passwordRevisionDate: "2022-01-31T12:00:00.000Z", @@ -82,7 +82,7 @@ describe("Login DTO", () => { totp: "encrypted totp", uris: [ { - match: null as UriMatchType, + match: null as UriMatchStrategySetting, _uri: "decrypted uri", _domain: null as string, _hostname: null as string, @@ -123,7 +123,7 @@ describe("Login DTO", () => { it("Converts from LoginData and back", () => { const data: LoginData = { - uris: [{ uri: "uri", uriChecksum: "checksum", match: UriMatchType.Domain }], + uris: [{ uri: "uri", uriChecksum: "checksum", match: UriMatchStrategy.Domain }], username: "username", password: "password", passwordRevisionDate: "2022-01-31T12:00:00.000Z", 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 2a806bb685..efc7509629 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 @@ -1,26 +1,26 @@ +import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { Utils } from "../../../platform/misc/utils"; -import { UriMatchType } from "../../enums"; import { LoginUriView } from "./login-uri.view"; const testData = [ { - match: UriMatchType.Host, + match: UriMatchStrategy.Host, uri: "http://example.com/login", expected: "http://example.com/login", }, { - match: UriMatchType.Host, + match: UriMatchStrategy.Host, uri: "bitwarden.com", expected: "http://bitwarden.com", }, { - match: UriMatchType.Host, + match: UriMatchStrategy.Host, uri: "bitwarden.de", expected: "http://bitwarden.de", }, { - match: UriMatchType.Host, + match: UriMatchStrategy.Host, uri: "bitwarden.br", expected: "http://bitwarden.br", }, @@ -41,7 +41,7 @@ const exampleUris = { describe("LoginUriView", () => { it("isWebsite() given an invalid domain should return false", async () => { const uri = new LoginUriView(); - Object.assign(uri, { match: UriMatchType.Host, uri: "bit!:_&ward.com" }); + Object.assign(uri, { match: UriMatchStrategy.Host, uri: "bit!:_&ward.com" }); expect(uri.isWebsite).toBe(false); }); @@ -67,32 +67,32 @@ describe("LoginUriView", () => { it(`canLaunch should return false when MatchDetection is set to Regex`, async () => { const uri = new LoginUriView(); - Object.assign(uri, { match: UriMatchType.RegularExpression, uri: "bitwarden.com" }); + Object.assign(uri, { match: UriMatchStrategy.RegularExpression, uri: "bitwarden.com" }); expect(uri.canLaunch).toBe(false); }); it(`canLaunch() should return false when the given protocol does not match CanLaunchWhiteList`, async () => { const uri = new LoginUriView(); - Object.assign(uri, { match: UriMatchType.Host, uri: "someprotocol://bitwarden.com" }); + Object.assign(uri, { match: UriMatchStrategy.Host, uri: "someprotocol://bitwarden.com" }); expect(uri.canLaunch).toBe(false); }); describe("uri matching", () => { describe("using domain matching", () => { it("matches the same domain", () => { - const uri = uriFactory(UriMatchType.Domain, exampleUris.standard); + const uri = uriFactory(UriMatchStrategy.Domain, exampleUris.standard); const actual = uri.matchesUri(exampleUris.subdomain, exampleUris.noEquivalentDomains()); expect(actual).toBe(true); }); it("matches equivalent domains", () => { - const uri = uriFactory(UriMatchType.Domain, exampleUris.standard); + const uri = uriFactory(UriMatchStrategy.Domain, exampleUris.standard); const actual = uri.matchesUri(exampleUris.differentDomain, exampleUris.equivalentDomains()); expect(actual).toBe(true); }); it("does not match a different domain", () => { - const uri = uriFactory(UriMatchType.Domain, exampleUris.standard); + const uri = uriFactory(UriMatchStrategy.Domain, exampleUris.standard); const actual = uri.matchesUri( exampleUris.differentDomain, exampleUris.noEquivalentDomains(), @@ -103,7 +103,7 @@ describe("LoginUriView", () => { // Actual integration test with the real blacklist, not ideal it("does not match domains that are blacklisted", () => { const googleEquivalentDomains = new Set(["google.com", "script.google.com"]); - const uri = uriFactory(UriMatchType.Domain, "google.com"); + const uri = uriFactory(UriMatchStrategy.Domain, "google.com"); const actual = uri.matchesUri("script.google.com", googleEquivalentDomains); @@ -113,13 +113,13 @@ describe("LoginUriView", () => { describe("using host matching", () => { it("matches the same host", () => { - const uri = uriFactory(UriMatchType.Host, Utils.getHost(exampleUris.standard)); + const uri = uriFactory(UriMatchStrategy.Host, Utils.getHost(exampleUris.standard)); const actual = uri.matchesUri(exampleUris.standard, exampleUris.noEquivalentDomains()); expect(actual).toBe(true); }); it("does not match a different host", () => { - const uri = uriFactory(UriMatchType.Host, Utils.getHost(exampleUris.differentDomain)); + const uri = uriFactory(UriMatchStrategy.Host, Utils.getHost(exampleUris.differentDomain)); const actual = uri.matchesUri(exampleUris.standard, exampleUris.noEquivalentDomains()); expect(actual).toBe(false); }); @@ -127,13 +127,13 @@ describe("LoginUriView", () => { describe("using exact matching", () => { it("matches if both uris are the same", () => { - const uri = uriFactory(UriMatchType.Exact, exampleUris.standard); + const uri = uriFactory(UriMatchStrategy.Exact, exampleUris.standard); const actual = uri.matchesUri(exampleUris.standard, exampleUris.noEquivalentDomains()); expect(actual).toBe(true); }); it("does not match if the uris are different", () => { - const uri = uriFactory(UriMatchType.Exact, exampleUris.standard); + const uri = uriFactory(UriMatchStrategy.Exact, exampleUris.standard); const actual = uri.matchesUri( exampleUris.standard + "#", exampleUris.noEquivalentDomains(), @@ -144,7 +144,7 @@ describe("LoginUriView", () => { describe("using startsWith matching", () => { it("matches if the target URI starts with the saved URI", () => { - const uri = uriFactory(UriMatchType.StartsWith, exampleUris.standard); + const uri = uriFactory(UriMatchStrategy.StartsWith, exampleUris.standard); const actual = uri.matchesUri( exampleUris.standard + "#bookmark", exampleUris.noEquivalentDomains(), @@ -153,7 +153,7 @@ describe("LoginUriView", () => { }); it("does not match if the start of the uri is not the same", () => { - const uri = uriFactory(UriMatchType.StartsWith, exampleUris.standard); + const uri = uriFactory(UriMatchStrategy.StartsWith, exampleUris.standard); const actual = uri.matchesUri( exampleUris.standard.slice(1), exampleUris.noEquivalentDomains(), @@ -164,13 +164,13 @@ describe("LoginUriView", () => { describe("using regular expression matching", () => { it("matches if the regular expression matches", () => { - const uri = uriFactory(UriMatchType.RegularExpression, exampleUris.standard); + const uri = uriFactory(UriMatchStrategy.RegularExpression, exampleUris.standard); const actual = uri.matchesUri(exampleUris.standardRegex, exampleUris.noEquivalentDomains()); expect(actual).toBe(false); }); it("does not match if the regular expression does not match", () => { - const uri = uriFactory(UriMatchType.RegularExpression, exampleUris.standardNotMatching); + const uri = uriFactory(UriMatchStrategy.RegularExpression, exampleUris.standardNotMatching); const actual = uri.matchesUri(exampleUris.standardRegex, exampleUris.noEquivalentDomains()); expect(actual).toBe(false); }); @@ -178,7 +178,7 @@ describe("LoginUriView", () => { describe("using never matching", () => { it("does not match even if uris are identical", () => { - const uri = uriFactory(UriMatchType.Never, exampleUris.standard); + const uri = uriFactory(UriMatchStrategy.Never, exampleUris.standard); const actual = uri.matchesUri(exampleUris.standard, exampleUris.noEquivalentDomains()); expect(actual).toBe(false); }); @@ -186,7 +186,7 @@ describe("LoginUriView", () => { }); }); -function uriFactory(match: UriMatchType, uri: string) { +function uriFactory(match: UriMatchStrategySetting, uri: string) { const loginUri = new LoginUriView(); loginUri.match = match; loginUri.uri = uri; 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 79b0432ec8..f3bc0a492d 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -1,13 +1,13 @@ import { Jsonify } from "type-fest"; +import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { View } from "../../../models/view/view"; import { SafeUrls } from "../../../platform/misc/safe-urls"; import { Utils } from "../../../platform/misc/utils"; -import { UriMatchType } from "../../enums"; import { LoginUri } from "../domain/login-uri"; export class LoginUriView implements View { - match: UriMatchType = null; + match: UriMatchStrategySetting = null; private _uri: string = null; private _domain: string = null; @@ -44,7 +44,7 @@ export class LoginUriView implements View { } get hostname(): string { - if (this.match === UriMatchType.RegularExpression) { + if (this.match === UriMatchStrategy.RegularExpression) { return null; } if (this._hostname == null && this.uri != null) { @@ -58,7 +58,7 @@ export class LoginUriView implements View { } get host(): string { - if (this.match === UriMatchType.RegularExpression) { + if (this.match === UriMatchStrategy.RegularExpression) { return null; } if (this._host == null && this.uri != null) { @@ -92,7 +92,7 @@ export class LoginUriView implements View { if (this._canLaunch != null) { return this._canLaunch; } - if (this.uri != null && this.match !== UriMatchType.RegularExpression) { + if (this.uri != null && this.match !== UriMatchStrategy.RegularExpression) { this._canLaunch = SafeUrls.canLaunch(this.launchUri); } else { this._canLaunch = false; @@ -113,30 +113,30 @@ export class LoginUriView implements View { matchesUri( targetUri: string, equivalentDomains: Set, - defaultUriMatch: UriMatchType = null, + defaultUriMatch: UriMatchStrategySetting = null, ): boolean { if (!this.uri || !targetUri) { return false; } let matchType = this.match ?? defaultUriMatch; - matchType ??= UriMatchType.Domain; + matchType ??= UriMatchStrategy.Domain; const targetDomain = Utils.getDomain(targetUri); const matchDomains = equivalentDomains.add(targetDomain); switch (matchType) { - case UriMatchType.Domain: + case UriMatchStrategy.Domain: return this.matchesDomain(targetUri, matchDomains); - case UriMatchType.Host: { + case UriMatchStrategy.Host: { const urlHost = Utils.getHost(targetUri); return urlHost != null && urlHost === Utils.getHost(this.uri); } - case UriMatchType.Exact: + case UriMatchStrategy.Exact: return targetUri === this.uri; - case UriMatchType.StartsWith: + case UriMatchStrategy.StartsWith: return targetUri.startsWith(this.uri); - case UriMatchType.RegularExpression: + case UriMatchStrategy.RegularExpression: try { const regex = new RegExp(this.uri, "i"); return regex.test(targetUri); @@ -144,7 +144,7 @@ export class LoginUriView implements View { // Invalid regex return false; } - case UriMatchType.Never: + case UriMatchStrategy.Never: return false; default: break; diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index 59b21b901d..53bbc22022 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -1,6 +1,7 @@ +import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { Utils } from "../../../platform/misc/utils"; import { DeepJsonify } from "../../../types/deep-jsonify"; -import { LoginLinkedId as LinkedId, UriMatchType } from "../../enums"; +import { LoginLinkedId as LinkedId } from "../../enums"; import { linkedFieldOption } from "../../linked-field-option.decorator"; import { Login } from "../domain/login"; @@ -71,7 +72,7 @@ export class LoginView extends ItemView { matchesUri( targetUri: string, equivalentDomains: Set, - defaultUriMatch: UriMatchType = null, + defaultUriMatch: UriMatchStrategySetting = null, ): boolean { if (this.uris == null) { return false; diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index cc9f0c1ba2..4b071ee2a4 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -6,8 +6,9 @@ import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { makeStaticByteArray } from "../../../spec/utils"; import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; -import { SettingsService } from "../../abstractions/settings.service"; import { AutofillSettingsService } from "../../autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; +import { UriMatchStrategy } from "../../models/domain/domain-service"; import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; @@ -21,7 +22,7 @@ import { ContainerService } from "../../platform/services/container.service"; import { UserId } from "../../types/guid"; import { CipherKey, OrgKey } from "../../types/key"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; -import { UriMatchType, FieldType } from "../enums"; +import { FieldType } from "../enums"; import { CipherRepromptType } from "../enums/cipher-reprompt-type"; import { CipherType } from "../enums/cipher-type"; import { CipherData } from "../models/data/cipher.data"; @@ -57,7 +58,9 @@ const cipherData: CipherData = { key: "EncKey", reprompt: CipherRepromptType.None, login: { - uris: [{ uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchType.Domain }], + uris: [ + { uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchStrategy.Domain }, + ], username: "EncryptedString", password: "EncryptedString", passwordRevisionDate: "2022-01-31T12:00:00.000Z", @@ -105,7 +108,7 @@ describe("Cipher Service", () => { const cryptoService = mock(); const stateService = mock(); const autofillSettingsService = mock(); - const settingsService = mock(); + const domainSettingsService = mock(); const apiService = mock(); const cipherFileUploadService = mock(); const i18nService = mock(); @@ -126,7 +129,7 @@ describe("Cipher Service", () => { cipherService = new CipherService( cryptoService, - settingsService, + domainSettingsService, apiService, i18nService, searchService, @@ -286,7 +289,7 @@ describe("Cipher Service", () => { it("should add a uri hash to login uris", async () => { encryptService.hash.mockImplementation((value) => Promise.resolve(`${value} hash`)); cipherView.login.uris = [ - { uri: "uri", match: UriMatchType.RegularExpression } as LoginUriView, + { uri: "uri", match: UriMatchStrategy.RegularExpression } as LoginUriView, ]; const domain = await cipherService.encrypt(cipherView); @@ -295,7 +298,7 @@ describe("Cipher Service", () => { { uri: new EncString("uri has been encrypted"), uriChecksum: new EncString("uri hash has been encrypted"), - match: UriMatchType.RegularExpression, + match: UriMatchStrategy.RegularExpression, }, ]); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index f5847a38b1..6b6a12dc69 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -4,8 +4,9 @@ import { Jsonify } from "type-fest"; import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; -import { SettingsService } from "../../abstractions/settings.service"; import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; +import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { ErrorResponse } from "../../models/response/error.response"; import { ListResponse } from "../../models/response/list.response"; import { View } from "../../models/view/view"; @@ -32,7 +33,7 @@ import { CipherId } from "../../types/guid"; import { UserKey, OrgKey } from "../../types/key"; import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; -import { FieldType, UriMatchType } from "../enums"; +import { FieldType } from "../enums"; import { CipherType } from "../enums/cipher-type"; import { CipherData } from "../models/data/cipher.data"; import { LocalData } from "../models/data/local.data"; @@ -94,7 +95,7 @@ export class CipherService implements CipherServiceAbstraction { constructor( private cryptoService: CryptoService, - private settingsService: SettingsService, + private domainSettingsService: DomainSettingsService, private apiService: ApiService, private i18nService: I18nService, private searchService: SearchService, @@ -410,15 +411,17 @@ export class CipherService implements CipherServiceAbstraction { async getAllDecryptedForUrl( url: string, includeOtherTypes?: CipherType[], - defaultMatch: UriMatchType = null, + defaultMatch: UriMatchStrategySetting = null, ): Promise { if (url == null && includeOtherTypes == null) { return Promise.resolve([]); } - const equivalentDomains = this.settingsService.getEquivalentDomains(url); + const equivalentDomains = await firstValueFrom( + this.domainSettingsService.getUrlEquivalentDomains(url), + ); const ciphers = await this.getAllDecrypted(); - defaultMatch ??= await this.stateService.getDefaultUriMatch(); + defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$); return ciphers.filter((cipher) => { const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null; @@ -564,12 +567,12 @@ export class CipherService implements CipherServiceAbstraction { return; } - let domains = await this.stateService.getNeverDomains(); + let domains = await firstValueFrom(this.domainSettingsService.neverDomains$); if (!domains) { domains = {}; } domains[domain] = null; - await this.stateService.setNeverDomains(domains); + await this.domainSettingsService.setNeverDomains(domains); } async createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise { diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts index 46d5e2d9c8..dd90c53cb1 100644 --- a/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts @@ -3,6 +3,7 @@ import { of } from "rxjs"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; @@ -34,6 +35,7 @@ describe("FidoAuthenticatorService", () => { let authService!: MockProxy; let stateService!: MockProxy; let vaultSettingsService: MockProxy; + let domainSettingsService: MockProxy; let client!: Fido2ClientService; let tab!: chrome.tabs.Tab; @@ -43,6 +45,7 @@ describe("FidoAuthenticatorService", () => { authService = mock(); stateService = mock(); vaultSettingsService = mock(); + domainSettingsService = mock(); client = new Fido2ClientService( authenticator, @@ -50,9 +53,11 @@ describe("FidoAuthenticatorService", () => { authService, stateService, vaultSettingsService, + domainSettingsService, ); configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any); vaultSettingsService.enablePasskeys$ = of(true); + domainSettingsService.neverDomains$ = of({}); authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked); tab = { id: 123, windowId: 456 } as chrome.tabs.Tab; }); @@ -130,7 +135,7 @@ describe("FidoAuthenticatorService", () => { origin: "https://bitwarden.com", rp: { id: "bitwarden.com", name: "Bitwarden" }, }); - stateService.getNeverDomains.mockResolvedValue({ "bitwarden.com": null }); + domainSettingsService.neverDomains$ = of({ "bitwarden.com": null }); const result = async () => await client.createCredential(params, tab); @@ -376,7 +381,8 @@ describe("FidoAuthenticatorService", () => { const params = createParams({ origin: "https://bitwarden.com", }); - stateService.getNeverDomains.mockResolvedValue({ "bitwarden.com": null }); + + domainSettingsService.neverDomains$ = of({ "bitwarden.com": null }); const result = async () => await client.assertCredential(params, tab); diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.ts b/libs/common/src/vault/services/fido2/fido2-client.service.ts index 764973b956..257700453e 100644 --- a/libs/common/src/vault/services/fido2/fido2-client.service.ts +++ b/libs/common/src/vault/services/fido2/fido2-client.service.ts @@ -3,6 +3,7 @@ import { parse } from "tldts"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; import { LogService } from "../../../platform/abstractions/log.service"; import { StateService } from "../../../platform/abstractions/state.service"; @@ -44,6 +45,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { private authService: AuthService, private stateService: StateService, private vaultSettingsService: VaultSettingsService, + private domainSettingsService: DomainSettingsService, private logService?: LogService, ) {} @@ -52,7 +54,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { const isUserLoggedIn = (await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut; - const neverDomains = await this.stateService.getNeverDomains(); + const neverDomains = await firstValueFrom(this.domainSettingsService.neverDomains$); + const isExcludedDomain = neverDomains != null && hostname in neverDomains; const serverConfig = await firstValueFrom(this.configService.serverConfig$); diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index c0105af758..200acf97f1 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -1,5 +1,4 @@ import { ApiService } from "../../../abstractions/api.service"; -import { SettingsService } from "../../../abstractions/settings.service"; import { InternalOrganizationServiceAbstraction } from "../../../admin-console/abstractions/organization/organization.service.abstraction"; import { InternalPolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "../../../admin-console/abstractions/provider.service"; @@ -10,6 +9,7 @@ import { ProviderData } from "../../../admin-console/models/data/provider.data"; import { PolicyResponse } from "../../../admin-console/models/response/policy.response"; import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; +import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { DomainsResponse } from "../../../models/response/domains.response"; import { SyncCipherNotification, @@ -44,7 +44,7 @@ export class SyncService implements SyncServiceAbstraction { constructor( private apiService: ApiService, - private settingsService: SettingsService, + private domainSettingsService: DomainSettingsService, private folderService: InternalFolderService, private cipherService: CipherService, private cryptoService: CryptoService, @@ -457,7 +457,7 @@ export class SyncService implements SyncServiceAbstraction { }); } - return this.settingsService.setEquivalentDomains(eqDomains); + return this.domainSettingsService.setEquivalentDomains(eqDomains); } private async syncPolicies(response: PolicyResponse[]) { diff --git a/package-lock.json b/package-lock.json index 07ae1a4100..468ae14ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,7 +123,6 @@ "autoprefixer": "10.4.18", "base64-loader": "1.0.0", "chromatic": "10.9.6", - "clean-webpack-plugin": "4.0.0", "concurrently": "8.2.2", "copy-webpack-plugin": "11.0.0", "cross-env": "7.0.3", @@ -234,7 +233,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2024.3.0", + "version": "2024.3.1", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -11053,16 +11052,6 @@ "@types/node": "*" } }, - "node_modules/@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "dev": true, - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, "node_modules/@types/graceful-fs": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", @@ -11311,12 +11300,6 @@ "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==", "dev": true }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "dev": true - }, "node_modules/@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -13806,15 +13789,6 @@ "node": ">=8" } }, - "node_modules/array-uniq": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", - "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", @@ -15791,126 +15765,6 @@ "node": ">=6" } }, - "node_modules/clean-webpack-plugin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz", - "integrity": "sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==", - "dev": true, - "dependencies": { - "del": "^4.1.1" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "webpack": ">=4.0.0 <6.0.0" - } - }, - "node_modules/clean-webpack-plugin/node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "dev": true, - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clean-webpack-plugin/node_modules/del": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", - "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", - "dev": true, - "dependencies": { - "@types/glob": "^7.1.1", - "globby": "^6.1.0", - "is-path-cwd": "^2.0.0", - "is-path-in-cwd": "^2.0.0", - "p-map": "^2.0.0", - "pify": "^4.0.1", - "rimraf": "^2.6.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/clean-webpack-plugin/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/clean-webpack-plugin/node_modules/globby": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", - "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", - "dev": true, - "dependencies": { - "array-union": "^1.0.1", - "glob": "^7.0.3", - "object-assign": "^4.0.1", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clean-webpack-plugin/node_modules/globby/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clean-webpack-plugin/node_modules/p-map": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", - "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/clean-webpack-plugin/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/clean-webpack-plugin/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -23705,30 +23559,6 @@ "node": ">=6" } }, - "node_modules/is-path-in-cwd": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", - "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", - "dev": true, - "dependencies": { - "is-path-inside": "^2.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/is-path-in-cwd/node_modules/is-path-inside": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", - "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", - "dev": true, - "dependencies": { - "path-is-inside": "^1.0.2" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -30485,12 +30315,6 @@ "node": ">=0.10.0" } }, - "node_modules/path-is-inside": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", - "dev": true - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", diff --git a/package.json b/package.json index c99a8349bc..0eabb1da8b 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "autoprefixer": "10.4.18", "base64-loader": "1.0.0", "chromatic": "10.9.6", - "clean-webpack-plugin": "4.0.0", "concurrently": "8.2.2", "copy-webpack-plugin": "11.0.0", "cross-env": "7.0.3",