1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-25 12:15:18 +01:00

Merge branch 'main' into vault/pm-5273

# Conflicts:
#	apps/browser/src/popup/app.component.ts
#	libs/common/src/state-migrations/migrate.ts
This commit is contained in:
Carlos Gonçalves 2024-03-12 20:05:58 +00:00
commit 0e5f66e9c7
No known key found for this signature in database
GPG Key ID: 8147F618E732EF25
129 changed files with 3197 additions and 1250 deletions

View File

@ -37,7 +37,6 @@
"base64-loader",
"buffer",
"bufferutil",
"clean-webpack-plugin",
"copy-webpack-plugin",
"core-js",
"css-loader",

View File

@ -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

View File

@ -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

View File

@ -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),
),
);
}

View File

@ -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<void>;
bgGetEnableChangedPasswordPrompt: () => Promise<boolean>;
bgGetEnableAddedLoginPrompt: () => Promise<boolean>;
bgGetExcludedDomains: () => Promise<NeverDomains>;
getWebVaultUrlForNotification: () => string;
};

View File

@ -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<FolderService>();
const stateService = mock<BrowserStateService>();
const userNotificationSettingsService = mock<UserNotificationSettingsService>();
const domainSettingsService = mock<DomainSettingsService>();
const environmentService = mock<EnvironmentService>();
const logService = mock<LogService>();
@ -59,6 +61,7 @@ describe("NotificationBackground", () => {
folderService,
stateService,
userNotificationSettingsService,
domainSettingsService,
environmentService,
logService,
);

View File

@ -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<NeverDomains> {
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.

View File

@ -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,

View File

@ -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),
),
);

View File

@ -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<DefaultDomainSettingsService> {
return factory(
cache,
"domainSettingsService",
opts,
async () => new DefaultDomainSettingsService(await stateProviderFactory(cache, opts)),
);
}

View File

@ -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();

View File

@ -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();

View File

@ -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) {

View File

@ -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 {

View File

@ -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<CipherService>();
const stateService = mock<BrowserStateService>();
const autofillSettingsService = mock<AutofillSettingsService>();
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService);
let domainSettingsService: DomainSettingsService;
const totpService = mock<TotpService>();
const eventCollectionService = mock<EventCollectionService>();
const logService = mock<LogService>();
const settingsService = mock<SettingsService>();
const userVerificationService = mock<UserVerificationService>();
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<LoginUriView>({
uri: "https://www.example.com",
match: UriMatchType.Domain,
match: UriMatchStrategy.Domain,
});
options = createGenerateFillScriptOptionsMock();
options.cipher.login = mock<LoginView>({
@ -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<LoginUriView>({
uri: "https://www.second-example.com",
});
const thirdUriView = mock<LoginUriView>({
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,

View File

@ -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<UriMatchStrategySetting> {
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<boolean> {
// 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,

View File

@ -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<CipherView>(),
tabUrl: "https://jest-testing-website.com",
defaultUriMatch: UriMatchType.Domain,
defaultUriMatch: UriMatchStrategy.Domain,
...customFields,
};
}

View File

@ -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<GlobalState, "neverDomains">;
/**
* A HTMLElement (usually a form element) with additional custom properties added by this script
*/

View File

@ -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<boolean> {
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();

View File

@ -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));
});
});
}

View File

@ -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();
}
}
}

View File

@ -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)),
);
}

View File

@ -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,

View File

@ -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";

View File

@ -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;

View File

@ -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<boolean>,
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,
});
}
}

View File

@ -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,
);
});

View File

@ -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<boolean>,
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() {

View File

@ -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<boolean>,
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 += "<p>" + this.sanitizer.sanitize(SecurityContext.HTML, t) + "</p>"),
);
text = message;
options.enableHtml = true;
}
this.toastrService.show(text, title, options, "toast-" + type);
// noop
}
}

View File

@ -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<IndividualConfig> = {};
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 += "<p>" + this.sanitizer.sanitize(SecurityContext.HTML, t) + "</p>"),
);
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) {

View File

@ -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");

View File

@ -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<T>(service: keyof MainBackground) {
},
{
provide: PlatformUtilsService,
useFactory: getBgService<PlatformUtilsService>("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<T>(service: keyof MainBackground) {
useClass: BrowserLocalStorageService,
deps: [],
},
{ provide: AppIdService, useFactory: getBgService<AppIdService>("appIdService"), deps: [] },
{
provide: AutofillService,
useFactory: getBgService<AutofillService>("autofillService"),

View File

@ -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"]);

View File

@ -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);
}

View File

@ -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

View File

@ -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<AccountSettingsSettings>;
@sessionSync({ initializer: (b: boolean) => b })
protected _disableFavicon: BehaviorSubject<boolean>;
}

View File

@ -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),

View File

@ -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",

View File

@ -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),
);

View File

@ -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$/,

View File

@ -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);
}

View File

@ -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),

View File

@ -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,

View File

@ -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",

View File

@ -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);

View File

@ -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"

View File

@ -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. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@ -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");

View File

@ -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");

View File

@ -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),

View File

@ -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$/,

View File

@ -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

View File

@ -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<boolean> {
return (await firstValueFrom(this.loginStrategyService.currentAuthType$)) !== null;
}
get needsLock(): boolean {
return (
this.loginStrategyService.authingWithSso() ||
this.loginStrategyService.authingWithUserApiKey()
);
private async needsLock(): Promise<boolean> {
const authType = await firstValueFrom(this.loginStrategyService.currentAuthType$);
return authType == AuthenticationType.Sso || authType == AuthenticationType.UserApiKey;
}
// implemented in clients

View File

@ -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,

View File

@ -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 },

View File

@ -10,7 +10,11 @@ module.exports = {
displayName: "libs/auth tests",
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/",
}),
moduleNameMapper: pathsToModuleNameMapper(
// lets us use @bitwarden/common/spec in tests
{ "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) },
{
prefix: "<rootDir>/",
},
),
};

View File

@ -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<AuthenticationType | null>;
/**
* Emits when an auth request has been approved.
*/
authRequestPushNotification$: Observable<string>;
/**
* If the login strategy uses the email address of the user, this
* will return it. Otherwise, it will return null.
*/
getEmail: () => Promise<string | null>;
/**
* If the user is logging in with a master password, this will return
* the master password hash. Otherwise, it will return null.
*/
getMasterPasswordHash: () => Promise<string | null>;
/**
* 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<string | null>;
/**
* Returns the access code if the user is logging in with an
* Auth Request. Otherwise, it will return null.
*/
getAccessCode: () => Promise<string | null>;
/**
* Returns the auth request ID if the user is logging in with an
* Auth Request. Otherwise, it will return null.
*/
getAuthRequestId: () => Promise<string | null>;
/**
* 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<AuthResult>;
/**
* 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<AuthResult>;
/**
* Creates a master key from the provided master password and email.
*/
makePreloginKey: (masterPassword: string, email: string) => Promise<MasterKey>;
authingWithUserApiKey: () => boolean;
authingWithSso: () => boolean;
authingWithPassword: () => boolean;
authingWithPasswordless: () => boolean;
authResponsePushNotification: (notification: AuthRequestPushNotification) => Promise<any>;
getPushNotificationObs$: () => Observable<any>;
/**
* Sends a notification to {@link LoginStrategyServiceAbstraction.authRequestPushNotification}
*/
sendAuthRequestPushNotification: (notification: AuthRequestPushNotification) => Promise<void>;
/**
* Sends a response to an auth request.
*/
passwordlessLogin: (
id: string,
key: string,
requestApproved: boolean,
) => Promise<AuthRequestResponse>;
}

View File

@ -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<CryptoService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
@ -65,6 +70,7 @@ describe("AuthRequestLoginStrategy", () => {
tokenService.decodeToken.mockResolvedValue({});
authRequestLoginStrategy = new AuthRequestLoginStrategy(
cache,
cryptoService,
apiService,
tokenService,

View File

@ -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>): 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<string>;
accessCode$: Observable<string>;
authRequestId$: Observable<string>;
get accessCode() {
return this.authRequestCredentials.accessCode;
}
get authRequestId() {
return this.authRequestCredentials.authRequestId;
}
tokenRequest: PasswordTokenRequest;
private authRequestCredentials: AuthRequestLoginCredentials;
protected cache: BehaviorSubject<AuthRequestLoginStrategyData>;
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<AuthResult> {
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<void> {
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,
};
}
}

View File

@ -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<LoginStrategyServiceAbstraction>;
let cryptoService: MockProxy<CryptoService>;
let apiService: MockProxy<ApiService>;
@ -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());

View File

@ -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<LoginStrategyData>;
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<AuthResult> {
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<void>;
protected abstract setUserKey(response: IdentityTokenResponse): Promise<void>;
protected abstract setPrivateKey(response: IdentityTokenResponse): Promise<void>;
// 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;

View File

@ -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<LoginStrategyServiceAbstraction>;
let cryptoService: MockProxy<CryptoService>;
let apiService: MockProxy<ApiService>;
@ -93,6 +95,7 @@ describe("PasswordLoginStrategy", () => {
policyService.evaluateMasterPassword.mockReturnValue(true);
passwordLoginStrategy = new PasswordLoginStrategy(
cache,
cryptoService,
apiService,
tokenService,

View File

@ -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>): 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<string>;
/**
* The master key hash of the user attempting to log in.
*/
masterKeyHash$: Observable<string | null>;
protected cache: BehaviorSubject<PasswordLoginStrategyData>;
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<AuthResult> {
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<AuthResult> {
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<void> {
@ -191,4 +230,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
return this.policyService.evaluateMasterPassword(passwordStrength, masterPassword, options);
}
exportCache(): CacheData {
return {
password: this.cache.value,
};
}
}

View File

@ -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<CryptoService>;
let apiService: MockProxy<ApiService>;
@ -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", () => {

View File

@ -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>): 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<string | null>;
/**
* @see {@link SsoLoginStrategyData.orgId}
*/
orgId$: Observable<string>;
/**
* @see {@link SsoLoginStrategyData.ssoEmail2FaSessionToken}
*/
ssoEmail2FaSessionToken$: Observable<string | null>;
// 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<SsoLoginStrategyData>;
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,
};
}
}

View File

@ -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<CryptoService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
@ -60,6 +62,7 @@ describe("UserApiLoginStrategy", () => {
tokenService.decodeToken.mockResolvedValue({});
apiLogInStrategy = new UserApiLoginStrategy(
cache,
cryptoService,
apiService,
tokenService,

View File

@ -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>): UserApiLoginStrategyData {
return Object.assign(new UserApiLoginStrategyData(), obj, {
tokenRequest: UserApiTokenRequest.fromJSON(obj.tokenRequest),
});
}
}
export class UserApiLoginStrategy extends LoginStrategy {
tokenRequest: UserApiTokenRequest;
protected cache: BehaviorSubject<UserApiLoginStrategyData>;
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,
};
}
}

View File

@ -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<CryptoService>;
let apiService!: MockProxy<ApiService>;
let tokenService!: MockProxy<TokenService>;
@ -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";

View File

@ -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>): 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<WebAuthnLoginStrategyData>;
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<AuthResult> {
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<AuthResult> {
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,
};
}
}

View File

@ -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<AuthRequestLoginCredentials>) {
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<WebAuthnLoginCredentials>) {
return new WebAuthnLoginCredentials(
json.token,
Object.assign(
Object.create(WebAuthnLoginAssertionResponseRequest.prototype),
json.deviceResponse,
),
SymmetricCryptoKey.fromJSON(json.prfKey),
);
}
}

View File

@ -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<CryptoService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
let appIdService: MockProxy<AppIdService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
let keyConnectorService: MockProxy<KeyConnectorService>;
let environmentService: MockProxy<EnvironmentService>;
let stateService: MockProxy<StateService>;
let twoFactorService: MockProxy<TwoFactorService>;
let i18nService: MockProxy<I18nService>;
let encryptService: MockProxy<EncryptService>;
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
let policyService: MockProxy<PolicyService>;
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let stateProvider: FakeGlobalStateProvider;
let loginStrategyCacheExpirationState: FakeGlobalState<Date | null>;
beforeEach(() => {
cryptoService = mock<CryptoService>();
apiService = mock<ApiService>();
tokenService = mock<TokenService>();
appIdService = mock<AppIdService>();
platformUtilsService = mock<PlatformUtilsService>();
messagingService = mock<MessagingService>();
logService = mock<LogService>();
keyConnectorService = mock<KeyConnectorService>();
environmentService = mock<EnvironmentService>();
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
i18nService = mock<I18nService>();
encryptService = mock<EncryptService>();
passwordStrengthService = mock<PasswordStrengthServiceAbstraction>();
policyService = mock<PolicyService>();
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
authRequestService = mock<AuthRequestServiceAbstraction>();
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();
});
});

View File

@ -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<AuthenticationType | null>;
private loginStrategyCacheState: GlobalState<CacheData | null>;
private loginStrategyCacheExpirationState: GlobalState<Date | null>;
private authRequestPushNotificationState: GlobalState<string>;
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<string>();
currentAuthType$: Observable<AuthenticationType | null>;
// TODO: move to auth request service
authRequestPushNotification$: Observable<string>;
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<string | null> {
const strategy = await firstValueFrom(this.loginStrategy$);
if ("email$" in strategy) {
return await firstValueFrom(strategy.email$);
}
return null;
}
async getMasterPasswordHash(): Promise<string | null> {
const strategy = await firstValueFrom(this.loginStrategy$);
if ("masterKeyHash$" in strategy) {
return await firstValueFrom(strategy.masterKeyHash$);
}
return null;
}
async getSsoEmail2FaSessionToken(): Promise<string | null> {
const strategy = await firstValueFrom(this.loginStrategy$);
if ("ssoEmail2FaSessionToken$" in strategy) {
return await firstValueFrom(strategy.ssoEmail2FaSessionToken$);
}
return null;
}
async getAccessCode(): Promise<string | null> {
const strategy = await firstValueFrom(this.loginStrategy$);
if ("accessCode$" in strategy) {
return await firstValueFrom(strategy.accessCode$);
}
return null;
}
async getAuthRequestId(): Promise<string | null> {
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<AuthResult> {
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<AuthResult> {
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<MasterKey> {
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<any> {
this.pushNotificationSubject.next(notification.id);
}
getPushNotificationObs$(): Observable<any> {
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<void> {
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<AuthRequestResponse> {
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<void> {
await this.currentAuthnTypeState.update((_) => null);
await this.loginStrategyCacheState.update((_) => null);
await this.clearSessionTimeout();
}
private async startSessionTimeout(): Promise<void> {
await this.clearSessionTimeout();
await this.loginStrategyCacheExpirationState.update(
(_) => new Date(Date.now() + sessionTimeoutLength),
);
this.sessionTimeout = setTimeout(() => this.clearCache(), sessionTimeoutLength);
}
private async clearSessionTimeout(): Promise<void> {
await this.loginStrategyCacheExpirationState.update((_) => null);
this.sessionTimeout = null;
}
private async isSessionValid(): Promise<boolean> {
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,
);
}
}),
);
}
}

View File

@ -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);
}
}
}

View File

@ -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<AuthenticationType | null>(
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<Date | null>(
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<string>(
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<CacheData | null>(
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,
};
},
},
);

View File

@ -1,14 +1,8 @@
import { Observable } from "rxjs";
import { AccountSettingsSettings } from "../platform/models/domain/account";
export abstract class SettingsService {
settings$: Observable<AccountSettingsSettings>;
disableFavicon$: Observable<boolean>;
setEquivalentDomains: (equivalentDomains: string[][]) => Promise<any>;
getEquivalentDomains: (url: string) => Set<string>;
setDisableFavicon: (value: boolean) => Promise<any>;
getDisableFavicon: () => boolean;
clear: (userId?: string) => Promise<void>;
}

View File

@ -1,7 +1,7 @@
export enum AuthenticationType {
Password = 0,
Sso = 1,
UserApi = 2,
UserApiKey = 2,
AuthRequest = 3,
WebAuthn = 4,
}

View File

@ -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<DeviceRequest>) {
return Object.assign(Object.create(DeviceRequest.prototype), json);
}
}

View File

@ -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,
});
}
}

View File

@ -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,
});
}
}

View File

@ -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,
});
}
}

View File

@ -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,
});
}
}

View File

@ -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,
);
}

View File

@ -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<OrganizationApiServiceAbstraction>;
let stateService: MockProxy<StateService>;
let accountService: MockProxy<AccountService>;
let cryptoService: MockProxy<CryptoService>;
let organizationUserService: MockProxy<OrganizationUserService>;
let i18nService: MockProxy<I18nService>;
@ -19,13 +24,14 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
beforeEach(() => {
organizationApiService = mock<OrganizationApiServiceAbstraction>();
stateService = mock<StateService>();
accountService = mock<AccountService>();
accountService.activeAccount$ = activeAccountSubject;
cryptoService = mock<CryptoService>();
organizationUserService = mock<OrganizationUserService>();
i18nService = mock<I18nService>();
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);

View File

@ -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);

View File

@ -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<WebAuthnLoginAssertionResponseRequest>) {
return Object.assign(Object.create(WebAuthnLoginAssertionResponseRequest.prototype), json);
}
}

View File

@ -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());
});
});
});

View File

@ -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<NeverDomains>;
setNeverDomains: (newValue: NeverDomains) => Promise<void>;
equivalentDomains$: Observable<EquivalentDomains>;
setEquivalentDomains: (newValue: EquivalentDomains) => Promise<void>;
defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise<void>;
getUrlEquivalentDomains: (url: string) => Observable<Set<string>>;
}
export class DefaultDomainSettingsService implements DomainSettingsService {
private neverDomainsState: GlobalState<NeverDomains>;
readonly neverDomains$: Observable<NeverDomains>;
private equivalentDomainsState: ActiveUserState<EquivalentDomains>;
readonly equivalentDomains$: Observable<EquivalentDomains>;
private defaultUriMatchStrategyState: ActiveUserState<UriMatchStrategySetting>;
readonly defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
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<void> {
await this.neverDomainsState.update(() => newValue);
}
async setEquivalentDomains(newValue: EquivalentDomains): Promise<void> {
await this.equivalentDomainsState.update(() => newValue);
}
async setDefaultUriMatchStrategy(newValue: UriMatchStrategySetting): Promise<void> {
await this.defaultUriMatchStrategyState.update(() => newValue);
}
getUrlEquivalentDomains(url: string): Observable<Set<string>> {
const domains$ = this.equivalentDomains$.pipe(
map((equivalentDomains) => {
const domain = Utils.getDomain(url);
if (domain == null || equivalentDomains == null) {
return new Set() as Set<string>;
}
const equivalents = equivalentDomains.filter((ed) => ed.includes(domain)).flat();
return new Set(equivalents);
}),
);
return domains$;
}
}

View File

@ -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[][];

View File

@ -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) {

View File

@ -1,4 +1,8 @@
import { Observable } from "rxjs";
export abstract class AppIdService {
appId$: Observable<string>;
anonymousAppId$: Observable<string>;
getAppId: () => Promise<string>;
getAnonymousAppId: () => Promise<string>;
}

View File

@ -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<T extends Account = Account> {
* @deprecated Do not call this directly, use SendService
*/
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
getDefaultUriMatch: (options?: StorageOptions) => Promise<UriMatchType>;
setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this, use SettingsService
*/
@ -272,8 +265,6 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated Do not call this directly, use SendService
*/
setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise<void>;
getEquivalentDomains: (options?: StorageOptions) => Promise<string[][]>;
setEquivalentDomains: (value: string, options?: StorageOptions) => Promise<void>;
getEventCollection: (options?: StorageOptions) => Promise<EventData[]>;
setEventCollection: (value: EventData[], options?: StorageOptions) => Promise<void>;
getEverBeenUnlocked: (options?: StorageOptions) => Promise<boolean>;
@ -307,8 +298,6 @@ export abstract class StateService<T extends Account = Account> {
setMainWindowSize: (value: number, options?: StorageOptions) => Promise<void>;
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
getNeverDomains: (options?: StorageOptions) => Promise<{ [id: string]: unknown }>;
setNeverDomains: (value: { [id: string]: unknown }, options?: StorageOptions) => Promise<void>;
getOpenAtLogin: (options?: StorageOptions) => Promise<boolean>;
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
@ -350,14 +339,6 @@ export abstract class StateService<T extends Account = Account> {
setRememberedEmail: (value: string, options?: StorageOptions) => Promise<void>;
getSecurityStamp: (options?: StorageOptions) => Promise<string>;
setSecurityStamp: (value: string, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use SettingsService
*/
getSettings: (options?: StorageOptions) => Promise<AccountSettingsSettings>;
/**
* @deprecated Do not call this directly, use SettingsService
*/
setSettings: (value: AccountSettingsSettings, options?: StorageOptions) => Promise<void>;
getTheme: (options?: StorageOptions) => Promise<ThemeType>;
setTheme: (value: ThemeType, options?: StorageOptions) => Promise<void>;
getTwoFactorToken: (options?: StorageOptions) => Promise<string>;

View File

@ -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 {

View File

@ -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;

View File

@ -25,6 +25,5 @@ export class GlobalState {
enableBrowserIntegration?: boolean;
enableBrowserIntegrationFingerprint?: boolean;
enableDuckDuckGoBrowserIntegration?: boolean;
neverDomains?: { [id: string]: unknown };
deepLinkRedirectUrl?: string;
}

View File

@ -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);
},
);
});
});

View File

@ -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<string>;
anonymousAppId$: Observable<string>;
getAppId(): Promise<string> {
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<string> {
return this.makeAndGetAppId("anonymousAppId");
async getAppId(): Promise<string> {
return await firstValueFrom(this.appId$);
}
private async makeAndGetAppId(key: string) {
const existingId = await this.storageService.get<string>(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<string> {
return await firstValueFrom(this.anonymousAppId$);
}
}

View File

@ -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<UriMatchType> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.settings?.defaultUriMatch;
}
async setDefaultUriMatch(value: UriMatchType, options?: StorageOptions): Promise<void> {
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<boolean> {
return (
(
@ -1304,23 +1285,6 @@ export class StateService<
);
}
async getEquivalentDomains(options?: StorageOptions): Promise<string[][]> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.settings?.equivalentDomains;
}
async setEquivalentDomains(value: string, options?: StorageOptions): Promise<void> {
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<EventData[]> {
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<void> {
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<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@ -1778,23 +1725,6 @@ export class StateService<
);
}
async getSettings(options?: StorageOptions): Promise<AccountSettingsSettings> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
)?.settings?.settings;
}
async setSettings(value: AccountSettingsSettings, options?: StorageOptions): Promise<void> {
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<ThemeType> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))

View File

@ -15,6 +15,7 @@ export interface GlobalState<T> {
* @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: <TCombine>(
configureState: (state: T, dependency: TCombine) => T,

View File

@ -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");

View File

@ -32,7 +32,8 @@ export interface ActiveUserState<T> extends UserState<T> {
* @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: <TCombine>(
configureState: (state: T, dependencies: TCombine) => T,
@ -50,7 +51,8 @@ export interface SingleUserState<T> extends UserState<T> {
* @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: <TCombine>(
configureState: (state: T, dependencies: TCombine) => T,

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