From 733ba46bfc79f375db9fb0e673e26d6890383e04 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Fri, 3 Oct 2025 10:51:10 -0600 Subject: [PATCH 01/37] [BEEEP] Use tracing in macOS provider --- apps/desktop/desktop_native/Cargo.lock | 15 ++++++++++++++- .../desktop_native/macos_provider/Cargo.toml | 3 ++- .../desktop_native/macos_provider/src/lib.rs | 18 ++++++++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 9020e08362e..640474f03c6 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -1793,13 +1793,14 @@ version = "0.0.0" dependencies = [ "desktop_core", "futures", - "log", "oslog", "serde", "serde_json", "tokio", "tokio-util", "tracing", + "tracing-oslog", + "tracing-subscriber", "uniffi", ] @@ -3445,6 +3446,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-oslog" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950" +dependencies = [ + "cc", + "cfg-if", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "tracing-subscriber" version = "0.3.20" diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml index 9f042209b06..97a8b7d545a 100644 --- a/apps/desktop/desktop_native/macos_provider/Cargo.toml +++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml @@ -16,12 +16,13 @@ bench = false [dependencies] desktop_core = { path = "../core" } futures = { workspace = true } -log = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["sync"] } tokio-util = { workspace = true } tracing = { workspace = true } +tracing-oslog = "0.3.0" +tracing-subscriber = { workspace = true } uniffi = { workspace = true, features = ["cli"] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs index ded133bcb54..4da1174131c 100644 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -9,6 +9,11 @@ use std::{ use futures::FutureExt; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tracing::{error, info}; +use tracing_subscriber::{ + filter::{EnvFilter, LevelFilter}, + layer::SubscriberExt, + util::SubscriberInitExt, +}; uniffi::setup_scaffolding!(); @@ -65,8 +70,17 @@ impl MacOSProviderClient { #[allow(clippy::unwrap_used)] #[uniffi::constructor] pub fn connect() -> Self { - let _ = oslog::OsLogger::new("com.bitwarden.desktop.autofill-extension") - .level_filter(log::LevelFilter::Trace) + let filter = EnvFilter::builder() + // Everything logs at `TRACE` + .with_default_directive(LevelFilter::TRACE.into()) + .from_env_lossy(); + + tracing_subscriber::registry() + .with(filter) + .with(tracing_oslog::OsLogger::new( + "com.bitwarden.desktop.autofill-extension", + "default", + )) .init(); let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32); From 9c7d794cac7060050cae51cd18e2bd1652413096 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 10 Oct 2025 12:05:27 -0400 Subject: [PATCH 02/37] Billing/pm 26686/update upgrade account component to be responsive (#16808) * fix(billing): Update initialization of payment component * fix(billing): Add more signal validation * fix(billing): Add responsiveness to upgrade-account component --- .../unified-upgrade-dialog.component.html | 2 +- .../upgrade-account.component.html | 8 ++-- .../upgrade-payment.component.html | 1 + .../upgrade-payment.component.ts | 37 +++++++++---------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html index 6cffd818afc..3e7b797f00f 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html @@ -1,6 +1,6 @@ @if (step() == PlanSelectionStep) { -} @else if (step() == PaymentStep && selectedPlan() !== null) { +} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) { @@ -24,10 +24,10 @@

-
+
@if (premiumCardDetails) { @if (passwordManager) { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 8aaced5e1fc..81a4c9191a5 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -118,27 +118,24 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { tier: this.selectedPlanId(), details: planDetails, }; + this.passwordManager = { + name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + cost: this.selectedPlan.details.passwordManager.annualPrice, + quantity: 1, + cadence: "year", + }; + + this.upgradeToMessage = this.i18nService.t( + this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium", + ); + + this.estimatedTax = 0; + } else { + this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); + return; } }); - if (!this.selectedPlan) { - this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); - return; - } - - this.passwordManager = { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", - cost: this.selectedPlan.details.passwordManager.annualPrice, - quantity: 1, - cadence: "year", - }; - - this.upgradeToMessage = this.i18nService.t( - this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium", - ); - - this.estimatedTax = 0; - this.formGroup.valueChanges .pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)) .subscribe(() => this.refreshSalesTax()); @@ -146,7 +143,9 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } ngAfterViewInit(): void { - this.cartSummaryComponent.isExpanded.set(false); + if (this.cartSummaryComponent) { + this.cartSummaryComponent.isExpanded.set(false); + } } protected get isPremiumPlan(): boolean { From bc48164cb98f00fba68698d0424f86ed7d98138a Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:41:18 -0500 Subject: [PATCH 03/37] remove archive date when soft deleting (#16794) --- .../src/vault/services/cipher.service.spec.ts | 22 +++++++++++++++++++ .../src/vault/services/cipher.service.ts | 1 + 2 files changed, 23 insertions(+) diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index b3cb43dcc93..9b1d8096fc7 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -819,6 +819,28 @@ describe("Cipher Service", () => { }); }); + describe("softDelete", () => { + it("clears archivedDate when soft deleting", async () => { + const cipherId = "cipher-id-1" as CipherId; + const archivedCipher = { + ...cipherData, + id: cipherId, + archivedDate: "2024-01-01T12:00:00.000Z", + } as CipherData; + + const ciphers = { [cipherId]: archivedCipher } as Record; + stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).nextState(ciphers); + + await cipherService.softDelete(cipherId, mockUserId); + + const result = await firstValueFrom( + stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$, + ); + expect(result[cipherId].archivedDate).toBeNull(); + expect(result[cipherId].deletedDate).toBeDefined(); + }); + }); + describe("replace (no upsert)", () => { // In order to set up initial state we need to manually update the encrypted state // which will result in an emission. All tests will have this baseline emission. diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index ced8f2bb796..f0e2f9f9404 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1412,6 +1412,7 @@ export class CipherService implements CipherServiceAbstraction { return; } ciphers[cipherId].deletedDate = new Date().toISOString(); + ciphers[cipherId].archivedDate = null; }; if (typeof id === "string") { From 89eb60135f7b32aa26dfe83dffb159875c8b67b0 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:29:12 -0700 Subject: [PATCH 04/37] [PM-24747] Remove chromium importer feature flag (#16749) --- libs/common/src/enums/feature-flag.enum.ts | 2 -- .../src/services/import.service.spec.ts | 34 ------------------- libs/importer/src/services/import.service.ts | 15 ++++---- 3 files changed, 6 insertions(+), 45 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 0897aab33c9..8b73010daf5 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -35,7 +35,6 @@ export enum FeatureFlag { /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", - UseChromiumImporter = "pm-23982-chromium-importer", /* DIRT */ EventBasedOrganizationIntegrations = "event-based-organization-integrations", @@ -81,7 +80,6 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.DesktopSendUIRefresh]: FALSE, [FeatureFlag.UseSdkPasswordGenerators]: FALSE, - [FeatureFlag.UseChromiumImporter]: FALSE, /* DIRT */ [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index c3d555af936..2a3963e19d7 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -341,19 +341,6 @@ describe("ImportService", () => { expect(result.loaders).toContain(Loader.file); }); - it("should exclude chromium loader when feature flag is disabled", async () => { - const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders - featureFlagSubject.next(false); - - const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); - typeSubject.next(testType); - - const result = await metadataPromise; - - expect(result.loaders).not.toContain(Loader.chromium); - expect(result.loaders).toContain(Loader.file); - }); - it("should update when type$ changes", async () => { const emissions: ImporterMetadata[] = []; const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { @@ -373,27 +360,6 @@ describe("ImportService", () => { subscription.unsubscribe(); }); - it("should update when feature flag changes", async () => { - const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader - const emissions: ImporterMetadata[] = []; - - const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { - emissions.push(metadata); - }); - - typeSubject.next(testType); - featureFlagSubject.next(true); - - // Wait for emissions - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(emissions).toHaveLength(2); - expect(emissions[0].loaders).not.toContain(Loader.chromium); - expect(emissions[1].loaders).toContain(Loader.chromium); - - subscription.unsubscribe(); - }); - it("should update when both type$ and feature flag change", async () => { const emissions: ImporterMetadata[] = []; diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 351d89be3fa..4050ae9fb4b 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -11,7 +11,6 @@ import { } from "@bitwarden/admin-console/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { ImportCiphersRequest } from "@bitwarden/common/models/request/import-ciphers.request"; @@ -138,18 +137,16 @@ export class ImportService implements ImportServiceAbstraction { } metadata$(type$: Observable): Observable { - const browserEnabled$ = this.system.configService.getFeatureFlag$( - FeatureFlag.UseChromiumImporter, - ); const client = this.system.environment.getClientType(); - const capabilities$ = combineLatest([type$, browserEnabled$]).pipe( - map(([type, enabled]) => { + const capabilities$ = combineLatest([type$]).pipe( + map(([type]) => { let loaders = availableLoaders(type, client); // Mac App Store is currently disabled due to sandboxing. let isUnsupported = this.system.environment.isMacAppStore(); - if (enabled && type === "bravecsv") { + // disable the chromium loader for Brave on Windows only + if (type === "bravecsv") { try { const device = this.system.environment.getDevice(); const isWindowsDesktop = device === DeviceType.WindowsDesktop; @@ -160,8 +157,8 @@ export class ImportService implements ImportServiceAbstraction { isUnsupported = true; } } - // If the feature flag is disabled, or if the browser is unsupported, remove the chromium loader - if (!enabled || isUnsupported) { + // If the browser is unsupported, remove the chromium loader + if (isUnsupported) { loaders = loaders?.filter((loader) => loader !== Loader.chromium); } From cc8bd71775c8131e4529e90e78c61b25c77ecba5 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 10 Oct 2025 23:04:47 +0200 Subject: [PATCH 05/37] [PM-21033/PM-22863] User Encryption v2 (#14942) * Add new encrypt service functions * Undo changes * Cleanup * Fix build * Fix comments * Switch encrypt service to use SDK functions * Move remaining functions to PureCrypto * Tests * Increase test coverage * Split up userkey rotation v2 and add tests * Fix eslint * Fix type errors * Fix tests * Implement signing keys * Fix sdk init * Remove key rotation v2 flag * Fix parsing when user does not have signing keys * Clear up trusted key naming * Split up getNewAccountKeys * Add trim and lowercase * Replace user.email with masterKeySalt * Add wasTrustDenied to verifyTrust in key rotation service * Move testable userkey rotation service code to testable class * Fix build * Add comments * Undo changes * Fix incorrect behavior on aborting key rotation and fix import * Fix tests * Make members of userkey rotation service protected * Fix type error * Cleanup and add injectable annotation * Fix tests * Update apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Remove v1 rotation request * Add upgrade to user encryption v2 * Fix types * Update sdk method calls * Update request models for new server api for rotation * Fix build * Update userkey rotation for new server API * Update crypto client call for new sdk changes * Fix rotation with signing keys * Cargo lock * Fix userkey rotation service * Fix types * Undo changes to feature flag service * Fix linting * [PM-22863] Account security state (#15309) * Add account security state * Update key rotation * Rename * Fix build * Cleanup * Further cleanup * Tests * Increase test coverage * Add test * Increase test coverage * Fix builds and update sdk * Fix build * Fix tests * Reset changes to encrypt service * Cleanup * Add comment * Cleanup * Cleanup * Rename model * Cleanup * Fix build * Clean up * Fix types * Cleanup * Cleanup * Cleanup * Add test * Simplify request model * Rename and add comments * Fix tests * Update responses to use less strict typing * Fix response parsing for v1 users * Update libs/common/src/key-management/keys/response/private-keys.response.ts Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Update libs/common/src/key-management/keys/response/private-keys.response.ts Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Fix build * Fix build * Fix build * Undo change * Fix attachments not encrypting for v2 users --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> --- .../browser/src/background/main.background.ts | 8 +- .../service-container/service-container.ts | 7 + .../settings/account/profile.component.ts | 4 +- ...c-key-encryption-key-pair-request.model.ts | 19 + ...ignature-key-pair-request-request.model.ts | 18 + .../request/account-keys.request.ts | 74 +- .../types/v1-cryptographic-state.ts | 10 + .../types/v2-cryptographic-state.ts | 49 ++ .../user-key-rotation.service.spec.ts | 649 ++++++++++++++---- .../key-rotation/user-key-rotation.service.ts | 449 ++++++++---- .../src/services/jslib-services.module.ts | 16 + .../services/web-crypto-function.service.ts | 3 +- .../enums/signing-key-type.enum.ts | 13 + .../keys/response/private-keys.response.ts | 55 ++ ...public-key-encryption-key-pair.response.ts | 32 + .../keys/response/public-keys.response.ts | 44 ++ .../response/signature-key-pair.response.ts | 22 + .../key-api-service.abstraction.ts | 5 + .../default-key-api-service.service.ts | 15 + .../abstractions/security-state.service.ts | 21 + .../request/security-state.request.ts | 8 + .../response/security-state.response.ts | 16 + .../services/security-state.service.ts | 26 + .../state/security-state.state.ts | 12 + libs/common/src/key-management/types.ts | 30 + .../src/models/response/profile.response.ts | 11 + .../services/key-state/user-key.state.ts | 10 + .../services/sdk/default-sdk.service.spec.ts | 8 +- .../services/sdk/default-sdk.service.ts | 89 ++- .../sync/default-sync.service.spec.ts | 141 ++++ .../src/platform/sync/default-sync.service.ts | 25 +- libs/common/src/types/key.ts | 3 +- .../src/abstractions/key.service.ts | 23 +- libs/key-management/src/key.service.spec.ts | 52 +- libs/key-management/src/key.service.ts | 42 +- .../services/node-crypto-function.service.ts | 11 +- 36 files changed, 1693 insertions(+), 327 deletions(-) create mode 100644 apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts create mode 100644 apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts create mode 100644 apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts create mode 100644 apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts create mode 100644 libs/common/src/key-management/enums/signing-key-type.enum.ts create mode 100644 libs/common/src/key-management/keys/response/private-keys.response.ts create mode 100644 libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts create mode 100644 libs/common/src/key-management/keys/response/public-keys.response.ts create mode 100644 libs/common/src/key-management/keys/response/signature-key-pair.response.ts create mode 100644 libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts create mode 100644 libs/common/src/key-management/keys/services/default-key-api-service.service.ts create mode 100644 libs/common/src/key-management/security-state/abstractions/security-state.service.ts create mode 100644 libs/common/src/key-management/security-state/request/security-state.request.ts create mode 100644 libs/common/src/key-management/security-state/response/security-state.response.ts create mode 100644 libs/common/src/key-management/security-state/services/security-state.service.ts create mode 100644 libs/common/src/key-management/security-state/state/security-state.state.ts create mode 100644 libs/common/src/key-management/types.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index bd28ddfbbbf..21609432a4b 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -100,6 +100,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key- import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; import { DefaultVaultTimeoutSettingsService, @@ -452,6 +454,7 @@ export default class MainBackground { taskService: TaskService; cipherEncryptionService: CipherEncryptionService; private restrictedItemTypesService: RestrictedItemTypesService; + private securityStateService: SecurityStateService; ipcContentScriptManagerService: IpcContentScriptManagerService; ipcService: IpcService; @@ -668,6 +671,8 @@ export default class MainBackground { logoutCallback, ); + this.securityStateService = new DefaultSecurityStateService(this.stateProvider); + this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService( messageListener, this.globalStateProvider, @@ -830,6 +835,7 @@ export default class MainBackground { this.accountService, this.kdfConfigService, this.keyService, + this.securityStateService, this.apiService, this.stateProvider, this.configService, @@ -999,7 +1005,6 @@ export default class MainBackground { this.avatarService = new AvatarService(this.apiService, this.stateProvider); this.providerService = new ProviderService(this.stateProvider); - this.syncService = new DefaultSyncService( this.masterPasswordService, this.accountService, @@ -1025,6 +1030,7 @@ export default class MainBackground { this.tokenService, this.authService, this.stateProvider, + this.securityStateService, ); this.syncServiceListener = new SyncServiceListener( diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index c2fffef6685..d13d251bce0 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -73,6 +73,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key- import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { DefaultVaultTimeoutService, DefaultVaultTimeoutSettingsService, @@ -305,6 +307,7 @@ export class ServiceContainer { cipherEncryptionService: CipherEncryptionService; restrictedItemTypesService: RestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService; + securityStateService: SecurityStateService; cipherArchiveService: CipherArchiveService; constructor() { @@ -406,6 +409,8 @@ export class ServiceContainer { this.derivedStateProvider, ); + this.securityStateService = new DefaultSecurityStateService(this.stateProvider); + this.environmentService = new DefaultEnvironmentService( this.stateProvider, this.accountService, @@ -612,6 +617,7 @@ export class ServiceContainer { this.accountService, this.kdfConfigService, this.keyService, + this.securityStateService, this.apiService, this.stateProvider, this.configService, @@ -818,6 +824,7 @@ export class ServiceContainer { this.tokenService, this.authService, this.stateProvider, + this.securityStateService, ); this.totpService = new TotpService(this.sdkService); diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts index 54f9ac58291..1f4fa578491 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.ts +++ b/apps/web/src/app/auth/settings/account/profile.component.ts @@ -56,7 +56,9 @@ export class ProfileComponent implements OnInit, OnDestroy { this.profile = await this.apiService.getProfile(); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.fingerprintMaterial = userId; - const publicKey = await firstValueFrom(this.keyService.userPublicKey$(userId)); + const publicKey = (await firstValueFrom( + this.keyService.userPublicKey$(userId), + )) as UserPublicKey; if (publicKey == null) { this.logService.error( "[ProfileComponent] No public key available for the user: " + diff --git a/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts b/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts new file mode 100644 index 00000000000..7504b599e16 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts @@ -0,0 +1,19 @@ +import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SignedPublicKey } from "@bitwarden/sdk-internal"; + +export class PublicKeyEncryptionKeyPairRequestModel { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: string; + signedPublicKey: SignedPublicKey | null; + + constructor( + wrappedPrivateKey: WrappedPrivateKey, + publicKey: UnsignedPublicKey, + signedPublicKey: SignedPublicKey | null, + ) { + this.wrappedPrivateKey = wrappedPrivateKey; + this.publicKey = Utils.fromBufferToB64(publicKey); + this.signedPublicKey = signedPublicKey; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts b/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts new file mode 100644 index 00000000000..2dbf75e2ff8 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts @@ -0,0 +1,18 @@ +import { VerifyingKey, WrappedSigningKey } from "@bitwarden/common/key-management/types"; +import { SignatureAlgorithm } from "@bitwarden/sdk-internal"; + +export class SignatureKeyPairRequestModel { + signatureAlgorithm: SignatureAlgorithm; + wrappedSigningKey: WrappedSigningKey; + verifyingKey: VerifyingKey; + + constructor( + signingKey: WrappedSigningKey, + verifyingKey: VerifyingKey, + signingKeyAlgorithm: SignatureAlgorithm, + ) { + this.signatureAlgorithm = signingKeyAlgorithm; + this.wrappedSigningKey = signingKey; + this.verifyingKey = verifyingKey; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts index 1c9b6c9ceca..2c8964a3588 100644 --- a/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts +++ b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts @@ -1,10 +1,70 @@ -export class AccountKeysRequest { - // Other keys encrypted by the userkey - userKeyEncryptedAccountPrivateKey: string; - accountPublicKey: string; +import { SecurityStateRequest } from "@bitwarden/common/key-management/security-state/request/security-state.request"; +import { WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { PureCrypto } from "@bitwarden/sdk-internal"; - constructor(userKeyEncryptedAccountPrivateKey: string, accountPublicKey: string) { - this.userKeyEncryptedAccountPrivateKey = userKeyEncryptedAccountPrivateKey; - this.accountPublicKey = accountPublicKey; +import { PublicKeyEncryptionKeyPairRequestModel } from "../model/public-key-encryption-key-pair-request.model"; +import { SignatureKeyPairRequestModel } from "../model/signature-key-pair-request-request.model"; +import { V1UserCryptographicState } from "../types/v1-cryptographic-state"; +import { V2UserCryptographicState } from "../types/v2-cryptographic-state"; + +// This request contains other account-owned keys that are encrypted with the user key. +export class AccountKeysRequest { + /** + * @deprecated + */ + userKeyEncryptedAccountPrivateKey: WrappedPrivateKey | null = null; + /** + * @deprecated + */ + accountPublicKey: string | null = null; + + publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairRequestModel | null = null; + signatureKeyPair: SignatureKeyPairRequestModel | null = null; + securityState: SecurityStateRequest | null = null; + + constructor() {} + + static fromV1CryptographicState(state: V1UserCryptographicState): AccountKeysRequest { + const request = new AccountKeysRequest(); + request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey; + request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey); + request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel( + state.publicKeyEncryptionKeyPair.wrappedPrivateKey, + state.publicKeyEncryptionKeyPair.publicKey, + null, + ); + + return request; + } + + static async fromV2CryptographicState( + state: V2UserCryptographicState, + ): Promise { + // Ensure the SDK is loaded, since it is used to derive the signature algorithm. + await SdkLoadService.Ready; + + const request = new AccountKeysRequest(); + request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey!; + request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey); + request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel( + state.publicKeyEncryptionKeyPair.wrappedPrivateKey, + state.publicKeyEncryptionKeyPair.publicKey, + state.publicKeyEncryptionKeyPair.signedPublicKey, + ); + request.signatureKeyPair = new SignatureKeyPairRequestModel( + state.signatureKeyPair.wrappedSigningKey, + state.signatureKeyPair.verifyingKey, + PureCrypto.key_algorithm_for_verifying_key( + Utils.fromB64ToArray(state.signatureKeyPair.verifyingKey), + ), + ); + request.securityState = new SecurityStateRequest( + state.securityState.securityState, + state.securityState.securityStateVersion, + ); + + return request; } } diff --git a/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts b/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts new file mode 100644 index 00000000000..220bdd37752 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts @@ -0,0 +1,10 @@ +import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { UserKey } from "@bitwarden/common/types/key"; + +export type V1UserCryptographicState = { + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + }; +}; diff --git a/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts b/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts new file mode 100644 index 00000000000..da52d6d6eef --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts @@ -0,0 +1,49 @@ +import { + SignedSecurityState, + UnsignedPublicKey, + VerifyingKey, + WrappedPrivateKey, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserKey } from "@bitwarden/common/types/key"; +import { SignedPublicKey, UserCryptoV2KeysResponse } from "@bitwarden/sdk-internal"; + +export type V2UserCryptographicState = { + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + signedPublicKey: SignedPublicKey; + }; + signatureKeyPair: { + wrappedSigningKey: WrappedSigningKey; + verifyingKey: VerifyingKey; + }; + securityState: { + securityState: SignedSecurityState; + securityStateVersion: number; + }; +}; + +export function fromSdkV2KeysToV2UserCryptographicState( + response: UserCryptoV2KeysResponse, +): V2UserCryptographicState { + return { + userKey: SymmetricCryptoKey.fromString(response.userKey) as UserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: response.privateKey as WrappedPrivateKey, + publicKey: Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey, + signedPublicKey: response.signedPublicKey, + }, + signatureKeyPair: { + wrappedSigningKey: response.signingKey as WrappedSigningKey, + verifyingKey: response.verifyingKey as VerifyingKey, + }, + securityState: { + securityState: response.securityState as SignedSecurityState, + securityStateVersion: response.securityVersion, + }, + }; +} diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index 0ffa48048f6..b790fb8409a 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -4,6 +4,7 @@ import { BehaviorSubject } from "rxjs"; import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { @@ -11,10 +12,22 @@ import { EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { + SignedPublicKey, + SignedSecurityState, + UnsignedPublicKey, + VerifyingKey, + WrappedPrivateKey, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; @@ -33,13 +46,14 @@ import { PBKDF2KdfConfig, KdfConfigService, KdfConfig, + KdfType, } from "@bitwarden/key-management"; import { AccountRecoveryTrustComponent, EmergencyAccessTrustComponent, KeyRotationTrustInfoComponent, } from "@bitwarden/key-management-ui"; -import { PureCrypto } from "@bitwarden/sdk-internal"; +import { BitwardenClient, PureCrypto } from "@bitwarden/sdk-internal"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { WebauthnLoginAdminService } from "../../auth"; @@ -48,11 +62,18 @@ import { EmergencyAccessStatusType } from "../../auth/emergency-access/enums/eme import { EmergencyAccessType } from "../../auth/emergency-access/enums/emergency-access-type"; import { EmergencyAccessWithIdRequest } from "../../auth/emergency-access/request/emergency-access-update.request"; +import { AccountKeysRequest } from "./request/account-keys.request"; import { MasterPasswordUnlockDataRequest } from "./request/master-password-unlock-data.request"; import { UnlockDataRequest } from "./request/unlock-data.request"; import { UserDataRequest } from "./request/userdata.request"; +import { V1UserCryptographicState } from "./types/v1-cryptographic-state"; +import { V2UserCryptographicState } from "./types/v2-cryptographic-state"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; -import { UserKeyRotationService } from "./user-key-rotation.service"; +import { + UserKeyRotationService, + V1CryptographicStateParameters, + V2CryptographicStateParameters, +} from "./user-key-rotation.service"; const initialPromptedOpenTrue = jest.fn(); initialPromptedOpenTrue.mockReturnValue({ closed: new BehaviorSubject(true) }); @@ -120,6 +141,21 @@ function createMockWebauthn(id: string): any { } as WebauthnRotateCredentialRequest; } +const TEST_VECTOR_USER_KEY_V1 = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; +const TEST_VECTOR_PRIVATE_KEY_V1 = + "2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|YvFBff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey; +const TEST_VECTOR_PUBLIC_KEY_V1 = Utils.fromBufferToB64(new Uint8Array(400)); +const TEST_VECTOR_PRIVATE_KEY_V1_ROTATED = + "2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|AAAAff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey; + +const TEST_VECTOR_USER_KEY_V2 = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey; +const TEST_VECTOR_PRIVATE_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedPrivateKey; +const TEST_VECTOR_SIGNING_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedSigningKey; +const TEST_VECTOR_VERIFYING_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as VerifyingKey; +const TEST_VECTOR_SECURITY_STATE_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedSecurityState; +const TEST_VECTOR_PUBLIC_KEY_V2 = Utils.fromBufferToB64(new Uint8Array(400)); +const TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedPublicKey; + class TestUserKeyRotationService extends UserKeyRotationService { override rotateUserKeyMasterPasswordAndEncryptedData( currentMasterPassword: string, @@ -138,22 +174,17 @@ class TestUserKeyRotationService extends UserKeyRotationService { return super.ensureIsAllowedToRotateUserKey(); } override getNewAccountKeysV1( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string }; - }> { - return super.getNewAccountKeysV1(currentUserKey, currentUserKeyWrappedPrivateKey); + cryptographicStateParameters: V1CryptographicStateParameters, + ): Promise { + return super.getNewAccountKeysV1(cryptographicStateParameters); } override getNewAccountKeysV2( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string }; - }> { - return super.getNewAccountKeysV2(currentUserKey, currentUserKeyWrappedPrivateKey); + userId: UserId, + kdfConfig: KdfConfig, + email: string, + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters, + ): Promise { + return super.getNewAccountKeysV2(userId, kdfConfig, email, cryptographicStateParameters); } override createMasterPasswordUnlockDataRequest( userKey: UserKey, @@ -176,8 +207,8 @@ class TestUserKeyRotationService extends UserKeyRotationService { masterKeyKdfConfig: KdfConfig; masterPasswordHint: string; }, - trustedEmergencyAccessGranteesPublicKeys: Uint8Array[], - trustedOrganizationPublicKeys: Uint8Array[], + trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[], + trustedOrganizationPublicKeys: UnsignedPublicKey[], ): Promise { return super.getAccountUnlockDataRequest( userId, @@ -190,8 +221,8 @@ class TestUserKeyRotationService extends UserKeyRotationService { } override verifyTrust(user: Account): Promise<{ wasTrustDenied: boolean; - trustedOrganizationPublicKeys: Uint8Array[]; - trustedEmergencyAccessUserPublicKeys: Uint8Array[]; + trustedOrganizationPublicKeys: UnsignedPublicKey[]; + trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[]; }> { return super.verifyTrust(user); } @@ -202,14 +233,6 @@ class TestUserKeyRotationService extends UserKeyRotationService { ): Promise { return super.getAccountDataRequest(originalUserKey, newUnencryptedUserKey, user); } - override makeNewUserKeyV1(oldUserKey: UserKey): Promise { - return super.makeNewUserKeyV1(oldUserKey); - } - override makeNewUserKeyV2( - oldUserKey: UserKey, - ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> { - return super.makeNewUserKeyV2(oldUserKey); - } override isV1User(userKey: UserKey): boolean { return super.isV1User(userKey); } @@ -227,6 +250,13 @@ class TestUserKeyRotationService extends UserKeyRotationService { masterKeySalt, ); } + override getCryptographicStateForUser(user: Account): Promise<{ + masterKeyKdfConfig: KdfConfig; + masterKeySalt: string; + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters; + }> { + return super.getCryptographicStateForUser(user); + } } describe("KeyRotationService", () => { @@ -251,6 +281,8 @@ describe("KeyRotationService", () => { let mockI18nService: MockProxy; let mockCryptoFunctionService: MockProxy; let mockKdfConfigService: MockProxy; + let mockSdkClientFactory: MockProxy; + let mockSecurityStateService: MockProxy; const mockUser = { id: "mockUserId" as UserId, @@ -261,6 +293,9 @@ describe("KeyRotationService", () => { const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")]; + const mockMakeKeysForUserCryptoV2 = jest.fn(); + const mockGetV2RotatedAccountKeys = jest.fn(); + beforeAll(() => { mockApiService = mock(); mockCipherService = mock(); @@ -271,7 +306,7 @@ describe("KeyRotationService", () => { mockTrustedPublicKeys.map((key) => { return { publicKey: key, - id: "mockId", + id: "00000000-0000-0000-0000-000000000000" as UserId, granteeId: "mockGranteeId", name: "mockName", email: "mockEmail", @@ -306,6 +341,17 @@ describe("KeyRotationService", () => { mockDialogService = mock(); mockCryptoFunctionService = mock(); mockKdfConfigService = mock(); + mockSdkClientFactory = mock(); + mockSdkClientFactory.createSdkClient.mockResolvedValue({ + crypto: () => { + return { + initialize_user_crypto: jest.fn(), + make_keys_for_user_crypto_v2: mockMakeKeysForUserCryptoV2, + get_v2_rotated_account_keys: mockGetV2RotatedAccountKeys, + } as any; + }, + } as BitwardenClient); + mockSecurityStateService = mock(); keyRotationService = new TestUserKeyRotationService( mockApiService, @@ -327,6 +373,8 @@ describe("KeyRotationService", () => { mockConfigService, mockCryptoFunctionService, mockKdfConfigService, + mockSdkClientFactory, + mockSecurityStateService, ); }); @@ -334,13 +382,16 @@ describe("KeyRotationService", () => { jest.clearAllMocks(); jest.mock("@bitwarden/key-management-ui"); jest.spyOn(PureCrypto, "make_user_key_aes256_cbc_hmac").mockReturnValue(new Uint8Array(64)); - jest.spyOn(PureCrypto, "make_user_key_xchacha20_poly1305").mockReturnValue(new Uint8Array(70)); jest .spyOn(PureCrypto, "encrypt_user_key_with_master_password") .mockReturnValue("mockNewUserKey"); + Object.defineProperty(SdkLoadService, "Ready", { + value: Promise.resolve(), + configurable: true, + }); }); - describe("rotateUserKeyAndEncryptedData", () => { + describe("rotateUserKeyMasterPasswordAndEncryptedData", () => { let privateKey: BehaviorSubject; let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>; @@ -438,6 +489,64 @@ describe("KeyRotationService", () => { expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2); }); + it("passes the EnrollAeadOnKeyRotation feature flag to getRotatedAccountKeysFlagged", async () => { + KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; + AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; + EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted; + mockKdfConfigService.getKdfConfig$.mockReturnValue( + new BehaviorSubject(new PBKDF2KdfConfig(100000)), + ); + mockKeyService.userKey$.mockReturnValue( + new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey), + ); + mockKeyService.userEncryptedPrivateKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString), + ); + mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null)); + mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null)); + mockConfigService.getFeatureFlag.mockResolvedValue(true); + + const spy = jest.spyOn(keyRotationService, "getRotatedAccountKeysFlagged").mockResolvedValue({ + userKey: TEST_VECTOR_USER_KEY_V2, + accountKeysRequest: { + userKeyEncryptedAccountPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + accountPublicKey: TEST_VECTOR_PUBLIC_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: TEST_VECTOR_PUBLIC_KEY_V2, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2, + signatureAlgorithm: "ed25519", + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2, + securityVersion: 2, + }, + }, + }); + + await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + "mockMasterPassword", + "mockMasterPassword1", + mockUser, + "masterPasswordHint", + ); + + expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.EnrollAeadOnKeyRotation, + ); + expect(spy).toHaveBeenCalledWith( + mockUser.id, + expect.any(PBKDF2KdfConfig), + mockUser.email, + expect.objectContaining({ version: 1 }), + true, + ); + }); + it("throws if kdf config is null", async () => { KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; @@ -511,17 +620,17 @@ describe("KeyRotationService", () => { }); describe("getNewAccountKeysV1", () => { - const currentUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const mockEncryptedPrivateKey = new EncString( - "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=", - ); - const mockNewEncryptedPrivateKey = new EncString( - "2.ab465OrUcluL9UpnCOUTAg==|4HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=", - ); + const currentUserKey = TEST_VECTOR_USER_KEY_V1; + const mockEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey; + const mockNewEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1_ROTATED as WrappedPrivateKey; beforeAll(() => { mockEncryptService.unwrapDecapsulationKey.mockResolvedValue(new Uint8Array(200)); - mockEncryptService.wrapDecapsulationKey.mockResolvedValue(mockNewEncryptedPrivateKey); - mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(new Uint8Array(400)); + mockEncryptService.wrapDecapsulationKey.mockResolvedValue( + new EncString(mockNewEncryptedPrivateKey), + ); + mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( + new Uint8Array(400) as UnsignedPublicKey, + ); }); afterAll(() => { @@ -529,28 +638,110 @@ describe("KeyRotationService", () => { }); it("returns new account keys", async () => { - const result = await keyRotationService.getNewAccountKeysV1( - currentUserKey, - mockEncryptedPrivateKey, - ); + const result = await keyRotationService.getNewAccountKeysV1({ + version: 1, + userKey: currentUserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: mockEncryptedPrivateKey, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + }); expect(result).toEqual({ userKey: expect.any(SymmetricCryptoKey), - asymmetricEncryptionKeys: { + publicKeyEncryptionKeyPair: { wrappedPrivateKey: mockNewEncryptedPrivateKey, - publicKey: Utils.fromBufferToB64(new Uint8Array(400)), + publicKey: new Uint8Array(400) as UserPublicKey, }, }); }); }); describe("getNewAccountKeysV2", () => { - it("throws not supported", async () => { - await expect( - keyRotationService.getNewAccountKeysV2( - new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, - null, - ), - ).rejects.toThrow("User encryption v2 upgrade is not supported yet"); + it("rotates a v2 user", async () => { + mockGetV2RotatedAccountKeys.mockReturnValue({ + userKey: TEST_VECTOR_USER_KEY_V2.toBase64(), + privateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: TEST_VECTOR_PUBLIC_KEY_V2, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + signingKey: TEST_VECTOR_SIGNING_KEY_V2, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2, + securityState: TEST_VECTOR_SECURITY_STATE_V2, + securityVersion: 2, + }); + const result = await keyRotationService.getNewAccountKeysV2( + "00000000-0000-0000-0000-000000000000" as UserId, + new PBKDF2KdfConfig(600_000), + "mockuseremail", + { + version: 2 as const, + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + }, + signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + }, + ); + expect(mockGetV2RotatedAccountKeys).toHaveBeenCalled(); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey, + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + securityStateVersion: 2, + }, + }); + }); + it("upgrades v1 user to v2 user", async () => { + mockMakeKeysForUserCryptoV2.mockReturnValue({ + userKey: TEST_VECTOR_USER_KEY_V2.toBase64(), + privateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + signingKey: TEST_VECTOR_SIGNING_KEY_V2, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2, + securityState: TEST_VECTOR_SECURITY_STATE_V2, + securityVersion: 2, + }); + const result = await keyRotationService.getNewAccountKeysV2( + "00000000-0000-0000-0000-000000000000" as UserId, + new PBKDF2KdfConfig(600_000), + "mockuseremail", + { + version: 1, + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + }, + ); + expect(mockMakeKeysForUserCryptoV2).toHaveBeenCalled(); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2), + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey, + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + securityStateVersion: 2, + }, + }); }); }); @@ -560,7 +751,7 @@ describe("KeyRotationService", () => { new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, ); mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash"); - const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const newKey = TEST_VECTOR_USER_KEY_V1; const userAccount = mockUser; const masterPasswordUnlockData = await keyRotationService.createMasterPasswordUnlockDataRequest(newKey, { @@ -572,13 +763,13 @@ describe("KeyRotationService", () => { expect(masterPasswordUnlockData).toEqual({ masterKeyEncryptedUserKey: "mockNewUserKey", email: "mockEmail", - kdfType: 0, + kdfType: KdfType.PBKDF2_SHA256, kdfIterations: 600_000, masterKeyAuthenticationHash: "mockMasterPasswordHash", masterPasswordHint: "mockMasterPasswordHint", }); expect(PureCrypto.encrypt_user_key_with_master_password).toHaveBeenCalledWith( - new SymmetricCryptoKey(new Uint8Array(64)).toEncoded(), + TEST_VECTOR_USER_KEY_V1.toEncoded(), "mockMasterPassword", userAccount.email, new PBKDF2KdfConfig(600_000).toSdkConfig(), @@ -637,8 +828,8 @@ describe("KeyRotationService", () => { masterKeyKdfConfig: new PBKDF2KdfConfig(600_000), masterPasswordHint: "mockMasterPasswordHint", }, - [new Uint8Array(1)], // emergency access public key - [new Uint8Array(2)], // account recovery public key + [new Uint8Array(1) as UnsignedPublicKey], // emergency access public key + [new Uint8Array(2) as UnsignedPublicKey], // account recovery public key ); expect(accountUnlockDataRequest.passkeyUnlockData).toEqual([ { @@ -758,66 +949,29 @@ describe("KeyRotationService", () => { expect(wasTrustDenied).toBe(true); }); - it("returns trusted keys if all dialogs are accepted", async () => { - KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; - EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted; - AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; - mockEmergencyAccessService.getPublicKeys.mockResolvedValue([ - mockGranteeEmergencyAccessWithPublicKey, - ]); - mockResetPasswordService.getPublicKeys.mockResolvedValue([ - mockOrganizationUserResetPasswordEntry, - ]); - const { - wasTrustDenied, - trustedOrganizationPublicKeys: trustedOrgs, - trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers, - } = await keyRotationService.verifyTrust(mockUser); - expect(wasTrustDenied).toBe(false); - expect(trustedEmergencyAccessUsers).toEqual([ - mockGranteeEmergencyAccessWithPublicKey.publicKey, - ]); - expect(trustedOrgs).toEqual([mockOrganizationUserResetPasswordEntry.publicKey]); - }); - }); - - describe("makeNewUserKeyV1", () => { - it("throws if old keys is xchacha20poly1305 key", async () => { - await expect( - keyRotationService.makeNewUserKeyV1(new SymmetricCryptoKey(new Uint8Array(70)) as UserKey), - ).rejects.toThrow( - "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.", - ); - }); - it("returns new user key", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const newKey = await keyRotationService.makeNewUserKeyV1(oldKey); - expect(newKey).toEqual(new SymmetricCryptoKey(new Uint8Array(64))); - }); - }); - - describe("makeNewUserKeyV2", () => { - it("returns xchacha20poly1305 key", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey; - const { newUserKey } = await keyRotationService.makeNewUserKeyV2(oldKey); - expect(newUserKey).toEqual(new SymmetricCryptoKey(new Uint8Array(70))); - }); - it("returns isUpgrading true if old key is v1", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const newKey = await keyRotationService.makeNewUserKeyV2(oldKey); - expect(newKey).toEqual({ - newUserKey: new SymmetricCryptoKey(new Uint8Array(70)), - isUpgrading: true, - }); - }); - it("returns isUpgrading false if old key is v2", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey; - const newKey = await keyRotationService.makeNewUserKeyV2(oldKey); - expect(newKey).toEqual({ - newUserKey: new SymmetricCryptoKey(new Uint8Array(70)), - isUpgrading: false, - }); - }); + test.each([ + [[mockGranteeEmergencyAccessWithPublicKey], []], + [[], [mockOrganizationUserResetPasswordEntry]], + [[], []], + [[mockGranteeEmergencyAccessWithPublicKey], [mockOrganizationUserResetPasswordEntry]], + ])( + "returns trusted keys when dialogs are open and public keys are provided", + async (emUsers, orgs) => { + KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; + EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted; + AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; + mockEmergencyAccessService.getPublicKeys.mockResolvedValue(emUsers); + mockResetPasswordService.getPublicKeys.mockResolvedValue(orgs); + const { + wasTrustDenied, + trustedOrganizationPublicKeys: trustedOrgs, + trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers, + } = await keyRotationService.verifyTrust(mockUser); + expect(wasTrustDenied).toBe(false); + expect(trustedEmergencyAccessUsers).toEqual(emUsers.map((e) => e.publicKey)); + expect(trustedOrgs).toEqual(orgs.map((o) => o.publicKey)); + }, + ); }); describe("getAccountDataRequest", () => { @@ -890,13 +1044,264 @@ describe("KeyRotationService", () => { }); describe("isV1UserKey", () => { - const v1Key = new SymmetricCryptoKey(new Uint8Array(64)); - const v2Key = new SymmetricCryptoKey(new Uint8Array(70)); + const aes256CbcHmacV1UserKey = new SymmetricCryptoKey(new Uint8Array(64)); + const coseV2UserKey = new SymmetricCryptoKey(new Uint8Array(70)); it("returns true for v1 key", () => { - expect(keyRotationService.isV1User(v1Key as UserKey)).toBe(true); + expect(keyRotationService.isV1User(aes256CbcHmacV1UserKey as UserKey)).toBe(true); }); it("returns false for v2 key", () => { - expect(keyRotationService.isV1User(v2Key as UserKey)).toBe(false); + expect(keyRotationService.isV1User(coseV2UserKey as UserKey)).toBe(false); + }); + it("returns false for 32 byte AES256-CBC key", () => { + const aes256CbcKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + expect(keyRotationService.isV1User(aes256CbcKey)).toBe(false); + }); + }); + + describe("makeServerMasterKeyAuthenticationHash", () => { + it("returns the master key authentication hash", async () => { + mockKeyService.makeMasterKey.mockResolvedValue( + new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, + ); + mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash"); + const masterKeyAuthenticationHash = + await keyRotationService.makeServerMasterKeyAuthenticationHash( + "mockMasterPassword", + new PBKDF2KdfConfig(600_000), + "mockEmail", + ); + expect(masterKeyAuthenticationHash).toBe("mockMasterPasswordHash"); + expect(mockKeyService.makeMasterKey).toHaveBeenCalledWith( + "mockMasterPassword", + "mockEmail", + new PBKDF2KdfConfig(600_000), + ); + expect(mockKeyService.hashMasterKey).toHaveBeenCalledWith( + "mockMasterPassword", + new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, + HashPurpose.ServerAuthorization, + ); + }); + }); + + describe("getCryptographicStateForUser", () => { + beforeEach(() => { + mockKdfConfigService.getKdfConfig$.mockReturnValue( + new BehaviorSubject(new PBKDF2KdfConfig(100000)), + ); + mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(TEST_VECTOR_USER_KEY_V2)); + mockKeyService.userEncryptedPrivateKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V2 as string as EncryptedString), + ); + mockKeyService.userSigningKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey), + ); + mockSecurityStateService.accountSecurityState$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState), + ); + mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( + new Uint8Array(400) as UnsignedPublicKey, + ); + }); + + it("returns the cryptographic state for v1 user", async () => { + mockKeyService.userKey$.mockReturnValue( + new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey), + ); + mockKeyService.userEncryptedPrivateKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString), + ); + mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null)); + mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null)); + + const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); + expect(cryptographicState).toEqual({ + masterKeyKdfConfig: new PBKDF2KdfConfig(100000), + masterKeySalt: "mockemail", // the email is lowercased to become the salt + cryptographicStateParameters: { + version: 1, + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1), + }, + }, + }); + }); + + it("returns the cryptographic state for v2 user", async () => { + const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); + expect(cryptographicState).toEqual({ + masterKeyKdfConfig: new PBKDF2KdfConfig(100000), + masterKeySalt: "mockemail", // the email is lowercased to become the salt + cryptographicStateParameters: { + version: 2, + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + }, + signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + }, + }); + }); + + it("throws if no kdf config is found", async () => { + mockKdfConfigService.getKdfConfig$.mockReturnValue(new BehaviorSubject(null)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Failed to get KDF config", + ); + }); + + it("throws if current user key is not found", async () => { + mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(null)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Failed to get User key", + ); + }); + + it("throws if private key is not found", async () => { + mockKeyService.userEncryptedPrivateKey$.mockReturnValue(new BehaviorSubject(null)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Failed to get Private key", + ); + }); + + it("throws if user key is not AES256-CBC-HMAC or COSE", async () => { + const invalidKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(invalidKey)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Unsupported user key type", + ); + }); + }); + + describe("getRotatedAccountKeysFlagged", () => { + const userId = "mockUserId" as UserId; + const kdfConfig = new PBKDF2KdfConfig(100000); + const masterKeySalt = "mockSalt"; + const v1Params = { + version: 1, + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + } as V1CryptographicStateParameters; + const v2Params = { + version: 2, + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + }, + signingKey: TEST_VECTOR_SIGNING_KEY_V2, + securityState: TEST_VECTOR_SECURITY_STATE_V2, + } as V2CryptographicStateParameters; + + beforeEach(() => { + jest.spyOn(keyRotationService, "getNewAccountKeysV1").mockResolvedValue({ + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1_ROTATED, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + }); + jest.spyOn(keyRotationService, "getNewAccountKeysV2").mockResolvedValue({ + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey, + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + securityStateVersion: 2, + }, + }); + jest + .spyOn(AccountKeysRequest, "fromV1CryptographicState") + .mockReturnValue("v1Request" as any); + jest + .spyOn(AccountKeysRequest, "fromV2CryptographicState") + .mockResolvedValue("v2Request" as any); + }); + + it("returns v2 keys and request if v2UpgradeEnabled is true", async () => { + const result = await keyRotationService.getRotatedAccountKeysFlagged( + userId, + kdfConfig, + masterKeySalt, + v1Params, + true, + ); + expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith( + userId, + kdfConfig, + masterKeySalt, + v1Params, + ); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + accountKeysRequest: "v2Request", + }); + }); + + it("returns v2 keys and request if params.version is 2", async () => { + const result = await keyRotationService.getRotatedAccountKeysFlagged( + userId, + kdfConfig, + masterKeySalt, + v2Params, + false, + ); + expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith( + userId, + kdfConfig, + masterKeySalt, + v2Params, + ); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + accountKeysRequest: "v2Request", + }); + }); + + it("returns v1 keys and request if v2UpgradeEnabled is false and params.version is 1", async () => { + const result = await keyRotationService.getRotatedAccountKeysFlagged( + userId, + kdfConfig, + masterKeySalt, + v1Params, + false, + ); + expect(keyRotationService.getNewAccountKeysV1).toHaveBeenCalledWith(v1Params); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V1, + accountKeysRequest: "v1Request", + }); + }); + }); + + describe("ensureIsAllowedToRotateUserKey", () => { + it("resolves if last sync exists", async () => { + mockSyncService.getLastSync.mockResolvedValue(new Date()); + await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).resolves.toBeUndefined(); + }); + + it("throws if last sync is null", async () => { + mockSyncService.getLastSync.mockResolvedValue(null); + await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).rejects.toThrow( + /de-synced|log out and log back in/i, + ); + expect(mockLogService.info).toHaveBeenCalledWith( + "[Userkey rotation] Client was never synced. Aborting!", + ); }); }); }); diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index f7f611b75ee..0980beddd09 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Observable } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -7,13 +7,21 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; -import { firstValueFromOrThrow } from "@bitwarden/common/key-management/utils"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { + SignedSecurityState, + UnsignedPublicKey, + WrappedPrivateKey, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { EncryptionType, HashPurpose } from "@bitwarden/common/platform/enums"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; @@ -28,7 +36,7 @@ import { EmergencyAccessTrustComponent, KeyRotationTrustInfoComponent, } from "@bitwarden/key-management-ui"; -import { PureCrypto } from "@bitwarden/sdk-internal"; +import { PureCrypto, TokenProvider } from "@bitwarden/sdk-internal"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { WebauthnLoginAdminService } from "../../auth/core"; @@ -39,6 +47,11 @@ import { MasterPasswordUnlockDataRequest } from "./request/master-password-unloc import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request"; import { UnlockDataRequest } from "./request/unlock-data.request"; import { UserDataRequest } from "./request/userdata.request"; +import { V1UserCryptographicState } from "./types/v1-cryptographic-state"; +import { + fromSdkV2KeysToV2UserCryptographicState, + V2UserCryptographicState, +} from "./types/v2-cryptographic-state"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; type MasterPasswordAuthenticationAndUnlockData = { @@ -48,6 +61,19 @@ type MasterPasswordAuthenticationAndUnlockData = { masterPasswordHint: string; }; +/** + * A token provider that exposes a null access token to the SDK. + */ +class NoopTokenProvider implements TokenProvider { + constructor() {} + + async get_access_token(): Promise { + // Ignore from the test coverage, since this is called by the SDK + /* istanbul ignore next */ + return undefined; + } +} + @Injectable({ providedIn: "root" }) export class UserKeyRotationService { constructor( @@ -70,6 +96,8 @@ export class UserKeyRotationService { private configService: ConfigService, private cryptoFunctionService: CryptoFunctionService, private kdfConfigService: KdfConfigService, + private sdkClientFactory: SdkClientFactory, + private securityStateService: SecurityStateService, ) {} /** @@ -85,12 +113,15 @@ export class UserKeyRotationService { user: Account, newMasterPasswordHint?: string, ): Promise { - this.logService.info("[UserKey Rotation] Starting user key rotation..."); + // Key-rotation uses the SDK, so we need to ensure that the SDK is loaded / the WASM initialized. + await SdkLoadService.Ready; const upgradeToV2FeatureFlagEnabled = await this.configService.getFeatureFlag( FeatureFlag.EnrollAeadOnKeyRotation, ); + this.logService.info("[UserKey Rotation] Starting user key rotation..."); + // Make sure all conditions match - e.g. account state is up to date await this.ensureIsAllowedToRotateUserKey(); @@ -104,53 +135,26 @@ export class UserKeyRotationService { } // Read current cryptographic state / settings - const masterKeyKdfConfig: KdfConfig = (await firstValueFromOrThrow( - this.kdfConfigService.getKdfConfig$(user.id), - "KDF config", - ))!; - // The masterkey salt used for deriving the masterkey always needs to be trimmed and lowercased. - const masterKeySalt = user.email.trim().toLowerCase(); - const currentUserKey: UserKey = (await firstValueFromOrThrow( - this.keyService.userKey$(user.id), - "User key", - ))!; - const currentUserKeyWrappedPrivateKey = new EncString( - (await firstValueFromOrThrow( - this.keyService.userEncryptedPrivateKey$(user.id), - "User encrypted private key", - ))!, - ); + const { + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters: currentCryptographicStateParameters, + } = await this.getCryptographicStateForUser(user); - // Update account keys - // This creates at least a new user key, and possibly upgrades user encryption formats - let newUserKey: UserKey; - let wrappedPrivateKey: EncString; - let publicKey: string; - if (upgradeToV2FeatureFlagEnabled) { - this.logService.info("[Userkey rotation] Using v2 account keys"); - const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV2( - currentUserKey, - currentUserKeyWrappedPrivateKey, - ); - newUserKey = userKey; - wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey; - publicKey = asymmetricEncryptionKeys.publicKey; - } else { - this.logService.info("[Userkey rotation] Using v1 account keys"); - const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV1( - currentUserKey, - currentUserKeyWrappedPrivateKey, - ); - newUserKey = userKey; - wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey; - publicKey = asymmetricEncryptionKeys.publicKey; - } + // Get new set of keys for the account. + const { userKey: newUserKey, accountKeysRequest } = await this.getRotatedAccountKeysFlagged( + user.id, + masterKeyKdfConfig, + user.email, + currentCryptographicStateParameters, + upgradeToV2FeatureFlagEnabled, + ); // Assemble the key rotation request const request = new RotateUserAccountKeysRequest( await this.getAccountUnlockDataRequest( user.id, - currentUserKey, + currentCryptographicStateParameters.userKey, newUserKey, { masterPassword: newMasterPassword, @@ -161,8 +165,12 @@ export class UserKeyRotationService { trustedEmergencyAccessUserPublicKeys, trustedOrganizationPublicKeys, ), - new AccountKeysRequest(wrappedPrivateKey.encryptedString!, publicKey), - await this.getAccountDataRequest(currentUserKey, newUserKey, user), + accountKeysRequest, + await this.getAccountDataRequest( + currentCryptographicStateParameters.userKey, + newUserKey, + user, + ), await this.makeServerMasterKeyAuthenticationHash( currentMasterPassword, masterKeyKdfConfig, @@ -194,55 +202,153 @@ export class UserKeyRotationService { } } + async getRotatedAccountKeysFlagged( + userId: UserId, + kdfConfig: KdfConfig, + masterKeySalt: string, + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters, + v2UpgradeEnabled: boolean, + ): Promise<{ userKey: UserKey; accountKeysRequest: AccountKeysRequest }> { + if (v2UpgradeEnabled || cryptographicStateParameters.version === 2) { + const keys = await this.getNewAccountKeysV2( + userId, + kdfConfig, + masterKeySalt, + cryptographicStateParameters, + ); + return { + userKey: keys.userKey, + accountKeysRequest: await AccountKeysRequest.fromV2CryptographicState(keys), + }; + } else { + const keys = await this.getNewAccountKeysV1( + cryptographicStateParameters as V1CryptographicStateParameters, + ); + return { + userKey: keys.userKey, + accountKeysRequest: AccountKeysRequest.fromV1CryptographicState(keys), + }; + } + } + + /** + * This method rotates the user key of a V1 user and re-encrypts the private key. + * @deprecated Removed after roll-out of V2 encryption. + */ protected async getNewAccountKeysV1( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { - wrappedPrivateKey: EncString; - publicKey: string; - }; - }> { - // Account key rotation creates a new userkey. All downstream data and keys need to be re-encrypted under this key. + cryptographicStateParameters: V1CryptographicStateParameters, + ): Promise { + // Account key rotation creates a new user key. All downstream data and keys need to be re-encrypted under this key. // Further, this method is used to create new keys in the event that the key hierarchy changes, such as for the // creation of a new signing key pair. - const newUserKey = await this.makeNewUserKeyV1(currentUserKey); + const newUserKey = new SymmetricCryptoKey( + PureCrypto.make_user_key_aes256_cbc_hmac(), + ) as UserKey; // Re-encrypt the private key with the new user key // Rotation of the private key is not supported yet const privateKey = await this.encryptService.unwrapDecapsulationKey( - currentUserKeyWrappedPrivateKey, - currentUserKey, + new EncString(cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey), + cryptographicStateParameters.userKey, ); - const newUserKeyWrappedPrivateKey = await this.encryptService.wrapDecapsulationKey( + const newUserKeyWrappedPrivateKey = ( + await this.encryptService.wrapDecapsulationKey(privateKey, newUserKey) + ).encryptedString! as string as WrappedPrivateKey; + const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey( privateKey, - newUserKey, - ); - const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); + )) as UnsignedPublicKey; return { userKey: newUserKey, - asymmetricEncryptionKeys: { + publicKeyEncryptionKeyPair: { wrappedPrivateKey: newUserKeyWrappedPrivateKey, - publicKey: Utils.fromBufferToB64(publicKey), + publicKey: publicKey, }, }; } + /** + * This method either enrolls a user from v1 encryption to v2 encryption, rotating the user key, or rotates the keys of a v2 user, staying on v2. + */ protected async getNewAccountKeysV2( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { - wrappedPrivateKey: EncString; - publicKey: string; - }; - }> { - throw new Error("User encryption v2 upgrade is not supported yet"); + userId: UserId, + masterKeyKdfConfig: KdfConfig, + masterKeySalt: string, + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters, + ): Promise { + if (cryptographicStateParameters.version === 1) { + return this.upgradeV1UserToV2UserAccountKeys( + userId, + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters as V1CryptographicStateParameters, + ); + } else { + return this.rotateV2UserAccountKeys( + userId, + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters as V2CryptographicStateParameters, + ); + } } + /** + * Upgrades a V1 user to a V2 user by creating a new user key, re-encrypting the private key, generating a signature key-pair, and + * finally creating a signed security state. + */ + protected async upgradeV1UserToV2UserAccountKeys( + userId: UserId, + kdfConfig: KdfConfig, + email: string, + cryptographicStateParameters: V1CryptographicStateParameters, + ): Promise { + // Initialize an SDK with the current cryptographic state + const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider()); + await sdk.crypto().initialize_user_crypto({ + userId: asUuid(userId), + kdfParams: kdfConfig.toSdkConfig(), + email: email, + privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, + signingKey: undefined, + securityState: undefined, + method: { + decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() }, + }, + }); + + return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().make_keys_for_user_crypto_v2()); + } + + /** + * Generates a new user key for a v2 user, and re-encrypts the private key, signing key. + */ + protected async rotateV2UserAccountKeys( + userId: UserId, + kdfConfig: KdfConfig, + email: string, + cryptographicStateParameters: V2CryptographicStateParameters, + ): Promise { + // Initialize an SDK with the current cryptographic state + const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider()); + await sdk.crypto().initialize_user_crypto({ + userId: asUuid(userId), + kdfParams: kdfConfig.toSdkConfig(), + email: email, + privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, + signingKey: cryptographicStateParameters.signingKey, + securityState: cryptographicStateParameters.securityState, + method: { + decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() }, + }, + }); + + return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().get_v2_rotated_account_keys()); + } + + /** + * Generates a new request for updating the master-password unlock/authentication data. + */ protected async createMasterPasswordUnlockDataRequest( userKey: UserKey, newUnlockData: MasterPasswordAuthenticationAndUnlockData, @@ -272,13 +378,17 @@ export class UserKeyRotationService { ); } + /** + * Re-generates the accounts unlock methods, including master-password, passkey, trusted device, emergency access, and organization account recovery + * for the new user key. + */ protected async getAccountUnlockDataRequest( userId: UserId, currentUserKey: UserKey, newUserKey: UserKey, masterPasswordAuthenticationAndUnlockData: MasterPasswordAuthenticationAndUnlockData, - trustedEmergencyAccessGranteesPublicKeys: Uint8Array[], - trustedOrganizationPublicKeys: Uint8Array[], + trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[], + trustedOrganizationPublicKeys: UnsignedPublicKey[], ): Promise { // To ensure access; all unlock methods need to be updated and provided the new user key. // User unlock methods @@ -321,10 +431,13 @@ export class UserKeyRotationService { ); } + /** + * Verifies the trust of the organizations and emergency access users by prompting the user. Denying any of these will return early. + */ protected async verifyTrust(user: Account): Promise<{ wasTrustDenied: boolean; - trustedOrganizationPublicKeys: Uint8Array[]; - trustedEmergencyAccessUserPublicKeys: Uint8Array[]; + trustedOrganizationPublicKeys: UnsignedPublicKey[]; + trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[]; }> { // Since currently the joined organizations and emergency access grantees are // not signed, manual trust prompts are required, to verify that the server @@ -392,11 +505,16 @@ export class UserKeyRotationService { ); return { wasTrustDenied: false, - trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey), - trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map((d) => d.publicKey), + trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey as UnsignedPublicKey), + trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map( + (d) => d.publicKey as UnsignedPublicKey, + ), }; } + /** + * Re-encrypts the account data owned by the user, such as ciphers, folders, and sends with the new user key. + */ protected async getAccountDataRequest( originalUserKey: UserKey, newUnencryptedUserKey: UserKey, @@ -429,64 +547,6 @@ export class UserKeyRotationService { return new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends); } - protected async makeNewUserKeyV1(oldUserKey: UserKey): Promise { - // The user's account format is determined by the user key. - // Being tied to the userkey ensures an all-or-nothing approach. A compromised - // server cannot downgrade to a previous format (no signing keys) without - // completely making the account unusable. - // - // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts) - // This format is unsupported, and not secure; It is being forced migrated, and being removed - // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025) - // This format is still supported, but may be migrated in the future - // V2: XChaCha20-Poly1305 userkey, signing key, account security version - // This is the new, modern format. - if (this.isV1User(oldUserKey)) { - this.logService.info( - "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; not upgrading", - ); - return new SymmetricCryptoKey(PureCrypto.make_user_key_aes256_cbc_hmac()) as UserKey; - } else { - // If the feature flag is rolled back, we want to block rotation in order to be as safe as possible with the user's account. - this.logService.info( - "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305, but feature flag is not enabled; aborting..", - ); - throw new Error( - "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.", - ); - } - } - - protected async makeNewUserKeyV2( - oldUserKey: UserKey, - ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> { - // The user's account format is determined by the user key. - // Being tied to the userkey ensures an all-or-nothing approach. A compromised - // server cannot downgrade to a previous format (no signing keys) without - // completely making the account unusable. - // - // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts) - // This format is unsupported, and not secure; It is being forced migrated, and being removed - // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025) - // This format is still supported, but may be migrated in the future - // V2: XChaCha20-Poly1305 userkey, signing key, account security version - // This is the new, modern format. - const newUserKey: UserKey = new SymmetricCryptoKey( - PureCrypto.make_user_key_xchacha20_poly1305(), - ) as UserKey; - const isUpgrading = this.isV1User(oldUserKey); - if (isUpgrading) { - this.logService.info( - "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; upgrading to XChaCha20-Poly1305", - ); - } else { - this.logService.info( - "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305; no upgrade needed", - ); - } - return { isUpgrading, newUserKey }; - } - /** * A V1 user has no signing key, and uses AES256-CBC-HMAC. * A V2 user has a signing key, and uses XChaCha20-Poly1305. @@ -516,4 +576,111 @@ export class UserKeyRotationService { HashPurpose.ServerAuthorization, ); } + + /** + * Gets the cryptographic state for a user. This can be a V1 user or a V2 user. + */ + protected async getCryptographicStateForUser(user: Account): Promise<{ + masterKeyKdfConfig: KdfConfig; + masterKeySalt: string; + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters; + }> { + // Master password unlock + const masterKeyKdfConfig: KdfConfig = (await this.firstValueFromOrThrow( + this.kdfConfigService.getKdfConfig$(user.id), + "KDF config", + ))!; + // The master key salt used for deriving the masterkey always needs to be trimmed and lowercased. + const masterKeySalt = user.email.trim().toLowerCase(); + + // V1 and V2 users both have a user key and a private key + const currentUserKey: UserKey = (await this.firstValueFromOrThrow( + this.keyService.userKey$(user.id), + "User key", + ))!; + const currentUserKeyWrappedPrivateKey: WrappedPrivateKey = new EncString( + (await this.firstValueFromOrThrow( + this.keyService.userEncryptedPrivateKey$(user.id), + "Private key", + ))!, + ).encryptedString! as string as WrappedPrivateKey; + const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey( + await this.encryptService.unwrapDecapsulationKey( + new EncString(currentUserKeyWrappedPrivateKey), + currentUserKey, + ), + )) as UnsignedPublicKey; + + if (this.isV1User(currentUserKey)) { + return { + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters: { + version: 1, + userKey: currentUserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: currentUserKeyWrappedPrivateKey, + publicKey: publicKey, + }, + }, + }; + } else if (currentUserKey.inner().type === EncryptionType.CoseEncrypt0) { + const signingKey = await this.firstValueFromOrThrow( + this.keyService.userSigningKey$(user.id), + "User signing key", + ); + const securityState = await this.firstValueFromOrThrow( + this.securityStateService.accountSecurityState$(user.id), + "User security state", + ); + + return { + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters: { + version: 2, + userKey: currentUserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: currentUserKeyWrappedPrivateKey, + publicKey: publicKey, + }, + signingKey: signingKey!, + securityState: securityState!, + }, + }; + } + + /// AES-CBC (no-hmac) keys are not supported as user keys + throw new Error( + `Unsupported user key type: ${currentUserKey.inner().type}. Expected AesCbc256_HmacSha256_B64 or XChaCha20_Poly1305_B64.`, + ); + } + + async firstValueFromOrThrow(value: Observable, name: string): Promise { + const result = await firstValueFrom(value); + if (result == null) { + throw new Error(`Failed to get ${name}`); + } + return result as T; + } } + +export type V1CryptographicStateParameters = { + version: 1; + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + }; +}; + +export type V2CryptographicStateParameters = { + version: 2; + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + }; + signingKey: WrappedSigningKey; + securityState: SignedSecurityState; +}; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 15f52d0e65c..717a6c501c9 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -170,6 +170,8 @@ import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/ch import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; +import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; +import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service"; import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, @@ -177,6 +179,8 @@ import { import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { SendPasswordService, DefaultSendPasswordService, @@ -702,6 +706,11 @@ const safeProviders: SafeProvider[] = [ KdfConfigService, ], }), + safeProvider({ + provide: SecurityStateService, + useClass: DefaultSecurityStateService, + deps: [StateProvider], + }), safeProvider({ provide: RestrictedItemTypesService, useClass: RestrictedItemTypesService, @@ -797,6 +806,11 @@ const safeProviders: SafeProvider[] = [ useClass: SendApiService, deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService], }), + safeProvider({ + provide: KeyApiService, + useClass: DefaultKeyApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: SyncService, useClass: DefaultSyncService, @@ -825,6 +839,7 @@ const safeProviders: SafeProvider[] = [ TokenServiceAbstraction, AuthServiceAbstraction, StateProvider, + SecurityStateService, ], }), safeProvider({ @@ -1523,6 +1538,7 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, KdfConfigService, KeyService, + SecurityStateService, ApiServiceAbstraction, StateProvider, ConfigService, diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts index 175da716803..11c186bc393 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts @@ -8,6 +8,7 @@ import { } from "../../../platform/models/domain/decrypt-parameters"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "../../../types/csprng"; +import { UnsignedPublicKey } from "../../types"; import { CryptoFunctionService } from "../abstractions/crypto-function.service"; export class WebCryptoFunctionService implements CryptoFunctionService { @@ -309,7 +310,7 @@ export class WebCryptoFunctionService implements CryptoFunctionService { "encrypt", ]); const buffer = await this.subtle.exportKey("spki", impPublicKey); - return new Uint8Array(buffer); + return new Uint8Array(buffer) as UnsignedPublicKey; } async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise { diff --git a/libs/common/src/key-management/enums/signing-key-type.enum.ts b/libs/common/src/key-management/enums/signing-key-type.enum.ts new file mode 100644 index 00000000000..b1370ef0034 --- /dev/null +++ b/libs/common/src/key-management/enums/signing-key-type.enum.ts @@ -0,0 +1,13 @@ +export const SigningKeyTypes = { + Ed25519: "ed25519", +} as const; + +export type SigningKeyType = (typeof SigningKeyTypes)[keyof typeof SigningKeyTypes]; +export function parseSigningKeyTypeFromString(value: string): SigningKeyType { + switch (value) { + case SigningKeyTypes.Ed25519: + return SigningKeyTypes.Ed25519; + default: + throw new Error(`Unknown signing key type: ${value}`); + } +} diff --git a/libs/common/src/key-management/keys/response/private-keys.response.ts b/libs/common/src/key-management/keys/response/private-keys.response.ts new file mode 100644 index 00000000000..2bd723fb455 --- /dev/null +++ b/libs/common/src/key-management/keys/response/private-keys.response.ts @@ -0,0 +1,55 @@ +import { SecurityStateResponse } from "../../security-state/response/security-state.response"; + +import { PublicKeyEncryptionKeyPairResponse } from "./public-key-encryption-key-pair.response"; +import { SignatureKeyPairResponse } from "./signature-key-pair.response"; + +/** + * The privately accessible view of an entity (account / org)'s keys. + * This includes the full key-pairs for public-key encryption and signing, as well as the security state if available. + */ +export class PrivateKeysResponseModel { + readonly publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairResponse; + readonly signatureKeyPair: SignatureKeyPairResponse | null = null; + readonly securityState: SecurityStateResponse | null = null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if ( + !("publicKeyEncryptionKeyPair" in response) || + typeof response.publicKeyEncryptionKeyPair !== "object" + ) { + throw new TypeError("Response must contain a valid publicKeyEncryptionKeyPair"); + } + this.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairResponse( + response.publicKeyEncryptionKeyPair, + ); + + if ( + "signatureKeyPair" in response && + typeof response.signatureKeyPair === "object" && + response.signatureKeyPair != null + ) { + this.signatureKeyPair = new SignatureKeyPairResponse(response.signatureKeyPair); + } + + if ( + "securityState" in response && + typeof response.securityState === "object" && + response.securityState != null + ) { + this.securityState = new SecurityStateResponse(response.securityState); + } + + if ( + (this.signatureKeyPair !== null && this.securityState === null) || + (this.signatureKeyPair === null && this.securityState !== null) + ) { + throw new TypeError( + "Both signatureKeyPair and securityState must be present or absent together", + ); + } + } +} diff --git a/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts b/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts new file mode 100644 index 00000000000..4b1e3f90e29 --- /dev/null +++ b/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts @@ -0,0 +1,32 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { SignedPublicKey, UnsignedPublicKey, WrappedPrivateKey } from "../../types"; + +export class PublicKeyEncryptionKeyPairResponse { + readonly wrappedPrivateKey: WrappedPrivateKey; + readonly publicKey: UnsignedPublicKey; + + readonly signedPublicKey: SignedPublicKey | null = null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("publicKey" in response) || typeof response.publicKey !== "string") { + throw new TypeError("Response must contain a valid publicKey"); + } + this.publicKey = Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey; + + if (!("wrappedPrivateKey" in response) || typeof response.wrappedPrivateKey !== "string") { + throw new TypeError("Response must contain a valid wrappedPrivateKey"); + } + this.wrappedPrivateKey = response.wrappedPrivateKey as WrappedPrivateKey; + + if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") { + this.signedPublicKey = response.signedPublicKey as SignedPublicKey; + } else { + this.signedPublicKey = null; + } + } +} diff --git a/libs/common/src/key-management/keys/response/public-keys.response.ts b/libs/common/src/key-management/keys/response/public-keys.response.ts new file mode 100644 index 00000000000..cf4b3efc349 --- /dev/null +++ b/libs/common/src/key-management/keys/response/public-keys.response.ts @@ -0,0 +1,44 @@ +import { SignedPublicKey } from "@bitwarden/sdk-internal"; + +import { UnsignedPublicKey, VerifyingKey } from "../../types"; + +/** + * The publicly accessible view of an entity (account / org)'s keys. That includes the encryption public key, and the verifying key if available. + */ +export class PublicKeysResponseModel { + readonly publicKey: UnsignedPublicKey; + readonly verifyingKey: VerifyingKey | null; + readonly signedPublicKey?: SignedPublicKey | null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("publicKey" in response) || !(response.publicKey instanceof Uint8Array)) { + throw new TypeError("Response must contain a valid publicKey"); + } + this.publicKey = response.publicKey as UnsignedPublicKey; + + if ("verifyingKey" in response && typeof response.verifyingKey === "string") { + this.verifyingKey = response.verifyingKey as VerifyingKey; + } else { + this.verifyingKey = null; + } + + if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") { + this.signedPublicKey = response.signedPublicKey as SignedPublicKey; + } else { + this.signedPublicKey = null; + } + + if ( + (this.signedPublicKey !== null && this.verifyingKey === null) || + (this.signedPublicKey === null && this.verifyingKey !== null) + ) { + throw new TypeError( + "Both signedPublicKey and verifyingKey must be present or absent together", + ); + } + } +} diff --git a/libs/common/src/key-management/keys/response/signature-key-pair.response.ts b/libs/common/src/key-management/keys/response/signature-key-pair.response.ts new file mode 100644 index 00000000000..2499839b64e --- /dev/null +++ b/libs/common/src/key-management/keys/response/signature-key-pair.response.ts @@ -0,0 +1,22 @@ +import { VerifyingKey, WrappedSigningKey } from "../../types"; + +export class SignatureKeyPairResponse { + readonly wrappedSigningKey: WrappedSigningKey; + readonly verifyingKey: VerifyingKey; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("wrappedSigningKey" in response) || typeof response.wrappedSigningKey !== "string") { + throw new TypeError("Response must contain a valid wrappedSigningKey"); + } + this.wrappedSigningKey = response.wrappedSigningKey as WrappedSigningKey; + + if (!("verifyingKey" in response) || typeof response.verifyingKey !== "string") { + throw new TypeError("Response must contain a valid verifyingKey"); + } + this.verifyingKey = response.verifyingKey as VerifyingKey; + } +} diff --git a/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts b/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts new file mode 100644 index 00000000000..93556dbb57a --- /dev/null +++ b/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts @@ -0,0 +1,5 @@ +import { PublicKeysResponseModel } from "../../response/public-keys.response"; + +export abstract class KeyApiService { + abstract getUserPublicKeys(id: string): Promise; +} diff --git a/libs/common/src/key-management/keys/services/default-key-api-service.service.ts b/libs/common/src/key-management/keys/services/default-key-api-service.service.ts new file mode 100644 index 00000000000..fd8321055be --- /dev/null +++ b/libs/common/src/key-management/keys/services/default-key-api-service.service.ts @@ -0,0 +1,15 @@ +import { UserId } from "@bitwarden/common/types/guid"; + +import { ApiService } from "../../../abstractions/api.service"; +import { PublicKeysResponseModel } from "../response/public-keys.response"; + +import { KeyApiService } from "./abstractions/key-api-service.abstraction"; + +export class DefaultKeyApiService implements KeyApiService { + constructor(private apiService: ApiService) {} + + async getUserPublicKeys(id: UserId): Promise { + const response = await this.apiService.send("GET", "/users/" + id + "/keys", null, true, true); + return new PublicKeysResponseModel(response); + } +} diff --git a/libs/common/src/key-management/security-state/abstractions/security-state.service.ts b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts new file mode 100644 index 00000000000..466095c2f45 --- /dev/null +++ b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts @@ -0,0 +1,21 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { SignedSecurityState } from "../../types"; + +export abstract class SecurityStateService { + /** + * Retrieves the security state for the provided user. + * Note: This state is not yet validated. To get a validated state, the SDK crypto client + * must be used. This security state is validated on initialization of the SDK. + */ + abstract accountSecurityState$(userId: UserId): Observable; + /** + * Sets the security state for the provided user. + */ + abstract setAccountSecurityState( + accountSecurityState: SignedSecurityState, + userId: UserId, + ): Promise; +} diff --git a/libs/common/src/key-management/security-state/request/security-state.request.ts b/libs/common/src/key-management/security-state/request/security-state.request.ts new file mode 100644 index 00000000000..7c825bedf84 --- /dev/null +++ b/libs/common/src/key-management/security-state/request/security-state.request.ts @@ -0,0 +1,8 @@ +import { SignedSecurityState } from "../../types"; + +export class SecurityStateRequest { + constructor( + readonly securityState: SignedSecurityState, + readonly securityVersion: number, + ) {} +} diff --git a/libs/common/src/key-management/security-state/response/security-state.response.ts b/libs/common/src/key-management/security-state/response/security-state.response.ts new file mode 100644 index 00000000000..0590da31913 --- /dev/null +++ b/libs/common/src/key-management/security-state/response/security-state.response.ts @@ -0,0 +1,16 @@ +import { SignedSecurityState } from "../../types"; + +export class SecurityStateResponse { + readonly securityState: SignedSecurityState | null = null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("securityState" in response) || !(typeof response.securityState === "string")) { + throw new TypeError("Response must contain a valid securityState"); + } + this.securityState = response.securityState as SignedSecurityState; + } +} diff --git a/libs/common/src/key-management/security-state/services/security-state.service.ts b/libs/common/src/key-management/security-state/services/security-state.service.ts new file mode 100644 index 00000000000..789d5171072 --- /dev/null +++ b/libs/common/src/key-management/security-state/services/security-state.service.ts @@ -0,0 +1,26 @@ +import { Observable } from "rxjs"; + +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { SignedSecurityState } from "../../types"; +import { SecurityStateService } from "../abstractions/security-state.service"; +import { ACCOUNT_SECURITY_STATE } from "../state/security-state.state"; + +export class DefaultSecurityStateService implements SecurityStateService { + constructor(protected stateProvider: StateProvider) {} + + // Emits the provided user's security state, or null if there is no security state present for the user. + accountSecurityState$(userId: UserId): Observable { + return this.stateProvider.getUserState$(ACCOUNT_SECURITY_STATE, userId); + } + + // Sets the security state for the provided user. + // This is not yet validated, and is only validated upon SDK initialization. + async setAccountSecurityState( + accountSecurityState: SignedSecurityState, + userId: UserId, + ): Promise { + await this.stateProvider.setUserState(ACCOUNT_SECURITY_STATE, accountSecurityState, userId); + } +} diff --git a/libs/common/src/key-management/security-state/state/security-state.state.ts b/libs/common/src/key-management/security-state/state/security-state.state.ts new file mode 100644 index 00000000000..e471ef17d76 --- /dev/null +++ b/libs/common/src/key-management/security-state/state/security-state.state.ts @@ -0,0 +1,12 @@ +import { CRYPTO_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { SignedSecurityState } from "../../types"; + +export const ACCOUNT_SECURITY_STATE = new UserKeyDefinition( + CRYPTO_DISK, + "accountSecurityState", + { + deserializer: (obj) => obj, + clearOn: ["logout"], + }, +); diff --git a/libs/common/src/key-management/types.ts b/libs/common/src/key-management/types.ts new file mode 100644 index 00000000000..df64a3ed342 --- /dev/null +++ b/libs/common/src/key-management/types.ts @@ -0,0 +1,30 @@ +import { Opaque } from "type-fest"; + +import { EncString, SignedSecurityState as SdkSignedSecurityState } from "@bitwarden/sdk-internal"; + +/** + * A private key, encrypted with a symmetric key. + */ +export type WrappedPrivateKey = Opaque; + +/** + * A public key, signed with the accounts signature key. + */ +export type SignedPublicKey = Opaque; +/** + * A public key in base64 encoded SPKI-DER + */ +export type UnsignedPublicKey = Opaque; + +/** + * A signature key encrypted with a symmetric key. + */ +export type WrappedSigningKey = Opaque; +/** + * A signature public key (verifying key) in base64 encoded CoseKey format + */ +export type VerifyingKey = Opaque; +/** + * A signed security state, encoded in base64. + */ +export type SignedSecurityState = Opaque; diff --git a/libs/common/src/models/response/profile.response.ts b/libs/common/src/models/response/profile.response.ts index a6982d7ed43..11aca73d5c9 100644 --- a/libs/common/src/models/response/profile.response.ts +++ b/libs/common/src/models/response/profile.response.ts @@ -1,3 +1,5 @@ +import { PrivateKeysResponseModel } from "@bitwarden/common/key-management/keys/response/private-keys.response"; + import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; @@ -18,7 +20,10 @@ export class ProfileResponse extends BaseResponse { key?: EncString; avatarColor: string; creationDate: string; + // Cleanup: Can be removed after moving to accountKeys privateKey: string; + // Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768 + accountKeys: PrivateKeysResponseModel | null = null; securityStamp: string; forcePasswordReset: boolean; usesKeyConnector: boolean; @@ -37,10 +42,16 @@ export class ProfileResponse extends BaseResponse { this.premiumFromOrganization = this.getResponseProperty("PremiumFromOrganization"); this.culture = this.getResponseProperty("Culture"); this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); + const key = this.getResponseProperty("Key"); if (key) { this.key = new EncString(key); } + // Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768 + if (this.getResponseProperty("AccountKeys") != null) { + this.accountKeys = new PrivateKeysResponseModel(this.getResponseProperty("AccountKeys")); + } + this.avatarColor = this.getResponseProperty("AvatarColor"); this.creationDate = this.getResponseProperty("CreationDate"); this.privateKey = this.getResponseProperty("PrivateKey"); diff --git a/libs/common/src/platform/services/key-state/user-key.state.ts b/libs/common/src/platform/services/key-state/user-key.state.ts index 1b88554e53b..2416c211d6b 100644 --- a/libs/common/src/platform/services/key-state/user-key.state.ts +++ b/libs/common/src/platform/services/key-state/user-key.state.ts @@ -1,4 +1,5 @@ import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; +import { WrappedSigningKey } from "../../../key-management/types"; import { UserKey } from "../../../types/key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { CRYPTO_DISK, CRYPTO_MEMORY, UserKeyDefinition } from "../../state"; @@ -25,3 +26,12 @@ export const USER_KEY = new UserKeyDefinition(CRYPTO_MEMORY, "userKey", deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey, clearOn: ["logout", "lock"], }); + +export const USER_KEY_ENCRYPTED_SIGNING_KEY = new UserKeyDefinition( + CRYPTO_DISK, + "userSigningKey", + { + deserializer: (obj) => obj, + clearOn: ["logout"], + }, +); diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index 7165e845885..4aee0d48e5a 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -1,7 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, of } from "rxjs"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -18,6 +18,7 @@ import { AccountInfo } from "../../../auth/abstractions/account.service"; import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; +import { ConfigService } from "../../abstractions/config/config.service"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; import { PlatformUtilsService } from "../../abstractions/platform-utils.service"; import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory"; @@ -43,6 +44,7 @@ describe("DefaultSdkService", () => { let platformUtilsService!: MockProxy; let kdfConfigService!: MockProxy; let keyService!: MockProxy; + let securityStateService!: MockProxy; let configService!: MockProxy; let service!: DefaultSdkService; let accountService!: FakeAccountService; @@ -57,6 +59,7 @@ describe("DefaultSdkService", () => { platformUtilsService = mock(); kdfConfigService = mock(); keyService = mock(); + securityStateService = mock(); apiService = mock(); const mockUserId = Utils.newGuid() as UserId; accountService = mockAccountServiceWith(mockUserId); @@ -75,6 +78,7 @@ describe("DefaultSdkService", () => { accountService, kdfConfigService, keyService, + securityStateService, apiService, fakeStateProvider, configService, @@ -100,6 +104,8 @@ describe("DefaultSdkService", () => { .calledWith(userId) .mockReturnValue(of("private-key" as EncryptedString)); keyService.encryptedOrgKeys$.calledWith(userId).mockReturnValue(of({})); + keyService.userSigningKey$.calledWith(userId).mockReturnValue(of(null)); + securityStateService.accountSecurityState$.calledWith(userId).mockReturnValue(of(null)); }); describe("given no client override has been set for the user", () => { diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index ec57783e02f..6f9c9df761c 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -31,6 +31,8 @@ import { ApiService } from "../../../abstractions/api.service"; import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { DeviceType } from "../../../enums/device-type.enum"; import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; +import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service"; +import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types"; import { OrganizationId, UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; @@ -98,6 +100,7 @@ export class DefaultSdkService implements SdkService { private accountService: AccountService, private kdfConfigService: KdfConfigService, private keyService: KeyService, + private securityStateService: SecurityStateService, private apiService: ApiService, private stateProvider: StateProvider, private configService: ConfigService, @@ -160,10 +163,14 @@ export class DefaultSdkService implements SdkService { const privateKey$ = this.keyService .userEncryptedPrivateKey$(userId) .pipe(distinctUntilChanged()); + const signingKey$ = this.keyService.userSigningKey$(userId).pipe(distinctUntilChanged()); const userKey$ = this.keyService.userKey$(userId).pipe(distinctUntilChanged()); const orgKeys$ = this.keyService.encryptedOrgKeys$(userId).pipe( distinctUntilChanged(compareValues), // The upstream observable emits different objects with the same values ); + const securityState$ = this.securityStateService + .accountSecurityState$(userId) + .pipe(distinctUntilChanged(compareValues)); const client$ = combineLatest([ this.environmentService.getEnvironment$(userId), @@ -171,51 +178,57 @@ export class DefaultSdkService implements SdkService { kdfParams$, privateKey$, userKey$, + signingKey$, orgKeys$, + securityState$, SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded ]).pipe( // switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value. - switchMap(([env, account, kdfParams, privateKey, userKey, orgKeys]) => { - // Create our own observable to be able to implement clean-up logic - return new Observable>((subscriber) => { - const createAndInitializeClient = async () => { - if (env == null || kdfParams == null || privateKey == null || userKey == null) { - return undefined; - } + switchMap( + ([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => { + // Create our own observable to be able to implement clean-up logic + return new Observable>((subscriber) => { + const createAndInitializeClient = async () => { + if (env == null || kdfParams == null || privateKey == null || userKey == null) { + return undefined; + } - const settings = this.toSettings(env); - const client = await this.sdkClientFactory.createSdkClient( - new JsTokenProvider(this.apiService, userId), - settings, - ); + const settings = this.toSettings(env); + const client = await this.sdkClientFactory.createSdkClient( + new JsTokenProvider(this.apiService, userId), + settings, + ); - await this.initializeClient( - userId, - client, - account, - kdfParams, - privateKey, - userKey, - orgKeys, - ); + await this.initializeClient( + userId, + client, + account, + kdfParams, + privateKey, + userKey, + signingKey, + securityState, + orgKeys, + ); - return client; - }; + return client; + }; - let client: Rc | undefined; - createAndInitializeClient() - .then((c) => { - client = c === undefined ? undefined : new Rc(c); + let client: Rc | undefined; + createAndInitializeClient() + .then((c) => { + client = c === undefined ? undefined : new Rc(c); - subscriber.next(client); - }) - .catch((e) => { - subscriber.error(e); - }); + subscriber.next(client); + }) + .catch((e) => { + subscriber.error(e); + }); - return () => client?.markForDisposal(); - }); - }), + return () => client?.markForDisposal(); + }); + }, + ), tap({ finalize: () => this.sdkClientCache.delete(userId) }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -231,6 +244,8 @@ export class DefaultSdkService implements SdkService { kdfParams: KdfConfig, privateKey: EncryptedString, userKey: UserKey, + signingKey: WrappedSigningKey | null, + securityState: SignedSecurityState | null, orgKeys: Record, ) { await client.crypto().initialize_user_crypto({ @@ -248,8 +263,8 @@ export class DefaultSdkService implements SdkService { }, }, privateKey, - signingKey: undefined, - securityState: undefined, + signingKey: signingKey || undefined, + securityState: securityState || undefined, }); // We initialize the org crypto even if the org_keys are diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index 193a5a2d2dd..f60b42ce450 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -11,6 +11,8 @@ import { UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -72,6 +74,7 @@ describe("DefaultSyncService", () => { let tokenService: MockProxy; let authService: MockProxy; let stateProvider: MockProxy; + let securityStateService: MockProxy; let sut: DefaultSyncService; @@ -101,6 +104,7 @@ describe("DefaultSyncService", () => { tokenService = mock(); authService = mock(); stateProvider = mock(); + securityStateService = mock(); sut = new DefaultSyncService( masterPasswordAbstraction, @@ -127,6 +131,7 @@ describe("DefaultSyncService", () => { tokenService, authService, stateProvider, + securityStateService, ); }); @@ -155,6 +160,142 @@ describe("DefaultSyncService", () => { stateProvider.getUser.mockReturnValue(mock()); }); + it("sets the correct keys for a V1 user with old response model", async () => { + const v1Profile = { + id: user1, + key: "encryptedUserKey", + privateKey: "privateKey", + providers: [] as any[], + organizations: [] as any[], + providerOrganizations: [] as any[], + avatarColor: "#fff", + securityStamp: "stamp", + emailVerified: true, + verifyDevices: false, + premiumPersonally: false, + premiumFromOrganization: false, + usesKeyConnector: false, + }; + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: v1Profile, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + await sut.fullSync(true); + expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString("encryptedUserKey"), + user1, + ); + expect(keyService.setPrivateKey).toHaveBeenCalledWith("privateKey", user1); + expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); + expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); + }); + + it("sets the correct keys for a V1 user", async () => { + const v1Profile = { + id: user1, + key: "encryptedUserKey", + privateKey: "privateKey", + providers: [] as any[], + organizations: [] as any[], + providerOrganizations: [] as any[], + avatarColor: "#fff", + securityStamp: "stamp", + emailVerified: true, + verifyDevices: false, + premiumPersonally: false, + premiumFromOrganization: false, + usesKeyConnector: false, + accountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "wrappedPrivateKey", + publicKey: "publicKey", + }, + }, + }; + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: v1Profile, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + await sut.fullSync(true); + expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString("encryptedUserKey"), + user1, + ); + expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); + expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); + expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); + }); + + it("sets the correct keys for a V2 user", async () => { + const v2Profile = { + id: user1, + key: "encryptedUserKey", + providers: [] as unknown[], + organizations: [] as unknown[], + providerOrganizations: [] as unknown[], + avatarColor: "#fff", + securityStamp: "stamp", + emailVerified: true, + verifyDevices: false, + premiumPersonally: false, + premiumFromOrganization: false, + usesKeyConnector: false, + privateKey: "wrappedPrivateKey", + accountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "wrappedPrivateKey", + publicKey: "publicKey", + signedPublicKey: "signedPublicKey", + }, + signatureKeyPair: { + wrappedSigningKey: "wrappedSigningKey", + verifyingKey: "verifyingKey", + }, + securityState: { + securityState: "securityState", + }, + }, + }; + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: v2Profile, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + await sut.fullSync(true); + expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString("encryptedUserKey"), + user1, + ); + expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); + expect(keyService.setUserSigningKey).toHaveBeenCalledWith("wrappedSigningKey", user1); + expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith( + "securityState", + user1, + ); + expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); + expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); + }); + it("does a token refresh when option missing from options", async () => { await sut.fullSync(true, { allowThrowOnError: false }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index a02d602dbf0..d5fa2d0ae68 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -10,6 +10,7 @@ import { CollectionService, } from "@bitwarden/admin-console/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -98,6 +99,7 @@ export class DefaultSyncService extends CoreSyncService { tokenService: TokenService, authService: AuthService, stateProvider: StateProvider, + private securityStateService: SecurityStateService, ) { super( tokenService, @@ -233,13 +235,34 @@ export class DefaultSyncService extends CoreSyncService { if (response?.key) { await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, response.id); } - await this.keyService.setPrivateKey(response.privateKey, response.id); + + // Cleanup: Only the first branch should be kept after the server always returns accountKeys https://bitwarden.atlassian.net/browse/PM-21768 + if (response.accountKeys != null) { + await this.keyService.setPrivateKey( + response.accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey, + response.id, + ); + if (response.accountKeys.signatureKeyPair !== null) { + // User is V2 user + await this.keyService.setUserSigningKey( + response.accountKeys.signatureKeyPair.wrappedSigningKey, + response.id, + ); + await this.securityStateService.setAccountSecurityState( + response.accountKeys.securityState.securityState, + response.id, + ); + } + } else { + await this.keyService.setPrivateKey(response.privateKey, response.id); + } await this.keyService.setProviderKeys(response.providers, response.id); await this.keyService.setOrgKeys( response.organizations, response.providerOrganizations, response.id, ); + await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor); await this.tokenService.setSecurityStamp(response.securityStamp, response.id); await this.accountService.setAccountEmailVerified(response.id, response.emailVerified); diff --git a/libs/common/src/types/key.ts b/libs/common/src/types/key.ts index 8984452e701..ca56deb2fb1 100644 --- a/libs/common/src/types/key.ts +++ b/libs/common/src/types/key.ts @@ -1,5 +1,6 @@ import { Opaque } from "type-fest"; +import { UnsignedPublicKey } from "../key-management/types"; import { SymmetricCryptoKey } from "../platform/models/domain/symmetric-crypto-key"; // symmetric keys @@ -15,4 +16,4 @@ export type CipherKey = Opaque; // asymmetric keys export type UserPrivateKey = Opaque; -export type UserPublicKey = Opaque; +export type UserPublicKey = Opaque; diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index c6c751bf25c..e4bb83cb2fd 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -7,6 +7,7 @@ import { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -236,8 +237,10 @@ export abstract class KeyService { */ abstract getOrgKey(orgId: string): Promise; /** - * Uses the org key to derive a new symmetric key for encrypting data - * @param key The organization's symmetric key + * Makes a fresh attachment content encryption key and returns it along with a wrapped (encrypted) version of it. + * @deprecated Do not use this for new code / new cryptographic designs. + * @param key The organization's symmetric key or the user's user key to wrap the attachment key with + * @returns The new attachment content encryption key and the wrapped version of it */ abstract makeDataEncKey( key: T, @@ -272,6 +275,14 @@ export abstract class KeyService { * @param encPrivateKey An encrypted private key */ abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise; + /** + * Sets the user's encrypted signing key in storage + * In contrast to the private key, the decrypted signing key + * is not stored in memory outside of the SDK. + * @param encryptedSigningKey An encrypted signing key + * @param userId The user id of the user to set the signing key for + */ + abstract setUserSigningKey(encryptedSigningKey: WrappedSigningKey, userId: UserId): Promise; /** * Gets an observable stream of the given users decrypted private key, will emit null if the user @@ -416,7 +427,13 @@ export abstract class KeyService { * * @throws If an invalid user id is passed in. */ - abstract userPublicKey$(userId: UserId): Observable; + abstract userPublicKey$(userId: UserId): Observable; + + /** + * Gets a users signing keys from local state. + * The observable will emit null, exactly if the local state returns null. + */ + abstract userSigningKey$(userId: UserId): Observable; /** * Validates that a userkey is correct for a given user diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 52fecf26c71..46d1125711b 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -11,6 +11,7 @@ import { } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { UnsignedPublicKey, WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -25,6 +26,7 @@ import { USER_ENCRYPTED_PRIVATE_KEY, USER_EVER_HAD_USER_KEY, USER_KEY, + USER_KEY_ENCRYPTED_SIGNING_KEY, } from "@bitwarden/common/platform/services/key-state/user-key.state"; import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { @@ -432,6 +434,7 @@ describe("keyService", () => { USER_ENCRYPTED_ORGANIZATION_KEYS, USER_ENCRYPTED_PROVIDER_KEYS, USER_ENCRYPTED_PRIVATE_KEY, + USER_KEY_ENCRYPTED_SIGNING_KEY, USER_KEY, ])("key removal", (key: UserKeyDefinition) => { it(`clears ${key.key} for the specified user when specified`, async () => { @@ -540,6 +543,51 @@ describe("keyService", () => { }); }); + describe("userSigningKey$", () => { + it("returns the signing key when the user has a signing key set", async () => { + const fakeSigningKey = "" as WrappedSigningKey; + const fakeSigningKeyState = stateProvider.singleUser.getFake( + mockUserId, + USER_KEY_ENCRYPTED_SIGNING_KEY, + ); + fakeSigningKeyState.nextState(fakeSigningKey); + + const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId)); + + expect(signingKey).toEqual(fakeSigningKey); + }); + + it("returns null when the user does not have a signing key set", async () => { + const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId)); + + expect(signingKey).toBeFalsy(); + }); + }); + + describe("setUserSigningKey", () => { + it("throws if the signing key is null", async () => { + await expect(keyService.setUserSigningKey(null as any, mockUserId)).rejects.toThrow( + "No user signing key provided.", + ); + }); + it("throws if the userId is null", async () => { + await expect( + keyService.setUserSigningKey("" as WrappedSigningKey, null as unknown as UserId), + ).rejects.toThrow("No userId provided."); + }); + it("sets the signing key for the user", async () => { + const fakeSigningKey = "" as WrappedSigningKey; + const fakeSigningKeyState = stateProvider.singleUser.getFake( + mockUserId, + USER_KEY_ENCRYPTED_SIGNING_KEY, + ); + fakeSigningKeyState.nextState(null); + await keyService.setUserSigningKey(fakeSigningKey, mockUserId); + expect(fakeSigningKeyState.nextMock).toHaveBeenCalledTimes(1); + expect(fakeSigningKeyState.nextMock).toHaveBeenCalledWith(fakeSigningKey); + }); + }); + describe("cipherDecryptionKeys$", () => { function fakePrivateKeyDecryption(encryptedPrivateKey: EncString, key: SymmetricCryptoKey) { const output = new Uint8Array(64); @@ -1132,12 +1180,12 @@ describe("keyService", () => { keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject("private key")); cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( - Utils.fromUtf8ToArray("public key"), + Utils.fromUtf8ToArray("public key") as UnsignedPublicKey, ); const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId)); expect(key).toEqual({ privateKey: "private key", - publicKey: Utils.fromUtf8ToArray("public key"), + publicKey: Utils.fromUtf8ToArray("public key") as UnsignedPublicKey, }); }); }); diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index cdf7d594f28..a13c74e96d2 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -28,6 +28,7 @@ import { } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -44,6 +45,7 @@ import { USER_ENCRYPTED_PRIVATE_KEY, USER_EVER_HAD_USER_KEY, USER_KEY, + USER_KEY_ENCRYPTED_SIGNING_KEY, } from "@bitwarden/common/platform/services/key-state/user-key.state"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CsprngArray } from "@bitwarden/common/types/csprng"; @@ -398,8 +400,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { throw new Error("No key provided"); } - const newSymKey = await this.keyGenerationService.createKey(512); - return this.buildProtectedSymmetricKey(key, newSymKey); + // Content encryption key is AES256_CBC_HMAC + const cek = await this.keyGenerationService.createKey(512); + const wrappedCek = await this.encryptService.wrapSymmetricKey(cek, key); + return [cek, wrappedCek]; } private async clearOrgKeys(userId: UserId): Promise { @@ -505,6 +509,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId); } + private async clearSigningKey(userId: UserId): Promise { + await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, null, userId); + } + async clearPinKeys(userId: UserId): Promise { if (userId == null) { throw new Error("UserId is required"); @@ -537,6 +545,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { await this.clearOrgKeys(userId); await this.clearProviderKeys(userId); await this.clearKeyPair(userId); + await this.clearSigningKey(userId); await this.clearPinKeys(userId); await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId); } @@ -758,6 +767,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { return phrase; } + /** + * @deprecated + * This should only be used for wrapping the user key with a master key or stretched master key. + */ private async buildProtectedSymmetricKey( encryptionKey: SymmetricCryptoKey, newSymKey: SymmetricCryptoKey, @@ -792,7 +805,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { return null; } - return (await this.cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey; + return await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); } userPrivateKey$(userId: UserId): Observable { @@ -808,7 +821,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { return null; } - const publicKey = (await this.derivePublicKey(privateKey))!; + const publicKey = (await this.derivePublicKey(privateKey))! as UserPublicKey; return { privateKey, publicKey }; }), ); @@ -905,6 +918,27 @@ export class DefaultKeyService implements KeyServiceAbstraction { ); } + async setUserSigningKey(userSigningKey: WrappedSigningKey, userId: UserId): Promise { + if (userSigningKey == null) { + throw new Error("No user signing key provided."); + } + if (userId == null) { + throw new Error("No userId provided."); + } + await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, userSigningKey, userId); + } + + userSigningKey$(userId: UserId): Observable { + return this.stateProvider.getUser(userId, USER_KEY_ENCRYPTED_SIGNING_KEY).state$.pipe( + map((encryptedSigningKey) => { + if (encryptedSigningKey == null) { + return null; + } + return encryptedSigningKey as WrappedSigningKey; + }), + ); + } + orgKeys$(userId: UserId): Observable | null> { return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys ?? null)); } diff --git a/libs/node/src/services/node-crypto-function.service.ts b/libs/node/src/services/node-crypto-function.service.ts index f3ac71b13e7..22cc5756f30 100644 --- a/libs/node/src/services/node-crypto-function.service.ts +++ b/libs/node/src/services/node-crypto-function.service.ts @@ -3,6 +3,7 @@ import * as crypto from "crypto"; import * as forge from "node-forge"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { UnsignedPublicKey } from "@bitwarden/common/key-management/types"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { @@ -232,7 +233,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { return Promise.resolve(this.toUint8Buffer(decipher)); } - rsaExtractPublicKey(privateKey: Uint8Array): Promise { + async rsaExtractPublicKey(privateKey: Uint8Array): Promise { const privateKeyByteString = Utils.fromBufferToByteString(privateKey); const privateKeyAsn1 = forge.asn1.fromDer(privateKeyByteString); const forgePrivateKey: any = forge.pki.privateKeyFromAsn1(privateKeyAsn1); @@ -240,11 +241,11 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { const publicKeyAsn1 = forge.pki.publicKeyToAsn1(forgePublicKey); const publicKeyByteString = forge.asn1.toDer(publicKeyAsn1).data; const publicKeyArray = Utils.fromByteStringToArray(publicKeyByteString); - return Promise.resolve(publicKeyArray); + return publicKeyArray as UnsignedPublicKey; } - async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> { - return new Promise<[Uint8Array, Uint8Array]>((resolve, reject) => { + async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[UnsignedPublicKey, Uint8Array]> { + return new Promise<[UnsignedPublicKey, Uint8Array]>((resolve, reject) => { forge.pki.rsa.generateKeyPair( { bits: length, @@ -266,7 +267,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { const privateKeyByteString = forge.asn1.toDer(privateKeyPkcs8).getBytes(); const privateKey = Utils.fromByteStringToArray(privateKeyByteString); - resolve([publicKey, privateKey]); + resolve([publicKey as UnsignedPublicKey, privateKey]); }, ); }); From 601c1cc0d415aca92ad82d86bdb0f2639a00e58c Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Sat, 11 Oct 2025 09:22:11 +0200 Subject: [PATCH 06/37] PM-26727 Bring back references section (#16826) --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9739288aac8..dd3b6445edd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,3 +96,13 @@ enum CipherType { ``` Example: `/libs/common/src/vault/enums/cipher-type.ts` + +## References + +- [Web Clients Architecture](https://contributing.bitwarden.com/architecture/clients) +- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/) +- [Contributing Guide](https://contributing.bitwarden.com/) +- [Web Clients Setup Guide](https://contributing.bitwarden.com/getting-started/clients/) +- [Code Style](https://contributing.bitwarden.com/contributing/code-style/) +- [Security Whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/) +- [Security Definitions](https://contributing.bitwarden.com/architecture/security/definitions) From 14e7ee4818f1fc3d80b40c3576f3a7d7cad18042 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 13 Oct 2025 06:31:17 -0400 Subject: [PATCH 07/37] build(web): integrate nx (#16706) --- apps/web/postcss.config.js | 8 +- apps/web/project.json | 215 ++++++++++++++++++++ apps/web/tailwind.config.js | 19 +- apps/web/webpack.base.js | 117 +++++++---- apps/web/webpack.config.js | 37 +++- bitwarden_license/bit-web/webpack.config.js | 39 +++- 6 files changed, 368 insertions(+), 67 deletions(-) create mode 100644 apps/web/project.json diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js index 5657df3afcf..0403fe4061e 100644 --- a/apps/web/postcss.config.js +++ b/apps/web/postcss.config.js @@ -1,9 +1,13 @@ /* eslint-disable @typescript-eslint/no-require-imports */ +const path = require("path"); + module.exports = { plugins: [ - require("postcss-import"), + require("postcss-import")({ + path: [path.resolve(__dirname, "../../libs"), path.resolve(__dirname, "src/scss")], + }), require("postcss-nested"), - require("tailwindcss"), + require("tailwindcss")({ config: path.resolve(__dirname, "tailwind.config.js") }), require("autoprefixer"), ], }; diff --git a/apps/web/project.json b/apps/web/project.json new file mode 100644 index 00000000000..4f51bf22740 --- /dev/null +++ b/apps/web/project.json @@ -0,0 +1,215 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "web", + "projectType": "application", + "sourceRoot": "apps/web/src", + "tags": ["scope:web", "type:app"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "oss", + "options": { + "outputPath": "dist/apps/web", + "webpackConfig": "apps/web/webpack.config.js", + "tsConfig": "apps/web/tsconfig.json", + "main": "apps/web/src/main.ts", + "target": "web", + "compiler": "tsc" + }, + "configurations": { + "oss": { + "mode": "production", + "outputPath": "dist/apps/web/oss" + }, + "oss-dev": { + "mode": "development", + "outputPath": "dist/apps/web/oss-dev", + "env": { + "NODE_ENV": "development", + "ENV": "development" + } + }, + "commercial": { + "mode": "production", + "outputPath": "dist/apps/web/commercial", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts" + }, + "commercial-dev": { + "mode": "development", + "outputPath": "dist/apps/web/commercial-dev", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "development", + "ENV": "development" + } + }, + "commercial-qa": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-qa", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "qa" + } + }, + "commercial-cloud": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-cloud", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "cloud" + } + }, + "commercial-euprd": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-euprd", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "euprd" + } + }, + "commercial-euqa": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-euqa", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "euqa" + } + }, + "commercial-usdev": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-usdev", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "usdev" + } + }, + "commercial-ee": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-ee", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "ee" + } + }, + "oss-selfhost": { + "mode": "production", + "outputPath": "dist/apps/web/oss-selfhost", + "env": { + "ENV": "selfhosted", + "NODE_ENV": "production" + } + }, + "oss-selfhost-dev": { + "mode": "development", + "outputPath": "dist/apps/web/oss-selfhost-dev", + "env": { + "NODE_ENV": "development", + "ENV": "selfhosted" + } + }, + "commercial-selfhost": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-selfhost", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "ENV": "selfhosted", + "NODE_ENV": "production" + } + }, + "commercial-selfhost-dev": { + "mode": "development", + "outputPath": "dist/apps/web/commercial-selfhost-dev", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "development", + "ENV": "selfhosted" + } + } + } + }, + "serve": { + "executor": "@nx/webpack:dev-server", + "defaultConfiguration": "oss-dev", + "options": { + "buildTarget": "web:build", + "host": "localhost", + "port": 8080 + }, + "configurations": { + "oss": { + "buildTarget": "web:build:oss" + }, + "oss-dev": { + "buildTarget": "web:build:oss-dev" + }, + "commercial": { + "buildTarget": "web:build:commercial" + }, + "commercial-dev": { + "buildTarget": "web:build:commercial-dev" + }, + "commercial-qa": { + "buildTarget": "web:build:commercial-qa" + }, + "commercial-cloud": { + "buildTarget": "web:build:commercial-cloud" + }, + "commercial-euprd": { + "buildTarget": "web:build:commercial-euprd" + }, + "commercial-euqa": { + "buildTarget": "web:build:commercial-euqa" + }, + "commercial-usdev": { + "buildTarget": "web:build:commercial-usdev" + }, + "commercial-ee": { + "buildTarget": "web:build:commercial-ee" + }, + "oss-selfhost": { + "buildTarget": "web:build:oss-selfhost" + }, + "oss-selfhost-dev": { + "buildTarget": "web:build:oss-selfhost-dev" + }, + "commercial-selfhost": { + "buildTarget": "web:build:commercial-selfhost" + }, + "commercial-selfhost-dev": { + "buildTarget": "web:build:commercial-selfhost-dev" + } + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/web/jest.config.js" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/web/**/*.ts", "apps/web/**/*.html"] + } + } + } +} diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 58812f4c6b7..865d681e91d 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -1,16 +1,17 @@ /* eslint-disable no-undef, @typescript-eslint/no-var-requires */ +const path = require("path"); const config = require("../../libs/components/tailwind.config.base"); config.content = [ - "./src/**/*.{html,ts}", - "../../libs/components/src/**/*.{html,ts}", - "../../libs/assets/src/**/*.{html,ts}", - "../../libs/auth/src/**/*.{html,ts}", - "../../libs/key-management-ui/src/**/*.{html,ts}", - "../../libs/vault/src/**/*.{html,ts}", - "../../libs/angular/src/**/*.{html,ts}", - "../../libs/tools/generator/components/src/**/*.{html,ts}", - "../../bitwarden_license/bit-web/src/**/*.{html,ts}", + path.resolve(__dirname, "./src/**/*.{html,ts}"), + path.resolve(__dirname, "../../libs/components/src/**/*.{html,ts}"), + path.resolve(__dirname, "../../libs/assets/src/**/*.{html,ts}"), + path.resolve(__dirname, "../../libs/auth/src/**/*.{html,ts}"), + path.resolve(__dirname, "../../libs/key-management-ui/src/**/*.{html,ts}"), + path.resolve(__dirname, "../../libs/vault/src/**/*.{html,ts}"), + path.resolve(__dirname, "../../libs/angular/src/**/*.{html,ts}"), + path.resolve(__dirname, "../../libs/tools/generator/components/src/**/*.{html,ts}"), + path.resolve(__dirname, "../../bitwarden_license/bit-web/src/**/*.{html,ts}"), ]; config.corePlugins.preflight = true; diff --git a/apps/web/webpack.base.js b/apps/web/webpack.base.js index 2bfe0e27553..7930a55f61a 100644 --- a/apps/web/webpack.base.js +++ b/apps/web/webpack.base.js @@ -9,17 +9,21 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const TerserPlugin = require("terser-webpack-plugin"); const webpack = require("webpack"); -const config = require("./config.js"); -const pjson = require("./package.json"); +const config = require(path.resolve(__dirname, "config.js")); +const pjson = require(path.resolve(__dirname, "package.json")); -module.exports.getEnv = function getEnv() { - const ENV = process.env.ENV == null ? "development" : process.env.ENV; +module.exports.getEnv = function getEnv(params) { + const ENV = params.env || (process.env.ENV == null ? "development" : process.env.ENV); const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; const LOGGING = process.env.LOGGING != "false"; return { ENV, NODE_ENV, LOGGING }; }; +const DEFAULT_PARAMS = { + outputPath: path.resolve(__dirname, "build"), +}; + /** * * @param {{ @@ -29,10 +33,14 @@ module.exports.getEnv = function getEnv() { * entryModule: string; * }; * tsConfig: string; + * outputPath?: string; + * mode?: string; + * env?: string; * }} params */ module.exports.buildConfig = function buildConfig(params) { - const { ENV, NODE_ENV, LOGGING } = module.exports.getEnv(); + params = { ...DEFAULT_PARAMS, ...params }; + const { ENV, NODE_ENV, LOGGING } = module.exports.getEnv(params); const envConfig = config.load(ENV); if (LOGGING) { @@ -89,6 +97,9 @@ module.exports.buildConfig = function buildConfig(params) { loader: "postcss-loader", options: { sourceMap: true, + postcssOptions: { + config: path.resolve(__dirname, "postcss.config.js"), + }, }, }, ], @@ -99,7 +110,7 @@ module.exports.buildConfig = function buildConfig(params) { { loader: "babel-loader", options: { - configFile: "../../babel.config.json", + configFile: path.resolve(__dirname, "../../babel.config.json"), cacheDirectory: NODE_ENV !== "production", }, }, @@ -113,43 +124,43 @@ module.exports.buildConfig = function buildConfig(params) { const plugins = [ new HtmlWebpackPlugin({ - template: "./src/index.html", + template: path.resolve(__dirname, "src/index.html"), filename: "index.html", chunks: ["theme_head", "app/polyfills", "app/vendor", "app/main", "styles"], }), new HtmlWebpackInjector(), new HtmlWebpackPlugin({ - template: "./src/connectors/webauthn.html", + template: path.resolve(__dirname, "src/connectors/webauthn.html"), filename: "webauthn-connector.html", chunks: ["connectors/webauthn", "styles"], }), new HtmlWebpackPlugin({ - template: "./src/connectors/webauthn-mobile.html", + template: path.resolve(__dirname, "src/connectors/webauthn-mobile.html"), filename: "webauthn-mobile-connector.html", chunks: ["connectors/webauthn", "styles"], }), new HtmlWebpackPlugin({ - template: "./src/connectors/webauthn-fallback.html", + template: path.resolve(__dirname, "src/connectors/webauthn-fallback.html"), filename: "webauthn-fallback-connector.html", chunks: ["connectors/webauthn-fallback", "styles"], }), new HtmlWebpackPlugin({ - template: "./src/connectors/sso.html", + template: path.resolve(__dirname, "src/connectors/sso.html"), filename: "sso-connector.html", chunks: ["connectors/sso", "styles"], }), new HtmlWebpackPlugin({ - template: "./src/connectors/redirect.html", + template: path.resolve(__dirname, "src/connectors/redirect.html"), filename: "redirect-connector.html", chunks: ["connectors/redirect", "styles"], }), new HtmlWebpackPlugin({ - template: "./src/connectors/duo-redirect.html", + template: path.resolve(__dirname, "src/connectors/duo-redirect.html"), filename: "duo-redirect-connector.html", chunks: ["connectors/duo-redirect", "styles"], }), new HtmlWebpackPlugin({ - template: "./src/404.html", + template: path.resolve(__dirname, "src/404.html"), filename: "404.html", chunks: ["styles"], // 404 page is a wildcard, this ensures it uses absolute paths. @@ -157,18 +168,28 @@ module.exports.buildConfig = function buildConfig(params) { }), new CopyWebpackPlugin({ patterns: [ - { from: "./src/.nojekyll" }, - { from: "./src/manifest.json" }, - { from: "./src/favicon.ico" }, - { from: "./src/browserconfig.xml" }, - { from: "./src/app-id.json" }, - { from: "./src/images", to: "images" }, - { from: "./src/videos", to: "videos" }, - { from: "./src/locales", to: "locales" }, - { from: "../../node_modules/qrious/dist/qrious.min.js", to: "scripts" }, - { from: "../../node_modules/braintree-web-drop-in/dist/browser/dropin.js", to: "scripts" }, + { from: path.resolve(__dirname, "src/.nojekyll") }, + { from: path.resolve(__dirname, "src/manifest.json") }, + { from: path.resolve(__dirname, "src/favicon.ico") }, + { from: path.resolve(__dirname, "src/browserconfig.xml") }, + { from: path.resolve(__dirname, "src/app-id.json") }, + { from: path.resolve(__dirname, "src/images"), to: "images" }, + { from: path.resolve(__dirname, "src/images/icons"), to: "images" }, + { from: path.resolve(__dirname, "src/videos"), to: "videos" }, + { from: path.resolve(__dirname, "src/locales"), to: "locales" }, { - from: "./src/version.json", + from: path.resolve(__dirname, "../../node_modules/qrious/dist/qrious.min.js"), + to: "scripts", + }, + { + from: path.resolve( + __dirname, + "../../node_modules/braintree-web-drop-in/dist/browser/dropin.js", + ), + to: "scripts", + }, + { + from: path.resolve(__dirname, "src/version.json"), transform(content, path) { return content.toString().replace("process.env.APPLICATION_VERSION", pjson.version); }, @@ -203,7 +224,9 @@ module.exports.buildConfig = function buildConfig(params) { ]; // ref: https://webpack.js.org/configuration/dev-server/#devserver - let certSuffix = fs.existsSync("dev-server.local.pem") ? ".local" : ".shared"; + let certSuffix = fs.existsSync(path.resolve(__dirname, "dev-server.local.pem")) + ? ".local" + : ".shared"; const devServer = NODE_ENV !== "development" ? {} @@ -211,8 +234,8 @@ module.exports.buildConfig = function buildConfig(params) { server: { type: "https", options: { - key: fs.readFileSync("dev-server" + certSuffix + ".pem"), - cert: fs.readFileSync("dev-server" + certSuffix + ".pem"), + key: fs.readFileSync(path.resolve(__dirname, "dev-server" + certSuffix + ".pem")), + cert: fs.readFileSync(path.resolve(__dirname, "dev-server" + certSuffix + ".pem")), }, }, // host: '192.168.1.9', @@ -332,6 +355,15 @@ module.exports.buildConfig = function buildConfig(params) { hot: false, port: envConfig.dev?.port ?? 8080, allowedHosts: envConfig.dev?.allowedHosts ?? "auto", + static: { + directory: path.resolve(params.outputPath), + publicPath: "/", + }, + devMiddleware: { + // when running `serve` locally this option writes all built files to `dist` + // files are still served from memory, this is just a helpful debug tool + writeToDisk: true, + }, client: { overlay: { errors: true, @@ -347,15 +379,21 @@ module.exports.buildConfig = function buildConfig(params) { devServer: devServer, target: "web", entry: { - "app/polyfills": "./src/polyfills.ts", + "app/polyfills": path.resolve(__dirname, "src/polyfills.ts"), "app/main": params.app.entry, - "connectors/webauthn": "./src/connectors/webauthn.ts", - "connectors/webauthn-fallback": "./src/connectors/webauthn-fallback.ts", - "connectors/sso": "./src/connectors/sso.ts", - "connectors/duo-redirect": "./src/connectors/duo-redirect.ts", - "connectors/redirect": "./src/connectors/redirect.ts", - styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"], - theme_head: "./src/theme.ts", + "connectors/webauthn": path.resolve(__dirname, "src/connectors/webauthn.ts"), + "connectors/webauthn-fallback": path.resolve( + __dirname, + "src/connectors/webauthn-fallback.ts", + ), + "connectors/sso": path.resolve(__dirname, "src/connectors/sso.ts"), + "connectors/duo-redirect": path.resolve(__dirname, "src/connectors/duo-redirect.ts"), + "connectors/redirect": path.resolve(__dirname, "src/connectors/redirect.ts"), + styles: [ + path.resolve(__dirname, "src/scss/styles.scss"), + path.resolve(__dirname, "src/scss/tailwind.css"), + ], + theme_head: path.resolve(__dirname, "src/theme.ts"), }, cache: NODE_ENV === "production" @@ -402,7 +440,10 @@ module.exports.buildConfig = function buildConfig(params) { resolve: { extensions: [".ts", ".js"], symlinks: false, - modules: [path.resolve("../../node_modules")], + modules: [ + path.resolve(__dirname, "../../node_modules"), + path.resolve(process.cwd(), "node_modules"), + ], fallback: { buffer: false, util: require.resolve("util/"), @@ -415,7 +456,7 @@ module.exports.buildConfig = function buildConfig(params) { }, output: { filename: "[name].[contenthash].js", - path: path.resolve(__dirname, "build"), + path: params.outputPath, clean: true, }, module: { diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index e9d7bd46002..962d72ac825 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -1,10 +1,29 @@ -const { buildConfig } = require("./webpack.base"); +const path = require("path"); +const { buildConfig } = require(path.resolve(__dirname, "webpack.base")); -module.exports = buildConfig({ - configName: "OSS", - app: { - entry: "./src/main.ts", - entryModule: "src/app/app.module#AppModule", - }, - tsConfig: "tsconfig.build.json", -}); +module.exports = (webpackConfig, context) => { + const isNxBuild = context && context.options; + + if (isNxBuild) { + return buildConfig({ + configName: "OSS", + app: { + entry: context.options.main + ? path.resolve(context.context.root, context.options.main) + : path.resolve(__dirname, "src/main.ts"), + entryModule: "src/app/app.module#AppModule", + }, + tsConfig: "apps/web/tsconfig.build.json", + outputPath: path.resolve(context.context.root, context.options.outputPath), + }); + } else { + return buildConfig({ + configName: "OSS", + app: { + entry: path.resolve(__dirname, "src/main.ts"), + entryModule: "src/app/app.module#AppModule", + }, + tsConfig: "tsconfig.build.json", + }); + } +}; diff --git a/bitwarden_license/bit-web/webpack.config.js b/bitwarden_license/bit-web/webpack.config.js index 37e0a0c5e03..6ac1efdc192 100644 --- a/bitwarden_license/bit-web/webpack.config.js +++ b/bitwarden_license/bit-web/webpack.config.js @@ -1,10 +1,31 @@ -const { buildConfig } = require("../../apps/web/webpack.base"); +const path = require("path"); +const { buildConfig } = require(path.resolve(__dirname, "../../apps/web/webpack.base")); -module.exports = buildConfig({ - configName: "Commercial", - app: { - entry: "../../bitwarden_license/bit-web/src/main.ts", - entryModule: "../../bitwarden_license/bit-web/src/app/app.module#AppModule", - }, - tsConfig: "../../bitwarden_license/bit-web/tsconfig.build.json", -}); +module.exports = (webpackConfig, context) => { + const isNxBuild = context && context.options; + if (isNxBuild) { + return buildConfig({ + configName: "Commercial", + app: { + entry: context.options.main + ? path.resolve(context.context.root, context.options.main) + : path.resolve(__dirname, "src/main.ts"), + entryModule: "bitwarden_license/bit-web/src/app/app.module#AppModule", + }, + tsConfig: "bitwarden_license/bit-web/tsconfig.build.json", + outputPath: + context.context && context.context.root + ? path.resolve(context.context.root, context.options.outputPath) + : context.options.outputPath, + }); + } else { + return buildConfig({ + configName: "Commercial", + app: { + entry: path.resolve(__dirname, "src/main.ts"), + entryModule: "bitwarden_license/bit-web/src/app/app.module#AppModule", + }, + tsConfig: path.resolve(__dirname, "tsconfig.build.json"), + }); + } +}; From a7242a1186b6e7e98eb016305d4e7ad461abd6f2 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 13 Oct 2025 15:06:41 +0200 Subject: [PATCH 08/37] [BEEEP|PM-25164] Prevent memory dumping on renderer on Linux (#16136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement libmemory_security * Cleanup and add script * Remove duplicate build for flatpak * Rename to process isolation * Move to desktop native * Undo changes in gitignore * Remove after-pack changes * Run cargo fmt * Sort deps * Attempt to fix windows build * Update apps/desktop/desktop_native/process_isolation/Cargo.toml Co-authored-by: Daniel García * Revert "Remove after-pack changes" This reverts commit c441025587629c2759adf523ffea46d95df4557f. * Fix lib process isolation not being included in build * Fix build * Attempt to fix build * Attempt to fix build * Undo * Fix library not being included --------- Co-authored-by: Daniel García --- apps/desktop/desktop_native/Cargo.lock | 42 ++++++++++++++++- apps/desktop/desktop_native/Cargo.toml | 2 + apps/desktop/desktop_native/build.js | 17 +++++++ .../process_isolation/Cargo.toml | 14 ++++++ .../process_isolation/src/lib.rs | 46 +++++++++++++++++++ .../process_isolation/test_isolation.sh | 40 ++++++++++++++++ apps/desktop/electron-builder.json | 4 ++ apps/desktop/package.json | 3 +- .../com.bitwarden.desktop.devel.yaml | 2 + apps/desktop/resources/linux-wrapper.sh | 13 ++++-- 10 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/desktop_native/process_isolation/Cargo.toml create mode 100644 apps/desktop/desktop_native/process_isolation/src/lib.rs create mode 100644 apps/desktop/desktop_native/process_isolation/test_isolation.sh diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 9020e08362e..4fe1dc8cd8d 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -754,6 +754,22 @@ dependencies = [ "syn", ] +[[package]] +name = "ctor" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + [[package]] name = "ctr" version = "0.9.2" @@ -1049,6 +1065,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dtor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1886,7 +1917,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" dependencies = [ "bitflags", - "ctor", + "ctor 0.2.9", "napi-derive", "napi-sys", "once_cell", @@ -2559,6 +2590,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process_isolation" +version = "0.0.0" +dependencies = [ + "ctor 0.5.0", + "desktop_core", + "libc", +] + [[package]] name = "quick-xml" version = "0.37.5" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 0a637b12de9..39c77f53254 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -6,6 +6,7 @@ members = [ "core", "macos_provider", "napi", + "process_isolation", "proxy", "windows_plugin_authenticator" ] @@ -27,6 +28,7 @@ byteorder = "=1.5.0" bytes = "=1.10.1" cbc = "=0.1.2" core-foundation = "=0.10.1" +ctor = "=0.5.0" dirs = "=6.0.0" ed25519 = "=2.2.3" embed_plist = "=1.2.2" diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 125cb1bb567..8b13fcc6eb3 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -45,6 +45,20 @@ function buildProxyBin(target, release = true) { } } +function buildProcessIsolation() { + if (process.platform !== "linux") { + return; + } + + child_process.execSync(`cargo build --release`, { + stdio: 'inherit', + cwd: path.join(__dirname, "process_isolation") + }); + + console.log("Copying process isolation library to dist folder"); + fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`)); +} + function installTarget(target) { child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname }); } @@ -53,6 +67,7 @@ if (!crossPlatform && !target) { console.log(`Building native modules in ${mode} mode for the native architecture`); buildNapiModule(false, mode === "release"); buildProxyBin(false, mode === "release"); + buildProcessIsolation(); return; } @@ -61,6 +76,7 @@ if (target) { installTarget(target); buildNapiModule(target, mode === "release"); buildProxyBin(target, mode === "release"); + buildProcessIsolation(); return; } @@ -78,4 +94,5 @@ platformTargets.forEach(([target, _]) => { installTarget(target); buildNapiModule(target); buildProxyBin(target); + buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/process_isolation/Cargo.toml b/apps/desktop/desktop_native/process_isolation/Cargo.toml new file mode 100644 index 00000000000..8e4072f1a90 --- /dev/null +++ b/apps/desktop/desktop_native/process_isolation/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "process_isolation" +edition = { workspace = true } +license = { workspace = true } +version = { workspace = true } +publish = { workspace = true } + +[lib] +crate-type = ["cdylib"] + +[dependencies] +ctor = { workspace = true } +desktop_core = { path = "../core" } +libc = { workspace = true } diff --git a/apps/desktop/desktop_native/process_isolation/src/lib.rs b/apps/desktop/desktop_native/process_isolation/src/lib.rs new file mode 100644 index 00000000000..57275817b9f --- /dev/null +++ b/apps/desktop/desktop_native/process_isolation/src/lib.rs @@ -0,0 +1,46 @@ +#![cfg(target_os = "linux")] + +//! This library compiles to a pre-loadable shared object. When preloaded, it +//! immediately isolates the process using the methods available on the platform. +//! On Linux, this is PR_SET_DUMPABLE to prevent debuggers from attaching, the env +//! from being read and the memory from being stolen. + +use desktop_core::process_isolation; +use std::{ffi::c_char, sync::LazyLock}; + +static ORIGINAL_UNSETENV: LazyLock i32> = + LazyLock::new(|| unsafe { + std::mem::transmute(libc::dlsym(libc::RTLD_NEXT, c"unsetenv".as_ptr())) + }); + +/// Hooks unsetenv to fix a bug in zypak-wrapper. +/// Zypak unsets the env in Flatpak as a side-effect, which means that only the top level +/// processes would be hooked. With this work-around all processes in the tree are hooked +#[unsafe(no_mangle)] +unsafe extern "C" fn unsetenv(name: *const c_char) -> i32 { + unsafe { + let Ok(name_str) = std::ffi::CStr::from_ptr(name).to_str() else { + return ORIGINAL_UNSETENV(name); + }; + + if name_str == "LD_PRELOAD" { + // This env variable is provided by the flatpak configuration + let ld_preload = std::env::var("PROCESS_ISOLATION_LD_PRELOAD").unwrap_or_default(); + std::env::set_var("LD_PRELOAD", ld_preload); + return 0; + } + + ORIGINAL_UNSETENV(name) + } +} + +// Hooks the shared object being loaded into the process +#[ctor::ctor] +fn preload_init() { + let pid = unsafe { libc::getpid() }; + unsafe { + println!("[Process Isolation] Enabling memory security for process {pid}"); + process_isolation::isolate_process(); + process_isolation::disable_coredumps(); + } +} diff --git a/apps/desktop/desktop_native/process_isolation/test_isolation.sh b/apps/desktop/desktop_native/process_isolation/test_isolation.sh new file mode 100644 index 00000000000..91f3b7933df --- /dev/null +++ b/apps/desktop/desktop_native/process_isolation/test_isolation.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# This script tests the memory isolation status of bitwarden-desktop processes. The script will print "isolated" +# if the memory is not accessible by other processes. + +CURRENT_USER=$(whoami) + +# Find processes with "bitwarden" in the command +pids=$(pgrep -f bitwarden) + +if [[ -z "$pids" ]]; then + echo "No bitwarden processes found." + exit 0 +fi + +for pid in $pids; do + # Get process info: command, PPID, RSS memory + read cmd ppid rss <<<$(ps -o comm=,ppid=,rss= -p "$pid") + + # Explicitly skip if the command line does not contain "bitwarden" + if ! grep -q "bitwarden" <<<"$cmd"; then + continue + fi + + # Check ownership of /proc/$pid/environ + owner=$(stat -c "%U" /proc/$pid/environ 2>/dev/null) + + if [[ "$owner" == "root" ]]; then + status="isolated" + elif [[ "$owner" == "$CURRENT_USER" ]]; then + status="insecure" + else + status="unknown-owner:$owner" + fi + + # Convert memory to MB + mem_mb=$((rss / 1024)) + + echo "PID: $pid | CMD: $cmd | Mem: ${mem_mb}MB | Owner: $owner | Status: $status" +done diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 2e780bf6b1d..f7dcfb65044 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -106,6 +106,10 @@ { "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}", "to": "desktop_proxy" + }, + { + "from": "desktop_native/dist/libprocess_isolation.so", + "to": "libprocess_isolation.so" } ], "target": ["deb", "freebsd", "rpm", "AppImage", "snap"], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ee987905980..26d48742b7c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,9 +35,10 @@ "build:renderer:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer --watch", "electron": "node ./scripts/start.js", "electron:ignore": "node ./scripts/start.js --ignore-certificate-errors", + "flatpak:dev": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --force-clean --install --user ../../.flatpak/ ./resources/com.bitwarden.desktop.devel.yaml && flatpak run com.bitwarden.desktop", "clean:dist": "rimraf ./dist", "pack:dir": "npm run clean:dist && electron-builder --dir -p never", - "pack:lin:flatpak": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --repo=build/.repo build/.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ./build/.repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop", + "pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop", "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/", "pack:lin:arm64": "npm run clean:dist && electron-builder --dir -p never && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .", "pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never", diff --git a/apps/desktop/resources/com.bitwarden.desktop.devel.yaml b/apps/desktop/resources/com.bitwarden.desktop.devel.yaml index 858fb6e1af2..e72df98e22b 100644 --- a/apps/desktop/resources/com.bitwarden.desktop.devel.yaml +++ b/apps/desktop/resources/com.bitwarden.desktop.devel.yaml @@ -46,4 +46,6 @@ modules: commands: - ulimit -c 0 - export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID" + - export ZYPAK_LD_PRELOAD="/app/bin/libprocess_isolation.so" + - export PROCESS_ISOLATION_LD_PRELOAD="/app/bin/libprocess_isolation.so" - exec zypak-wrapper /app/bin/bitwarden-app "$@" diff --git a/apps/desktop/resources/linux-wrapper.sh b/apps/desktop/resources/linux-wrapper.sh index dd53eb9811c..50a323e1c18 100644 --- a/apps/desktop/resources/linux-wrapper.sh +++ b/apps/desktop/resources/linux-wrapper.sh @@ -7,12 +7,19 @@ ulimit -c 0 RAW_PATH=$(readlink -f "$0") APP_PATH=$(dirname $RAW_PATH) -# force use of base image libdus in snap -if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ] -then +# force use of base image libdbus in snap +if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]; then export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" fi +# If running in non-snap, add libprocess_isolation.so from app path to LD_PRELOAD +# This prevents debugger / memory dumping on all desktop processes +if [ -z "$SNAP" ] && [ -f "$APP_PATH/libprocess_isolation.so" ]; then + LIBPROCESS_ISOLATION_SO="$APP_PATH/libprocess_isolation.so" + LD_PRELOAD="$LIBPROCESS_ISOLATION_SO${LD_PRELOAD:+:$LD_PRELOAD}" + export LD_PRELOAD +fi + PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto" if [ "$USE_X11" = "true" ]; then PARAMS="" From 6ee41343a59e22662ad3f52a9a4d5757c0ad5e71 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:49:52 -0500 Subject: [PATCH 09/37] [PM-25379] Refactor org metadata (#16759) * removing unused properties from org metadata * removing further properties from the response and replacing them with data already available * [PM-25379] new org metadata service for new endpoint * don't need strict ignore * forgot unit tests * added cache busting to metadata service not used yet - waiting for a decision on moving a portion of this to AC --- .../members/members.component.ts | 4 +- ...ganization-subscription-cloud.component.ts | 10 +- .../member-access-report.component.ts | 6 +- .../src/services/jslib-services.module.ts | 7 + .../billing-api.service.abstraction.ts | 7 +- ...ganization-metadata.service.abstraction.ts | 12 + .../organization-billing-metadata.response.ts | 23 -- .../billing/services/billing-api.service.ts | 17 +- .../organization-metadata.service.spec.ts | 276 ++++++++++++++++++ .../organization-metadata.service.ts | 74 +++++ libs/common/src/enums/feature-flag.enum.ts | 2 + 11 files changed, 404 insertions(+), 34 deletions(-) create mode 100644 libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts create mode 100644 libs/common/src/billing/services/organization/organization-metadata.service.spec.ts create mode 100644 libs/common/src/billing/services/organization/organization-metadata.service.ts diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index b31f1cbf358..dd1c0edf08b 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -47,6 +47,7 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -146,6 +147,7 @@ export class MembersComponent extends BaseMembersComponent private groupService: GroupApiService, private collectionService: CollectionService, private billingApiService: BillingApiServiceAbstraction, + private organizationMetadataService: OrganizationMetadataServiceAbstraction, protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, private configService: ConfigService, private organizationUserService: OrganizationUserService, @@ -257,7 +259,7 @@ export class MembersComponent extends BaseMembersComponent this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe( switchMap(([_, organization]) => - this.billingApiService.getOrganizationBillingMetadata(organization.id), + this.organizationMetadataService.getOrganizationMetadata$(organization.id), ), takeUntilDestroyed(), shareReplay({ bufferSize: 1, refCount: false }), diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 6bb262152ed..79d4057fdd7 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -148,19 +148,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy const isResoldOrganizationOwner = this.userOrg.hasReseller && this.userOrg.isOwner; const isMSPUser = this.userOrg.hasProvider && this.userOrg.isProviderUser; - const metadata = await this.billingApiService.getOrganizationBillingMetadata( - this.organizationId, - ); - this.organizationIsManagedByConsolidatedBillingMSP = - this.userOrg.hasProvider && metadata.isManaged; + this.userOrg.hasProvider && this.userOrg.hasBillableProvider; this.showSubscription = isIndependentOrganizationOwner || isResoldOrganizationOwner || (isMSPUser && !this.organizationIsManagedByConsolidatedBillingMSP); - this.showSelfHost = metadata.isEligibleForSelfHost; + this.showSelfHost = + this.userOrg.productTierType === ProductTierType.Families || + this.userOrg.productTierType === ProductTierType.Enterprise; if (this.showSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts index b9cab679560..796cf212a67 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts @@ -9,6 +9,7 @@ import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rx import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -56,6 +57,7 @@ export class MemberAccessReportComponent implements OnInit { protected dialogService: DialogService, protected userNamePipe: UserNamePipe, protected billingApiService: BillingApiServiceAbstraction, + protected organizationMetadataService: OrganizationMetadataServiceAbstraction, ) { // Connect the search input to the table dataSource filter input this.searchControl.valueChanges @@ -69,8 +71,8 @@ export class MemberAccessReportComponent implements OnInit { const params = await firstValueFrom(this.route.params); this.organizationId = params.organizationId; - const billingMetadata = await this.billingApiService.getOrganizationBillingMetadata( - this.organizationId, + const billingMetadata = await firstValueFrom( + this.organizationMetadataService.getOrganizationMetadata$(this.organizationId), ); this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 717a6c501c9..c66c74a3ea9 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -145,12 +145,14 @@ import { } from "@bitwarden/common/billing/abstractions"; import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service"; +import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service"; import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service"; @@ -1414,6 +1416,11 @@ const safeProviders: SafeProvider[] = [ useClass: BillingApiService, deps: [ApiServiceAbstraction], }), + safeProvider({ + provide: OrganizationMetadataServiceAbstraction, + useClass: DefaultOrganizationMetadataService, + deps: [BillingApiServiceAbstraction, ConfigService], + }), safeProvider({ provide: BillingAccountProfileStateService, useClass: DefaultBillingAccountProfileStateService, diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index b5695e2e8a0..1dbb8053e97 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -4,6 +4,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/su import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; import { PlanResponse } from "../../billing/models/response/plan.response"; import { ListResponse } from "../../models/response/list.response"; +import { OrganizationId } from "../../types/guid"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request"; import { InvoicesResponse } from "../models/response/invoices.response"; @@ -23,7 +24,11 @@ export abstract class BillingApiServiceAbstraction { ): Promise; abstract getOrganizationBillingMetadata( - organizationId: string, + organizationId: OrganizationId, + ): Promise; + + abstract getOrganizationBillingMetadataVNext( + organizationId: OrganizationId, ): Promise; abstract getPlans(): Promise>; diff --git a/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts b/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts new file mode 100644 index 00000000000..c16d4112273 --- /dev/null +++ b/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts @@ -0,0 +1,12 @@ +import { Observable } from "rxjs"; + +import { OrganizationId } from "../../types/guid"; +import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response"; + +export abstract class OrganizationMetadataServiceAbstraction { + abstract getOrganizationMetadata$( + organizationId: OrganizationId, + ): Observable; + + abstract refreshMetadataCache(): void; +} diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index aa34c37bd1d..366d10a1dca 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -1,35 +1,12 @@ import { BaseResponse } from "../../../models/response/base.response"; export class OrganizationBillingMetadataResponse extends BaseResponse { - isEligibleForSelfHost: boolean; - isManaged: boolean; isOnSecretsManagerStandalone: boolean; - isSubscriptionUnpaid: boolean; - hasSubscription: boolean; - hasOpenInvoice: boolean; - invoiceDueDate: Date | null; - invoiceCreatedDate: Date | null; - subPeriodEndDate: Date | null; - isSubscriptionCanceled: boolean; organizationOccupiedSeats: number; constructor(response: any) { super(response); - this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); - this.isManaged = this.getResponseProperty("IsManaged"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); - this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid"); - this.hasSubscription = this.getResponseProperty("HasSubscription"); - this.hasOpenInvoice = this.getResponseProperty("HasOpenInvoice"); - - this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate")); - this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate")); - this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate")); - this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled"); this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats"); } - - private parseDate(dateString: any): Date | null { - return dateString ? new Date(dateString) : null; - } } diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index a34809e9f02..c953d920055 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response"; import { ListResponse } from "../../models/response/list.response"; +import { OrganizationId } from "../../types/guid"; import { BillingApiServiceAbstraction } from "../abstractions"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request"; @@ -48,7 +49,7 @@ export class BillingApiService implements BillingApiServiceAbstraction { } async getOrganizationBillingMetadata( - organizationId: string, + organizationId: OrganizationId, ): Promise { const r = await this.apiService.send( "GET", @@ -61,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new OrganizationBillingMetadataResponse(r); } + async getOrganizationBillingMetadataVNext( + organizationId: OrganizationId, + ): Promise { + const r = await this.apiService.send( + "GET", + "/organizations/" + organizationId + "/billing/vnext/metadata", + null, + true, + true, + ); + + return new OrganizationBillingMetadataResponse(r); + } + async getPlans(): Promise> { const r = await this.apiService.send("GET", "/plans", null, false, true); return new ListResponse(r, PlanResponse); diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts new file mode 100644 index 00000000000..0ed60bef605 --- /dev/null +++ b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts @@ -0,0 +1,276 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { newGuid } from "@bitwarden/guid"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { OrganizationId } from "../../../types/guid"; + +import { DefaultOrganizationMetadataService } from "./organization-metadata.service"; + +describe("DefaultOrganizationMetadataService", () => { + let service: DefaultOrganizationMetadataService; + let billingApiService: jest.Mocked; + let configService: jest.Mocked; + let featureFlagSubject: BehaviorSubject; + + const mockOrganizationId = newGuid() as OrganizationId; + const mockOrganizationId2 = newGuid() as OrganizationId; + + const createMockMetadataResponse = ( + isOnSecretsManagerStandalone = false, + organizationOccupiedSeats = 5, + ): OrganizationBillingMetadataResponse => { + return { + isOnSecretsManagerStandalone, + organizationOccupiedSeats, + } as OrganizationBillingMetadataResponse; + }; + + beforeEach(() => { + billingApiService = mock(); + configService = mock(); + featureFlagSubject = new BehaviorSubject(false); + + configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable()); + + service = new DefaultOrganizationMetadataService(billingApiService, configService); + }); + + afterEach(() => { + jest.resetAllMocks(); + featureFlagSubject.complete(); + }); + + describe("getOrganizationMetadata$", () => { + describe("feature flag OFF", () => { + beforeEach(() => { + featureFlagSubject.next(false); + }); + + it("calls getOrganizationBillingMetadata when feature flag is off", async () => { + const mockResponse = createMockMetadataResponse(false, 10); + billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse); + + const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(configService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM25379_UseNewOrganizationMetadataStructure, + ); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith( + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled(); + expect(result).toEqual(mockResponse); + }); + + it("does not cache metadata when feature flag is off", async () => { + const mockResponse1 = createMockMetadataResponse(false, 10); + const mockResponse2 = createMockMetadataResponse(false, 15); + billingApiService.getOrganizationBillingMetadata + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2); + expect(result1).toEqual(mockResponse1); + expect(result2).toEqual(mockResponse2); + }); + }); + + describe("feature flag ON", () => { + beforeEach(() => { + featureFlagSubject.next(true); + }); + + it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => { + const mockResponse = createMockMetadataResponse(true, 15); + billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + + const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(configService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM25379_UseNewOrganizationMetadataStructure, + ); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith( + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled(); + expect(result).toEqual(mockResponse); + }); + + it("caches metadata by organization ID when feature flag is on", async () => { + const mockResponse = createMockMetadataResponse(true, 10); + billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1); + expect(result1).toEqual(mockResponse); + expect(result2).toEqual(mockResponse); + }); + + it("maintains separate cache entries for different organization IDs", async () => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(false, 20); + billingApiService.getOrganizationBillingMetadataVNext + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); + const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); + + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith( + 1, + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith( + 2, + mockOrganizationId2, + ); + expect(result1).toEqual(mockResponse1); + expect(result2).toEqual(mockResponse2); + expect(result3).toEqual(mockResponse1); + expect(result4).toEqual(mockResponse2); + }); + }); + + describe("shareReplay behavior", () => { + beforeEach(() => { + featureFlagSubject.next(true); + }); + + it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => { + const mockResponse = createMockMetadataResponse(true, 10); + billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + + const metadata$ = service.getOrganizationMetadata$(mockOrganizationId); + + const subscription1Promise = firstValueFrom(metadata$); + const subscription2Promise = firstValueFrom(metadata$); + const subscription3Promise = firstValueFrom(metadata$); + + const [result1, result2, result3] = await Promise.all([ + subscription1Promise, + subscription2Promise, + subscription3Promise, + ]); + + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1); + expect(result1).toEqual(mockResponse); + expect(result2).toEqual(mockResponse); + expect(result3).toEqual(mockResponse); + }); + }); + }); + + describe("refreshMetadataCache", () => { + beforeEach(() => { + featureFlagSubject.next(true); + }); + + it("refreshes cached metadata when called with feature flag on", (done) => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(true, 20); + let invocationCount = 0; + + billingApiService.getOrganizationBillingMetadataVNext + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ + next: (result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toEqual(mockResponse1); + } else if (invocationCount === 2) { + expect(result).toEqual(mockResponse2); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2); + subscription.unsubscribe(); + done(); + } + }, + error: done.fail, + }); + + setTimeout(() => { + service.refreshMetadataCache(); + }, 10); + }); + + it("does not trigger refresh when feature flag is disabled", async () => { + featureFlagSubject.next(false); + + const mockResponse1 = createMockMetadataResponse(false, 10); + const mockResponse2 = createMockMetadataResponse(false, 20); + let invocationCount = 0; + + billingApiService.getOrganizationBillingMetadata + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ + next: () => { + invocationCount++; + }, + }); + + // wait for initial invocation + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(invocationCount).toBe(1); + + service.refreshMetadataCache(); + + // wait to ensure no additional invocations + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(invocationCount).toBe(1); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + }); + + it("bypasses cache when refreshing metadata", (done) => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(true, 20); + const mockResponse3 = createMockMetadataResponse(true, 30); + let invocationCount = 0; + + billingApiService.getOrganizationBillingMetadataVNext + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2) + .mockResolvedValueOnce(mockResponse3); + + const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ + next: (result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toEqual(mockResponse1); + service.refreshMetadataCache(); + } else if (invocationCount === 2) { + expect(result).toEqual(mockResponse2); + service.refreshMetadataCache(); + } else if (invocationCount === 3) { + expect(result).toEqual(mockResponse3); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3); + subscription.unsubscribe(); + done(); + } + }, + error: done.fail, + }); + }); + }); +}); diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.ts b/libs/common/src/billing/services/organization/organization-metadata.service.ts new file mode 100644 index 00000000000..09aaa202112 --- /dev/null +++ b/libs/common/src/billing/services/organization/organization-metadata.service.ts @@ -0,0 +1,74 @@ +import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs"; + +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; +import { OrganizationId } from "../../../types/guid"; +import { OrganizationMetadataServiceAbstraction } from "../../abstractions/organization-metadata.service.abstraction"; +import { OrganizationBillingMetadataResponse } from "../../models/response/organization-billing-metadata.response"; + +export class DefaultOrganizationMetadataService implements OrganizationMetadataServiceAbstraction { + private metadataCache = new Map< + OrganizationId, + Observable + >(); + + constructor( + private billingApiService: BillingApiServiceAbstraction, + private configService: ConfigService, + ) {} + private refreshMetadataTrigger = new Subject(); + + refreshMetadataCache = () => this.refreshMetadataTrigger.next(); + + getOrganizationMetadata$ = ( + organizationId: OrganizationId, + ): Observable => + this.configService + .getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure) + .pipe( + switchMap((featureFlagEnabled) => { + return merge( + this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled), + this.refreshMetadataTrigger.pipe( + filter(() => featureFlagEnabled), + switchMap(() => + this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true), + ), + ), + ); + }), + ); + + private getOrganizationMetadataInternal$( + organizationId: OrganizationId, + featureFlagEnabled: boolean, + bypassCache: boolean = false, + ): Observable { + if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) { + return this.metadataCache.get(organizationId)!; + } + + const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe( + shareReplay({ bufferSize: 1, refCount: false }), + ); + + if (featureFlagEnabled) { + this.metadataCache.set(organizationId, metadata$); + } + + return metadata$; + } + + private async fetchMetadata( + organizationId: OrganizationId, + featureFlagEnabled: boolean, + ): Promise { + if (featureFlagEnabled) { + return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId); + } + + return await this.billingApiService.getOrganizationBillingMetadata(organizationId); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 8b73010daf5..78113d74cb8 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -25,6 +25,7 @@ export enum FeatureFlag { PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", + PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", /* Key Management */ @@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, + [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, /* Key Management */ From 2c08af2b42ee82fcc2b9648b6ee0d7d60392248d Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:01:16 +0100 Subject: [PATCH 10/37] [PM-25166]Hide and Show Old Premium Banner Depending On the Feature Flag (#16684) * Code to hide and show the premium banner * add the right flag name * Removed unused flag * Remove the unused feature flag * Resolve the flag name issue --- .../vault-banners.component.spec.ts | 42 +++++++++++++++++-- .../vault-banners/vault-banners.component.ts | 17 +++++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts index f3a859e17bf..7730ab974fb 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts @@ -8,6 +8,7 @@ import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -29,6 +30,7 @@ describe("VaultBannersComponent", () => { let messageSubject: Subject<{ command: string }>; const premiumBanner$ = new BehaviorSubject(false); const pendingAuthRequest$ = new BehaviorSubject(false); + const PM24996_ImplementUpgradeFromFreeDialogFlag$ = new BehaviorSubject(false); const mockUserId = Utils.newGuid() as UserId; const bannerService = mock({ @@ -88,7 +90,14 @@ describe("VaultBannersComponent", () => { }, { provide: ConfigService, - useValue: mock(), + useValue: mock({ + getFeatureFlag$: jest.fn((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog) { + return PM24996_ImplementUpgradeFromFreeDialogFlag$; + } + return new BehaviorSubject(false); + }), + }), }, ], }) @@ -104,8 +113,14 @@ describe("VaultBannersComponent", () => { }); describe("premiumBannerVisible$", () => { - it("shows premium banner", async () => { + beforeEach(() => { + // Reset feature flag to default (false) before each test + PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false); + }); + + it("shows premium banner when shouldShowPremiumBanner is true and feature flag is off", async () => { premiumBanner$.next(true); + PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false); fixture.detectChanges(); @@ -113,8 +128,29 @@ describe("VaultBannersComponent", () => { expect(banner.componentInstance.bannerType()).toBe("premium"); }); - it("dismisses premium banner", async () => { + it("hides premium banner when feature flag is enabled", async () => { + premiumBanner$.next(true); + PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true); + + fixture.detectChanges(); + + const banner = fixture.debugElement.query(By.directive(BannerComponent)); + expect(banner).toBeNull(); + }); + + it("dismisses premium banner when shouldShowPremiumBanner is false", async () => { premiumBanner$.next(false); + PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false); + + fixture.detectChanges(); + + const banner = fixture.debugElement.query(By.directive(BannerComponent)); + expect(banner).toBeNull(); + }); + + it("hides premium banner when both shouldShowPremiumBanner is false and feature flag is enabled", async () => { + premiumBanner$.next(false); + PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true); fixture.detectChanges(); diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts index cd32eaf2858..78624b3662c 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts @@ -1,10 +1,12 @@ import { Component, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs"; +import { combineLatest, filter, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; import { BannerModule } from "@bitwarden/components"; @@ -39,10 +41,21 @@ export class VaultBannersComponent implements OnInit { private router: Router, private accountService: AccountService, private messageListener: MessageListener, + private configService: ConfigService, ) { this.premiumBannerVisible$ = this.activeUserId$.pipe( filter((userId): userId is UserId => userId != null), - switchMap((userId) => this.vaultBannerService.shouldShowPremiumBanner$(userId)), + switchMap((userId) => + combineLatest([ + this.vaultBannerService.shouldShowPremiumBanner$(userId), + this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), + ]).pipe( + map( + ([shouldShowBanner, PM24996_ImplementUpgradeFromFreeDialogEnabled]) => + shouldShowBanner && !PM24996_ImplementUpgradeFromFreeDialogEnabled, + ), + ), + ), ); // Listen for auth request messages and show banner immediately From 886003ba88a51b9672f478894de5a6b1da16b140 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:48:25 -0400 Subject: [PATCH 11/37] feat(two-factor-api-service) [PM-26465]: (Refactor) Two-Factor API Service (#16747) * feat(two-factor-api-service) [PM-26465]: Add TwoFactorApiServiceAbstraction. * feat(two-factor-api-service) [PM-26465]: Add TwoFactorApiService implementation. * feat(two-factor-api-service) [PM-26465]: Add test suite for TwoFactorApiService. * feat(two-factor-api-service) [PM-26465]: Replace ApiService dependencies with TwoFactorApiService for all refactored methods. * feat(two-factor-api-service) [PM-26465]: Finish removal of Two-Factor API methods from ApiService. * fix(two-factor-api-service) [PM-26465]: Correct endpoint spelling. * feat(two-factor-api-service) [PM-26465]: Update dependency support for CLI. * fix(two-factor-api-service) [PM-26465]: Update tests/deps for corrected spelling. * feat(two-factor-api-service) [PM-26465]: Add TwoFactorApiService to Browser services module. * fix(two-factor-api-service) [PM-26465]: Re-spell dependencies to take *Abstraction throughout, move to JslibServices module for cleaner importing across clients. * feat(two-factor-api-service) [PM-26465]: Move new services to a feature area, rename abstract and concrete/default. * feat(two-factor-api-service) [PM-26465]: Move the feature area to common/auth, not auth/common. * feat(two-factor-api-service) [PM-26465]: Remove now-unneeded include from auth/tsconfig. --- apps/cli/src/auth/commands/login.command.ts | 6 +- apps/cli/src/program.ts | 2 +- .../service-container/service-container.ts | 4 + .../settings/two-factor-setup.component.ts | 8 +- .../account/change-email.component.spec.ts | 6 +- .../account/change-email.component.ts | 4 +- ...account-verify-devices-dialog.component.ts | 6 +- ...wo-factor-setup-authenticator.component.ts | 10 +- .../two-factor-setup-duo.component.ts | 13 +- .../two-factor-setup-email.component.ts | 10 +- .../two-factor-setup-method-base.component.ts | 15 +- .../two-factor-setup-webauthn.component.ts | 12 +- .../two-factor-setup-yubikey.component.ts | 8 +- .../two-factor/two-factor-setup.component.ts | 6 +- .../two-factor/two-factor-verify.component.ts | 18 +- .../src/services/jslib-services.module.ts | 6 + .../two-factor-auth-email.component.ts | 6 +- libs/common/src/abstractions/api.service.ts | 81 -- .../request/device-verification.request.ts | 7 - .../response/device-verification.response.ts | 16 - .../default-two-factor-api.service.ts | 272 +++++++ libs/common/src/auth/two-factor/index.ts | 2 + .../two-factor/two-factor-api.service.spec.ts | 697 ++++++++++++++++++ .../auth/two-factor/two-factor-api.service.ts | 292 ++++++++ libs/common/src/services/api.service.ts | 220 ------ 25 files changed, 1344 insertions(+), 383 deletions(-) delete mode 100644 libs/common/src/auth/models/request/device-verification.request.ts delete mode 100644 libs/common/src/auth/models/response/device-verification.response.ts create mode 100644 libs/common/src/auth/two-factor/default-two-factor-api.service.ts create mode 100644 libs/common/src/auth/two-factor/index.ts create mode 100644 libs/common/src/auth/two-factor/two-factor-api.service.spec.ts create mode 100644 libs/common/src/auth/two-factor/two-factor-api.service.ts diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 7e5058dcff0..e1a3d123441 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -14,7 +14,6 @@ import { SsoUrlService, UserApiLoginCredentials, } from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -29,6 +28,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; @@ -62,7 +62,7 @@ export class LoginCommand { constructor( protected loginStrategyService: LoginStrategyServiceAbstraction, protected authService: AuthService, - protected apiService: ApiService, + protected twoFactorApiService: TwoFactorApiService, protected masterPasswordApiService: MasterPasswordApiService, protected cryptoFunctionService: CryptoFunctionService, protected environmentService: EnvironmentService, @@ -279,7 +279,7 @@ export class LoginCommand { const emailReq = new TwoFactorEmailRequest(); emailReq.email = await this.loginStrategyService.getEmail(); emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); - await this.apiService.postTwoFactorEmail(emailReq); + await this.twoFactorApiService.postTwoFactorEmail(emailReq); } if (twoFactorToken == null) { diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 8f202bc0845..e8c9bff746e 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -175,7 +175,7 @@ export class Program extends BaseProgram { const command = new LoginCommand( this.serviceContainer.loginStrategyService, this.serviceContainer.authService, - this.serviceContainer.apiService, + this.serviceContainer.twoFactorApiService, this.serviceContainer.masterPasswordApiService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.environmentService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index d13d251bce0..7c4b708576b 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -49,6 +49,7 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; 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 { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AutofillSettingsService, AutofillSettingsServiceAbstraction, @@ -228,6 +229,7 @@ export class ServiceContainer { tokenService: TokenService; appIdService: AppIdService; apiService: NodeApiService; + twoFactorApiService: TwoFactorApiService; hibpApiService: HibpApiService; environmentService: EnvironmentService; cipherService: CipherService; @@ -528,6 +530,8 @@ export class ServiceContainer { this.configApiService = new ConfigApiService(this.apiService); + this.twoFactorApiService = new DefaultTwoFactorApiService(this.apiService); + this.authService = new AuthService( this.accountService, this.messagingService, diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts index 020a16dd932..3151e0a702f 100644 --- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts @@ -5,7 +5,6 @@ import { ActivatedRoute } from "@angular/router"; import { concatMap, takeUntil, map, lastValueFrom, firstValueFrom } from "rxjs"; import { first, tap } from "rxjs/operators"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { getOrganizationById, OrganizationService, @@ -15,6 +14,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -35,7 +35,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme tabbedHeader = false; constructor( dialogService: DialogService, - apiService: ApiService, + twoFactorApiService: TwoFactorApiService, messagingService: MessagingService, policyService: PolicyService, private route: ActivatedRoute, @@ -47,7 +47,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme ) { super( dialogService, - apiService, + twoFactorApiService, messagingService, policyService, billingAccountProfileStateService, @@ -116,7 +116,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme } protected getTwoFactorProviders() { - return this.apiService.getTwoFactorOrganizationProviders(this.organizationId); + return this.twoFactorApiService.getTwoFactorOrganizationProviders(this.organizationId); } protected filterProvider(type: TwoFactorProviderType): boolean { diff --git a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts index bd0d9df9f06..934de0f6453 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts @@ -7,6 +7,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -22,12 +23,14 @@ describe("ChangeEmailComponent", () => { let fixture: ComponentFixture; let apiService: MockProxy; + let twoFactorApiService: MockProxy; let accountService: FakeAccountService; let keyService: MockProxy; let kdfConfigService: MockProxy; beforeEach(async () => { apiService = mock(); + twoFactorApiService = mock(); keyService = mock(); kdfConfigService = mock(); accountService = mockAccountServiceWith("UserId" as UserId); @@ -37,6 +40,7 @@ describe("ChangeEmailComponent", () => { providers: [ { provide: AccountService, useValue: accountService }, { provide: ApiService, useValue: apiService }, + { provide: TwoFactorApiService, useValue: twoFactorApiService }, { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: KeyService, useValue: keyService }, { provide: MessagingService, useValue: mock() }, @@ -57,7 +61,7 @@ describe("ChangeEmailComponent", () => { describe("ngOnInit", () => { beforeEach(() => { - apiService.getTwoFactorProviders.mockResolvedValue({ + twoFactorApiService.getTwoFactorProviders.mockResolvedValue({ data: [{ type: TwoFactorProviderType.Email, enabled: true } as TwoFactorProviderResponse], } as ListResponse); }); diff --git a/apps/web/src/app/auth/settings/account/change-email.component.ts b/apps/web/src/app/auth/settings/account/change-email.component.ts index a55846a5c0f..b6ca39c6413 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.ts @@ -8,6 +8,7 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p import { EmailTokenRequest } from "@bitwarden/common/auth/models/request/email-token.request"; import { EmailRequest } from "@bitwarden/common/auth/models/request/email.request"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -37,6 +38,7 @@ export class ChangeEmailComponent implements OnInit { constructor( private accountService: AccountService, private apiService: ApiService, + private twoFactorApiService: TwoFactorApiService, private i18nService: I18nService, private keyService: KeyService, private messagingService: MessagingService, @@ -48,7 +50,7 @@ export class ChangeEmailComponent implements OnInit { async ngOnInit() { this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - const twoFactorProviders = await this.apiService.getTwoFactorProviders(); + const twoFactorProviders = await this.twoFactorApiService.getTwoFactorProviders(); this.showTwoFactorEmailWarning = twoFactorProviders.data.some( (p) => p.type === TwoFactorProviderType.Email && p.enabled, ); diff --git a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts index 63a26f08eee..c66f31f6c3b 100644 --- a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts @@ -5,11 +5,11 @@ import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { SetVerifyDevicesRequest } from "@bitwarden/common/auth/models/request/set-verify-devices.request"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -64,7 +64,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy private userVerificationService: UserVerificationService, private dialogRef: DialogRef, private toastService: ToastService, - private apiService: ApiService, + private twoFactorApiService: TwoFactorApiService, ) { this.accountService.accountVerifyNewDeviceLogin$ .pipe(takeUntil(this.destroy$)) @@ -74,7 +74,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy } async ngOnInit() { - const twoFactorProviders = await this.apiService.getTwoFactorProviders(); + const twoFactorProviders = await this.twoFactorApiService.getTwoFactorProviders(); this.has2faConfigured = twoFactorProviders.data.length > 0; } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts index 698e0911b04..d57d6eca894 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts @@ -6,13 +6,13 @@ import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angu import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -92,7 +92,7 @@ export class TwoFactorSetupAuthenticatorComponent constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, private dialogRef: DialogRef, - apiService: ApiService, + twoFactorApiService: TwoFactorApiService, i18nService: I18nService, userVerificationService: UserVerificationService, private formBuilder: FormBuilder, @@ -104,7 +104,7 @@ export class TwoFactorSetupAuthenticatorComponent protected toastService: ToastService, ) { super( - apiService, + twoFactorApiService, i18nService, platformUtilsService, logService, @@ -154,7 +154,7 @@ export class TwoFactorSetupAuthenticatorComponent request.key = this.key; request.userVerificationToken = this.userVerificationToken; - const response = await this.apiService.putTwoFactorAuthenticator(request); + const response = await this.twoFactorApiService.putTwoFactorAuthenticator(request); await this.processResponse(response); this.onUpdated.emit(true); } @@ -174,7 +174,7 @@ export class TwoFactorSetupAuthenticatorComponent request.type = this.type; request.key = this.key; request.userVerificationToken = this.userVerificationToken; - await this.apiService.deleteTwoFactorAuthenticator(request); + await this.twoFactorApiService.deleteTwoFactorAuthenticator(request); this.enabled = false; this.toastService.showToast({ variant: "success", diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts index 0efd0c79b4e..bf820e32917 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts @@ -2,11 +2,11 @@ import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request"; import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -63,7 +63,7 @@ export class TwoFactorSetupDuoComponent constructor( @Inject(DIALOG_DATA) protected data: TwoFactorDuoComponentConfig, - apiService: ApiService, + twoFactorApiService: TwoFactorApiService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, logService: LogService, @@ -74,7 +74,7 @@ export class TwoFactorSetupDuoComponent protected toastService: ToastService, ) { super( - apiService, + twoFactorApiService, i18nService, platformUtilsService, logService, @@ -139,9 +139,12 @@ export class TwoFactorSetupDuoComponent let response: TwoFactorDuoResponse; if (this.organizationId != null) { - response = await this.apiService.putTwoFactorOrganizationDuo(this.organizationId, request); + response = await this.twoFactorApiService.putTwoFactorOrganizationDuo( + this.organizationId, + request, + ); } else { - response = await this.apiService.putTwoFactorDuo(request); + response = await this.twoFactorApiService.putTwoFactorDuo(request); } this.processResponse(response); diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts index 544f3850ea6..138d541d551 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts @@ -3,13 +3,13 @@ import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { firstValueFrom, map } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request"; import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -66,7 +66,7 @@ export class TwoFactorSetupEmailComponent constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, - apiService: ApiService, + twoFactorApiService: TwoFactorApiService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, logService: LogService, @@ -78,7 +78,7 @@ export class TwoFactorSetupEmailComponent protected toastService: ToastService, ) { super( - apiService, + twoFactorApiService, i18nService, platformUtilsService, logService, @@ -131,7 +131,7 @@ export class TwoFactorSetupEmailComponent sendEmail = async () => { const request = await this.buildRequestModel(TwoFactorEmailRequest); request.email = this.email; - this.emailPromise = this.apiService.postTwoFactorEmailSetup(request); + this.emailPromise = this.twoFactorApiService.postTwoFactorEmailSetup(request); await this.emailPromise; this.sentEmail = this.email; }; @@ -141,7 +141,7 @@ export class TwoFactorSetupEmailComponent request.email = this.email; request.token = this.token; - const response = await this.apiService.putTwoFactorEmail(request); + const response = await this.twoFactorApiService.putTwoFactorEmail(request); await this.processResponse(response); this.onUpdated.emit(true); } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts index 7569577e781..aa3b9e1def3 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts @@ -1,11 +1,11 @@ import { Directive, EventEmitter, Output } from "@angular/core"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponseBase } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -30,7 +30,7 @@ export abstract class TwoFactorSetupMethodBaseComponent { protected componentName = ""; constructor( - protected apiService: ApiService, + protected twoFactorApiService: TwoFactorApiService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, protected logService: LogService, @@ -77,9 +77,12 @@ export abstract class TwoFactorSetupMethodBaseComponent { } request.type = this.type; if (this.organizationId != null) { - promise = this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request); + promise = this.twoFactorApiService.putTwoFactorOrganizationDisable( + this.organizationId, + request, + ); } else { - promise = this.apiService.putTwoFactorDisable(request); + promise = this.twoFactorApiService.putTwoFactorDisable(request); } await promise; this.enabled = false; @@ -111,9 +114,9 @@ export abstract class TwoFactorSetupMethodBaseComponent { } request.type = this.type; if (this.organizationId != null) { - await this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request); + await this.twoFactorApiService.putTwoFactorOrganizationDisable(this.organizationId, request); } else { - await this.apiService.putTwoFactorDisable(request); + await this.twoFactorApiService.putTwoFactorDisable(request); } this.enabled = false; this.toastService.showToast({ diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts index 66cd3596063..ff0e971461e 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts @@ -3,7 +3,6 @@ import { Component, Inject, NgZone } from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; @@ -13,6 +12,7 @@ import { ChallengeResponse, TwoFactorWebAuthnResponse, } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -79,7 +79,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, private dialogRef: DialogRef, - apiService: ApiService, + twoFactorApiService: TwoFactorApiService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, private ngZone: NgZone, @@ -89,7 +89,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom toastService: ToastService, ) { super( - apiService, + twoFactorApiService, i18nService, platformUtilsService, logService, @@ -127,7 +127,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom request.id = this.keyIdAvailable; request.name = this.formGroup.value.name || ""; - const response = await this.apiService.putTwoFactorWebAuthn(request); + const response = await this.twoFactorApiService.putTwoFactorWebAuthn(request); this.processResponse(response); this.toastService.showToast({ title: this.i18nService.t("success"), @@ -163,7 +163,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom const request = await this.buildRequestModel(UpdateTwoFactorWebAuthnDeleteRequest); request.id = key.id; try { - key.removePromise = this.apiService.deleteTwoFactorWebAuthn(request); + key.removePromise = this.twoFactorApiService.deleteTwoFactorWebAuthn(request); const response = await key.removePromise; key.removePromise = null; await this.processResponse(response); @@ -177,7 +177,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom return; } const request = await this.buildRequestModel(SecretVerificationRequest); - this.challengePromise = this.apiService.getTwoFactorWebAuthnChallenge(request); + this.challengePromise = this.twoFactorApiService.getTwoFactorWebAuthnChallenge(request); const challenge = await this.challengePromise; this.readDevice(challenge); }; diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts index 0b85d219928..4e4691a5f60 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts @@ -9,11 +9,11 @@ import { } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request"; import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -93,7 +93,7 @@ export class TwoFactorSetupYubiKeyComponent constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, - apiService: ApiService, + twoFactorApiService: TwoFactorApiService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, logService: LogService, @@ -103,7 +103,7 @@ export class TwoFactorSetupYubiKeyComponent protected toastService: ToastService, ) { super( - apiService, + twoFactorApiService, i18nService, platformUtilsService, logService, @@ -176,7 +176,7 @@ export class TwoFactorSetupYubiKeyComponent request.key5 = keys != null && keys.length > 4 ? (keys[4]?.key ?? "") : ""; request.nfc = this.formGroup.value.anyKeyHasNfc ?? false; - this.processResponse(await this.apiService.putTwoFactorYubiKey(request)); + this.processResponse(await this.twoFactorApiService.putTwoFactorYubiKey(request)); this.refreshFormArrayData(); this.toastService.showToast({ title: this.i18nService.t("success"), diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index 043c27998cd..c3a55ad661e 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -13,7 +13,6 @@ import { } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -26,6 +25,7 @@ import { TwoFactorWebAuthnResponse } from "@bitwarden/common/auth/models/respons import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; @@ -68,7 +68,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { constructor( protected dialogService: DialogService, - protected apiService: ApiService, + protected twoFactorApiService: TwoFactorApiService, protected messagingService: MessagingService, protected policyService: PolicyService, billingAccountProfileStateService: BillingAccountProfileStateService, @@ -270,7 +270,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { } protected getTwoFactorProviders() { - return this.apiService.getTwoFactorProviders(); + return this.twoFactorApiService.getTwoFactorProviders(); } protected filterProvider(type: TwoFactorProviderType): boolean { diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts index 07939db7eff..a2c734ed2d5 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts @@ -2,11 +2,11 @@ import { Component, EventEmitter, Inject, Output } from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { TwoFactorResponse } from "@bitwarden/common/auth/types/two-factor-response"; import { Verification } from "@bitwarden/common/auth/types/verification"; @@ -55,7 +55,7 @@ export class TwoFactorVerifyComponent { constructor( @Inject(DIALOG_DATA) protected data: TwoFactorVerifyDialogData, private dialogRef: DialogRef, - private apiService: ApiService, + private twoFactorApiService: TwoFactorApiService, private i18nService: I18nService, private userVerificationService: UserVerificationService, ) { @@ -116,22 +116,22 @@ export class TwoFactorVerifyComponent { private apiCall(request: SecretVerificationRequest): Promise { switch (this.type) { case -1 as TwoFactorProviderType: - return this.apiService.getTwoFactorRecover(request); + return this.twoFactorApiService.getTwoFactorRecover(request); case TwoFactorProviderType.Duo: case TwoFactorProviderType.OrganizationDuo: if (this.organizationId != null) { - return this.apiService.getTwoFactorOrganizationDuo(this.organizationId, request); + return this.twoFactorApiService.getTwoFactorOrganizationDuo(this.organizationId, request); } else { - return this.apiService.getTwoFactorDuo(request); + return this.twoFactorApiService.getTwoFactorDuo(request); } case TwoFactorProviderType.Email: - return this.apiService.getTwoFactorEmail(request); + return this.twoFactorApiService.getTwoFactorEmail(request); case TwoFactorProviderType.WebAuthn: - return this.apiService.getTwoFactorWebAuthn(request); + return this.twoFactorApiService.getTwoFactorWebAuthn(request); case TwoFactorProviderType.Authenticator: - return this.apiService.getTwoFactorAuthenticator(request); + return this.twoFactorApiService.getTwoFactorAuthenticator(request); case TwoFactorProviderType.Yubikey: - return this.apiService.getTwoFactorYubiKey(request); + return this.twoFactorApiService.getTwoFactorYubiKey(request); default: throw new Error(`Unknown two-factor type: ${this.type}`); } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c66c74a3ea9..3f9657b8115 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -127,6 +127,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/services/user-ve import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service"; import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service"; import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service"; +import { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AutofillSettingsService, AutofillSettingsServiceAbstraction, @@ -1519,6 +1520,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultTwoFactorAuthWebAuthnComponentService, deps: [], }), + safeProvider({ + provide: TwoFactorApiService, + useClass: DefaultTwoFactorApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: ViewCacheService, useExisting: NoopViewCacheService, diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts index 9b402f3a956..084e8e6e851 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts @@ -4,10 +4,10 @@ import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; +import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -62,7 +62,7 @@ export class TwoFactorAuthEmailComponent implements OnInit { protected loginStrategyService: LoginStrategyServiceAbstraction, protected platformUtilsService: PlatformUtilsService, protected logService: LogService, - protected apiService: ApiService, + protected twoFactorApiService: TwoFactorApiService, protected appIdService: AppIdService, private toastService: ToastService, private cacheService: TwoFactorAuthEmailComponentCacheService, @@ -131,7 +131,7 @@ export class TwoFactorAuthEmailComponent implements OnInit { request.deviceIdentifier = await this.appIdService.getAppId(); request.authRequestAccessCode = (await this.loginStrategyService.getAccessCode()) ?? ""; request.authRequestId = (await this.loginStrategyService.getAuthRequestId()) ?? ""; - this.emailPromise = this.apiService.postTwoFactorEmail(request); + this.emailPromise = this.twoFactorApiService.postTwoFactorEmail(request); await this.emailPromise; this.emailSent = true; diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 1cab48148e9..93e47a6d9a8 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -37,8 +37,6 @@ import { ProviderUserUserDetailsResponse, } from "../admin-console/models/response/provider/provider-user.response"; import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; -import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; -import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; import { EmailTokenRequest } from "../auth/models/request/email-token.request"; import { EmailRequest } from "../auth/models/request/email.request"; import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request"; @@ -48,34 +46,15 @@ import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token import { PasswordHintRequest } from "../auth/models/request/password-hint.request"; import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request"; import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request"; -import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request"; -import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request"; import { UpdateProfileRequest } from "../auth/models/request/update-profile.request"; -import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request"; -import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request"; -import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request"; -import { UpdateTwoFactorWebAuthnDeleteRequest } from "../auth/models/request/update-two-factor-web-authn-delete.request"; -import { UpdateTwoFactorWebAuthnRequest } from "../auth/models/request/update-two-factor-web-authn.request"; -import { UpdateTwoFactorYubikeyOtpRequest } from "../auth/models/request/update-two-factor-yubikey-otp.request"; import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; -import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response"; import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; import { PreloginResponse } from "../auth/models/response/prelogin.response"; import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response"; -import { TwoFactorAuthenticatorResponse } from "../auth/models/response/two-factor-authenticator.response"; -import { TwoFactorDuoResponse } from "../auth/models/response/two-factor-duo.response"; -import { TwoFactorEmailResponse } from "../auth/models/response/two-factor-email.response"; -import { TwoFactorProviderResponse } from "../auth/models/response/two-factor-provider.response"; -import { TwoFactorRecoverResponse } from "../auth/models/response/two-factor-recover.response"; -import { - ChallengeResponse, - TwoFactorWebAuthnResponse, -} from "../auth/models/response/two-factor-web-authn.response"; -import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response"; import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request"; import { BillingHistoryResponse } from "../billing/models/response/billing-history.response"; import { PaymentResponse } from "../billing/models/response/payment.response"; @@ -306,66 +285,6 @@ export abstract class ApiService { abstract getSettingsDomains(): Promise; abstract putSettingsDomains(request: UpdateDomainsRequest): Promise; - abstract getTwoFactorProviders(): Promise>; - abstract getTwoFactorOrganizationProviders( - organizationId: string, - ): Promise>; - abstract getTwoFactorAuthenticator( - request: SecretVerificationRequest, - ): Promise; - abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise; - abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise; - abstract getTwoFactorOrganizationDuo( - organizationId: string, - request: SecretVerificationRequest, - ): Promise; - abstract getTwoFactorYubiKey( - request: SecretVerificationRequest, - ): Promise; - abstract getTwoFactorWebAuthn( - request: SecretVerificationRequest, - ): Promise; - abstract getTwoFactorWebAuthnChallenge( - request: SecretVerificationRequest, - ): Promise; - abstract getTwoFactorRecover( - request: SecretVerificationRequest, - ): Promise; - abstract putTwoFactorAuthenticator( - request: UpdateTwoFactorAuthenticatorRequest, - ): Promise; - abstract deleteTwoFactorAuthenticator( - request: DisableTwoFactorAuthenticatorRequest, - ): Promise; - abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise; - abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise; - abstract putTwoFactorOrganizationDuo( - organizationId: string, - request: UpdateTwoFactorDuoRequest, - ): Promise; - abstract putTwoFactorYubiKey( - request: UpdateTwoFactorYubikeyOtpRequest, - ): Promise; - abstract putTwoFactorWebAuthn( - request: UpdateTwoFactorWebAuthnRequest, - ): Promise; - abstract deleteTwoFactorWebAuthn( - request: UpdateTwoFactorWebAuthnDeleteRequest, - ): Promise; - abstract putTwoFactorDisable( - request: TwoFactorProviderRequest, - ): Promise; - abstract putTwoFactorOrganizationDisable( - organizationId: string, - request: TwoFactorProviderRequest, - ): Promise; - abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise; - abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise; - abstract getDeviceVerificationSettings(): Promise; - abstract putDeviceVerificationSettings( - request: DeviceVerificationRequest, - ): Promise; - abstract getCloudCommunicationsEnabled(): Promise; abstract getOrganizationConnection( id: string, diff --git a/libs/common/src/auth/models/request/device-verification.request.ts b/libs/common/src/auth/models/request/device-verification.request.ts deleted file mode 100644 index 5e119efa190..00000000000 --- a/libs/common/src/auth/models/request/device-verification.request.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class DeviceVerificationRequest { - unknownDeviceVerificationEnabled: boolean; - - constructor(unknownDeviceVerificationEnabled: boolean) { - this.unknownDeviceVerificationEnabled = unknownDeviceVerificationEnabled; - } -} diff --git a/libs/common/src/auth/models/response/device-verification.response.ts b/libs/common/src/auth/models/response/device-verification.response.ts deleted file mode 100644 index d703605703f..00000000000 --- a/libs/common/src/auth/models/response/device-verification.response.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BaseResponse } from "../../../models/response/base.response"; - -export class DeviceVerificationResponse extends BaseResponse { - isDeviceVerificationSectionEnabled: boolean; - unknownDeviceVerificationEnabled: boolean; - - constructor(response: any) { - super(response); - this.isDeviceVerificationSectionEnabled = this.getResponseProperty( - "IsDeviceVerificationSectionEnabled", - ); - this.unknownDeviceVerificationEnabled = this.getResponseProperty( - "UnknownDeviceVerificationEnabled", - ); - } -} diff --git a/libs/common/src/auth/two-factor/default-two-factor-api.service.ts b/libs/common/src/auth/two-factor/default-two-factor-api.service.ts new file mode 100644 index 00000000000..93f7b207922 --- /dev/null +++ b/libs/common/src/auth/two-factor/default-two-factor-api.service.ts @@ -0,0 +1,272 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; +import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; +import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; +import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; +import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; +import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request"; +import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request"; +import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request"; +import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request"; +import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request"; +import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; +import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; +import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; +import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response"; +import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response"; +import { + TwoFactorWebAuthnResponse, + ChallengeResponse, +} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; +import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { TwoFactorApiService } from "./two-factor-api.service"; + +export class DefaultTwoFactorApiService implements TwoFactorApiService { + constructor(private apiService: ApiService) {} + + // Providers + + async getTwoFactorProviders(): Promise> { + const response = await this.apiService.send("GET", "/two-factor", null, true, true); + return new ListResponse(response, TwoFactorProviderResponse); + } + + async getTwoFactorOrganizationProviders( + organizationId: string, + ): Promise> { + const response = await this.apiService.send( + "GET", + `/organizations/${organizationId}/two-factor`, + null, + true, + true, + ); + return new ListResponse(response, TwoFactorProviderResponse); + } + + // Authenticator (TOTP) + + async getTwoFactorAuthenticator( + request: SecretVerificationRequest, + ): Promise { + const response = await this.apiService.send( + "POST", + "/two-factor/get-authenticator", + request, + true, + true, + ); + return new TwoFactorAuthenticatorResponse(response); + } + + async putTwoFactorAuthenticator( + request: UpdateTwoFactorAuthenticatorRequest, + ): Promise { + const response = await this.apiService.send( + "PUT", + "/two-factor/authenticator", + request, + true, + true, + ); + return new TwoFactorAuthenticatorResponse(response); + } + + async deleteTwoFactorAuthenticator( + request: DisableTwoFactorAuthenticatorRequest, + ): Promise { + const response = await this.apiService.send( + "DELETE", + "/two-factor/authenticator", + request, + true, + true, + ); + return new TwoFactorProviderResponse(response); + } + + // Email + + async getTwoFactorEmail(request: SecretVerificationRequest): Promise { + const response = await this.apiService.send( + "POST", + "/two-factor/get-email", + request, + true, + true, + ); + return new TwoFactorEmailResponse(response); + } + + async postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise { + return this.apiService.send("POST", "/two-factor/send-email", request, true, false); + } + + async postTwoFactorEmail(request: TwoFactorEmailRequest): Promise { + return this.apiService.send("POST", "/two-factor/send-email-login", request, false, false); + } + + async putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise { + const response = await this.apiService.send("PUT", "/two-factor/email", request, true, true); + return new TwoFactorEmailResponse(response); + } + + // Duo + + async getTwoFactorDuo(request: SecretVerificationRequest): Promise { + const response = await this.apiService.send("POST", "/two-factor/get-duo", request, true, true); + return new TwoFactorDuoResponse(response); + } + + async getTwoFactorOrganizationDuo( + organizationId: string, + request: SecretVerificationRequest, + ): Promise { + const response = await this.apiService.send( + "POST", + `/organizations/${organizationId}/two-factor/get-duo`, + request, + true, + true, + ); + return new TwoFactorDuoResponse(response); + } + + async putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise { + const response = await this.apiService.send("PUT", "/two-factor/duo", request, true, true); + return new TwoFactorDuoResponse(response); + } + + async putTwoFactorOrganizationDuo( + organizationId: string, + request: UpdateTwoFactorDuoRequest, + ): Promise { + const response = await this.apiService.send( + "PUT", + `/organizations/${organizationId}/two-factor/duo`, + request, + true, + true, + ); + return new TwoFactorDuoResponse(response); + } + + // YubiKey + + async getTwoFactorYubiKey(request: SecretVerificationRequest): Promise { + const response = await this.apiService.send( + "POST", + "/two-factor/get-yubikey", + request, + true, + true, + ); + return new TwoFactorYubiKeyResponse(response); + } + + async putTwoFactorYubiKey( + request: UpdateTwoFactorYubikeyOtpRequest, + ): Promise { + const response = await this.apiService.send("PUT", "/two-factor/yubikey", request, true, true); + return new TwoFactorYubiKeyResponse(response); + } + + // WebAuthn + + async getTwoFactorWebAuthn( + request: SecretVerificationRequest, + ): Promise { + const response = await this.apiService.send( + "POST", + "/two-factor/get-webauthn", + request, + true, + true, + ); + return new TwoFactorWebAuthnResponse(response); + } + + async getTwoFactorWebAuthnChallenge( + request: SecretVerificationRequest, + ): Promise { + const response = await this.apiService.send( + "POST", + "/two-factor/get-webauthn-challenge", + request, + true, + true, + ); + return new ChallengeResponse(response); + } + + async putTwoFactorWebAuthn( + request: UpdateTwoFactorWebAuthnRequest, + ): Promise { + const deviceResponse = request.deviceResponse.response as AuthenticatorAttestationResponse; + const body: any = Object.assign({}, request); + + body.deviceResponse = { + id: request.deviceResponse.id, + rawId: btoa(request.deviceResponse.id), + type: request.deviceResponse.type, + extensions: request.deviceResponse.getClientExtensionResults(), + response: { + AttestationObject: Utils.fromBufferToB64(deviceResponse.attestationObject), + clientDataJson: Utils.fromBufferToB64(deviceResponse.clientDataJSON), + }, + }; + + const response = await this.apiService.send("PUT", "/two-factor/webauthn", body, true, true); + return new TwoFactorWebAuthnResponse(response); + } + + async deleteTwoFactorWebAuthn( + request: UpdateTwoFactorWebAuthnDeleteRequest, + ): Promise { + const response = await this.apiService.send( + "DELETE", + "/two-factor/webauthn", + request, + true, + true, + ); + return new TwoFactorWebAuthnResponse(response); + } + + // Recovery Code + + async getTwoFactorRecover(request: SecretVerificationRequest): Promise { + const response = await this.apiService.send( + "POST", + "/two-factor/get-recover", + request, + true, + true, + ); + return new TwoFactorRecoverResponse(response); + } + + // Disable + + async putTwoFactorDisable(request: TwoFactorProviderRequest): Promise { + const response = await this.apiService.send("PUT", "/two-factor/disable", request, true, true); + return new TwoFactorProviderResponse(response); + } + + async putTwoFactorOrganizationDisable( + organizationId: string, + request: TwoFactorProviderRequest, + ): Promise { + const response = await this.apiService.send( + "PUT", + `/organizations/${organizationId}/two-factor/disable`, + request, + true, + true, + ); + return new TwoFactorProviderResponse(response); + } +} diff --git a/libs/common/src/auth/two-factor/index.ts b/libs/common/src/auth/two-factor/index.ts new file mode 100644 index 00000000000..85e072403b7 --- /dev/null +++ b/libs/common/src/auth/two-factor/index.ts @@ -0,0 +1,2 @@ +export { TwoFactorApiService } from "./two-factor-api.service"; +export { DefaultTwoFactorApiService } from "./default-two-factor-api.service"; diff --git a/libs/common/src/auth/two-factor/two-factor-api.service.spec.ts b/libs/common/src/auth/two-factor/two-factor-api.service.spec.ts new file mode 100644 index 00000000000..c54790d9f76 --- /dev/null +++ b/libs/common/src/auth/two-factor/two-factor-api.service.spec.ts @@ -0,0 +1,697 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; +import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; +import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; +import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; +import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; +import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request"; +import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request"; +import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request"; +import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request"; +import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request"; +import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; +import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; +import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; +import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response"; +import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response"; +import { + TwoFactorWebAuthnResponse, + ChallengeResponse, +} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; +import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +import { DefaultTwoFactorApiService } from "./default-two-factor-api.service"; + +describe("TwoFactorApiService", () => { + let apiService: MockProxy; + let twoFactorApiService: DefaultTwoFactorApiService; + + beforeEach(() => { + apiService = mock(); + twoFactorApiService = new DefaultTwoFactorApiService(apiService); + }); + + describe("Two-Factor Providers", () => { + describe("getTwoFactorProviders", () => { + it("retrieves all enabled two-factor providers for the current user", async () => { + const mockResponse = { + data: [ + { Type: 0, Enabled: true }, + { Type: 1, Enabled: true }, + ], + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorProviders(); + + expect(apiService.send).toHaveBeenCalledWith("GET", "/two-factor", null, true, true); + expect(result).toBeInstanceOf(ListResponse); + expect(result.data).toHaveLength(2); + for (let i = 0; i < result.data.length; i++) { + expect(result.data[i]).toBeInstanceOf(TwoFactorProviderResponse); + expect(result.data[i].type).toBe(i); + expect(result.data[i].enabled).toBe(true); + } + }); + }); + + describe("getTwoFactorOrganizationProviders", () => { + it("retrieves all enabled two-factor providers for a specific organization", async () => { + const organizationId = "org-123"; + const mockResponse = { + data: [{ Type: 6, Enabled: true }], + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorOrganizationProviders(organizationId); + + expect(apiService.send).toHaveBeenCalledWith( + "GET", + `/organizations/${organizationId}/two-factor`, + null, + true, + true, + ); + expect(result).toBeInstanceOf(ListResponse); + expect(result.data[0]).toBeInstanceOf(TwoFactorProviderResponse); + expect(result.data[0].enabled).toBe(true); + expect(result.data[0].type).toBe(6); // Duo + }); + }); + }); + + describe("Authenticator (TOTP) APIs", () => { + describe("getTwoFactorAuthenticator", () => { + it("retrieves authenticator configuration with secret key after user verification", async () => { + const request = new SecretVerificationRequest(); + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: false, + Key: "MFRGGZDFMZTWQ2LK", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorAuthenticator(request); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/two-factor/get-authenticator", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorAuthenticatorResponse); + expect(result.enabled).toBe(false); + }); + }); + + describe("putTwoFactorAuthenticator", () => { + it("enables authenticator after validating the provided token", async () => { + const request = new UpdateTwoFactorAuthenticatorRequest(); + request.token = "123456"; + request.key = "MFRGGZDFMZTWQ2LK"; + const mockResponse = { + Enabled: true, + Key: "MFRGGZDFMZTWQ2LK", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.putTwoFactorAuthenticator(request); + + expect(apiService.send).toHaveBeenCalledWith( + "PUT", + "/two-factor/authenticator", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorAuthenticatorResponse); + expect(result.enabled).toBe(true); + expect(result.key).toBeDefined(); + }); + }); + + describe("deleteTwoFactorAuthenticator", () => { + it("disables authenticator two-factor authentication", async () => { + const request = new DisableTwoFactorAuthenticatorRequest(); + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: false, + Type: 0, + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.deleteTwoFactorAuthenticator(request); + + expect(apiService.send).toHaveBeenCalledWith( + "DELETE", + "/two-factor/authenticator", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorProviderResponse); + expect(result.enabled).toBe(false); + expect(result.type).toBe(0); // Authenticator + }); + }); + }); + + describe("Email APIs", () => { + describe("getTwoFactorEmail", () => { + it("retrieves email two-factor configuration after user verification", async () => { + const request = new SecretVerificationRequest(); + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: true, + Email: "user@example.com", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorEmail(request); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/two-factor/get-email", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorEmailResponse); + expect(result.enabled).toBe(true); + expect(result.email).toBeDefined(); + }); + }); + + describe("postTwoFactorEmailSetup", () => { + it("sends verification code to email address during two-factor setup", async () => { + const request = new TwoFactorEmailRequest(); + request.email = "user@example.com"; + request.masterPasswordHash = "master-password-hash"; + + await twoFactorApiService.postTwoFactorEmailSetup(request); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/two-factor/send-email", + request, + true, + false, + ); + }); + }); + + describe("postTwoFactorEmail", () => { + it("sends two-factor authentication code during login flow", async () => { + const request = new TwoFactorEmailRequest(); + request.email = "user@example.com"; + // Note: masterPasswordHash not required for login flow + + await twoFactorApiService.postTwoFactorEmail(request); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/two-factor/send-email-login", + request, + false, + false, + ); + }); + }); + + describe("putTwoFactorEmail", () => { + it("enables email two-factor after validating the verification code", async () => { + const request = new UpdateTwoFactorEmailRequest(); + request.email = "user@example.com"; + request.token = "verification-code"; + const mockResponse = { + Enabled: true, + Email: "user@example.com", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.putTwoFactorEmail(request); + + expect(apiService.send).toHaveBeenCalledWith( + "PUT", + "/two-factor/email", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorEmailResponse); + expect(result.enabled).toBe(true); + expect(result.email).toBeDefined(); + }); + }); + }); + + describe("Duo APIs", () => { + describe("getTwoFactorDuo", () => { + it("retrieves Duo configuration for premium user after verification", async () => { + const request = new SecretVerificationRequest(); + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: true, + Host: "api-abc123.duosecurity.com", + ClientId: "DI9ABC1DEFGH2JKL", + ClientSecret: "client******", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorDuo(request); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/two-factor/get-duo", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorDuoResponse); + expect(result.enabled).toBe(true); + expect(result.host).toBeDefined(); + expect(result.clientId).toBeDefined(); + expect(result.clientSecret).toContain("******"); + }); + }); + + describe("getTwoFactorOrganizationDuo", () => { + it("retrieves Duo configuration for organization with admin permissions", async () => { + const organizationId = "org-123"; + const request = new SecretVerificationRequest(); + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: true, + Host: "api-xyz789.duosecurity.com", + ClientId: "DI4XYZ9MNOP3QRS", + ClientSecret: "orgcli******", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorOrganizationDuo( + organizationId, + request, + ); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + `/organizations/${organizationId}/two-factor/get-duo`, + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorDuoResponse); + expect(result.enabled).toBe(true); + expect(result.host).toBeDefined(); + expect(result.clientId).toBeDefined(); + expect(result.clientSecret).toContain("******"); + }); + }); + + describe("putTwoFactorDuo", () => { + it("enables Duo two-factor for premium user with valid integration details", async () => { + const request = new UpdateTwoFactorDuoRequest(); + request.host = "api-abc123.duosecurity.com"; + request.clientId = "DI9ABC1DEFGH2JKL"; + request.clientSecret = "client-secret-value-here"; + const mockResponse = { + Enabled: true, + Host: "api-abc123.duosecurity.com", + ClientId: "DI9ABC1DEFGH2JKL", + ClientSecret: "client******", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.putTwoFactorDuo(request); + + expect(apiService.send).toHaveBeenCalledWith("PUT", "/two-factor/duo", request, true, true); + expect(result).toBeInstanceOf(TwoFactorDuoResponse); + expect(result.enabled).toBe(true); + expect(result.host).toBeDefined(); + expect(result.clientId).toBeDefined(); + expect(result.clientSecret).toContain("******"); + }); + }); + + describe("putTwoFactorOrganizationDuo", () => { + it("enables organization-level Duo with policy management permissions", async () => { + const organizationId = "org-123"; + const request = new UpdateTwoFactorDuoRequest(); + request.host = "api-xyz789.duosecurity.com"; + request.clientId = "DI4XYZ9MNOP3QRS"; + request.clientSecret = "orgcli-secret-value-here"; + const mockResponse = { + Enabled: true, + Host: "api-xyz789.duosecurity.com", + ClientId: "DI4XYZ9MNOP3QRS", + ClientSecret: "orgcli******", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.putTwoFactorOrganizationDuo( + organizationId, + request, + ); + + expect(apiService.send).toHaveBeenCalledWith( + "PUT", + `/organizations/${organizationId}/two-factor/duo`, + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorDuoResponse); + expect(result.enabled).toBe(true); + expect(result.host).toBeDefined(); + expect(result.clientId).toBeDefined(); + expect(result.clientSecret).toContain("******"); + }); + }); + }); + + describe("YubiKey APIs", () => { + describe("getTwoFactorYubiKey", () => { + it("retrieves YubiKey configuration for premium user after verification", async () => { + const request = new SecretVerificationRequest(); + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: true, + Key1: "cccccccccccc", + Key2: "dddddddddddd", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorYubiKey(request); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/two-factor/get-yubikey", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorYubiKeyResponse); + expect(result.enabled).toBe(true); + expect(result.key1).toBeDefined(); + expect(result.key2).toBeDefined(); + }); + }); + + describe("putTwoFactorYubiKey", () => { + it("enables YubiKey two-factor for premium user after validating device OTPs", async () => { + const request = new UpdateTwoFactorYubikeyOtpRequest(); + request.key1 = "ccccccccccccjkhbhbhrkcitringjkrjirfjuunlnlvcghnkrtgfj"; + request.key2 = "ddddddddddddvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv"; + const mockResponse = { + Enabled: true, + Key1: "cccccccccccc", + Key2: "dddddddddddd", + Nfc: false, + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.putTwoFactorYubiKey(request); + + expect(apiService.send).toHaveBeenCalledWith( + "PUT", + "/two-factor/yubikey", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorYubiKeyResponse); + expect(result.enabled).toBe(true); + expect(result.key1).toBeDefined(); + expect(result.key2).toBeDefined(); + }); + }); + }); + + describe("WebAuthn APIs", () => { + describe("getTwoFactorWebAuthn", () => { + it("retrieves list of registered WebAuthn credentials after verification", async () => { + const request = new SecretVerificationRequest(); + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: true, + Keys: [ + { Name: "YubiKey 5", Id: 1, Migrated: false }, + { Name: "Security Key", Id: 2, Migrated: true }, + ], + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorWebAuthn(request); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/two-factor/get-webauthn", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse); + expect(result.enabled).toBe(true); + expect(result.keys).toHaveLength(2); + result.keys.forEach((key) => { + expect(key).toHaveProperty("name"); + expect(key).toHaveProperty("id"); + expect(key).toHaveProperty("migrated"); + }); + }); + }); + + describe("getTwoFactorWebAuthnChallenge", () => { + it("obtains cryptographic challenge for WebAuthn credential registration", async () => { + const request = new SecretVerificationRequest(); + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + challenge: "Y2hhbGxlbmdlLXN0cmluZw", + rp: { name: "Bitwarden" }, + user: { + id: "dXNlci1pZA", + name: "user@example.com", + displayName: "User", + }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256 + excludeCredentials: [] as PublicKeyCredentialDescriptor[], + timeout: 60000, + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorWebAuthnChallenge(request); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/two-factor/get-webauthn-challenge", + request, + true, + true, + ); + expect(result).toBeInstanceOf(ChallengeResponse); + expect(result.challenge).toBeDefined(); + expect(result.rp).toHaveProperty("name", "Bitwarden"); + expect(result.user).toHaveProperty("id"); + expect(result.user).toHaveProperty("name"); + expect(result.user).toHaveProperty("displayName", "User"); + expect(result.pubKeyCredParams).toHaveLength(1); + expect(Number(result.timeout)).toBeTruthy(); + }); + }); + + describe("putTwoFactorWebAuthn", () => { + it("registers new WebAuthn credential by serializing browser credential to JSON", async () => { + const mockAttestationResponse: Partial = { + clientDataJSON: new Uint8Array([1, 2, 3]).buffer, + attestationObject: new Uint8Array([4, 5, 6]).buffer, + }; + + const mockCredential: Partial = { + id: "credential-id", + type: "public-key", + response: mockAttestationResponse as AuthenticatorAttestationResponse, + getClientExtensionResults: jest.fn().mockReturnValue({}), + }; + + const request = new UpdateTwoFactorWebAuthnRequest(); + request.deviceResponse = mockCredential as PublicKeyCredential; + request.name = "My Security Key"; + + const mockResponse = { + Enabled: true, + Keys: [{ Name: "My Security Key", Id: 1, Migrated: false }], + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.putTwoFactorWebAuthn(request); + + expect(apiService.send).toHaveBeenCalledWith( + "PUT", + "/two-factor/webauthn", + expect.objectContaining({ + name: "My Security Key", + deviceResponse: expect.objectContaining({ + id: "credential-id", + rawId: expect.any(String), // base64 encoded + type: "public-key", + extensions: {}, + response: expect.objectContaining({ + AttestationObject: expect.any(String), // base64 encoded + clientDataJson: expect.any(String), // base64 encoded + }), + }), + }), + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse); + expect(result.enabled).toBe(true); + expect(result.keys).toHaveLength(1); + expect(result.keys[0].name).toBeDefined(); + expect(result.keys[0].id).toBeDefined(); + expect(result.keys[0].migrated).toBeDefined(); + }); + + it("preserves original request object without mutation during serialization", async () => { + const mockAttestationResponse: Partial = { + clientDataJSON: new Uint8Array([1, 2, 3]).buffer, + attestationObject: new Uint8Array([4, 5, 6]).buffer, + }; + + const mockCredential: Partial = { + id: "credential-id", + type: "public-key", + response: mockAttestationResponse as AuthenticatorAttestationResponse, + getClientExtensionResults: jest.fn().mockReturnValue({}), + }; + + const request = new UpdateTwoFactorWebAuthnRequest(); + request.deviceResponse = mockCredential as PublicKeyCredential; + request.name = "My Security Key"; + + const originalDeviceResponse = request.deviceResponse; + apiService.send.mockResolvedValue({ enabled: true, keys: [] }); + + await twoFactorApiService.putTwoFactorWebAuthn(request); + + // Do not mutate the original request object + expect(request.deviceResponse).toBe(originalDeviceResponse); + expect(request.deviceResponse.response).toBe(mockAttestationResponse); + }); + }); + + describe("deleteTwoFactorWebAuthn", () => { + it("removes specific WebAuthn credential while preserving other registered keys", async () => { + const request = new UpdateTwoFactorWebAuthnDeleteRequest(); + request.id = 1; + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: true, + Keys: [{ Name: "Security Key", Id: 2, Migrated: true }], // Key with id:1 removed + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.deleteTwoFactorWebAuthn(request); + + expect(apiService.send).toHaveBeenCalledWith( + "DELETE", + "/two-factor/webauthn", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse); + expect(result.keys).toHaveLength(1); + expect(result.keys[0].id).toBe(2); + }); + }); + }); + + describe("Recovery Code APIs", () => { + describe("getTwoFactorRecover", () => { + it("retrieves recovery code for regaining access when two-factor is unavailable", async () => { + const request = new SecretVerificationRequest(); + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Code: "ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ12", + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.getTwoFactorRecover(request); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/two-factor/get-recover", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorRecoverResponse); + expect(result.code).toBeDefined(); + expect(result.code).toMatch(/^[A-Z0-9-]+$/); + }); + }); + }); + + describe("Disable APIs", () => { + describe("putTwoFactorDisable", () => { + it("disables specified two-factor provider for current user", async () => { + const request = new TwoFactorProviderRequest(); + request.type = 0; // Authenticator + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: false, + Type: 0, + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.putTwoFactorDisable(request); + + expect(apiService.send).toHaveBeenCalledWith( + "PUT", + "/two-factor/disable", + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorProviderResponse); + expect(result.enabled).toBe(false); + expect(result.type).toBe(0); // Authenticator + }); + }); + + describe("putTwoFactorOrganizationDisable", () => { + it("disables two-factor provider for organization with policy management permissions", async () => { + const organizationId = "org-123"; + const request = new TwoFactorProviderRequest(); + request.type = 6; // Duo + request.masterPasswordHash = "master-password-hash"; + const mockResponse = { + Enabled: false, + Type: 6, + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await twoFactorApiService.putTwoFactorOrganizationDisable( + organizationId, + request, + ); + + expect(apiService.send).toHaveBeenCalledWith( + "PUT", + `/organizations/${organizationId}/two-factor/disable`, + request, + true, + true, + ); + expect(result).toBeInstanceOf(TwoFactorProviderResponse); + expect(result.enabled).toBe(false); + expect(result.type).toBe(6); // Duo + }); + }); + }); +}); diff --git a/libs/common/src/auth/two-factor/two-factor-api.service.ts b/libs/common/src/auth/two-factor/two-factor-api.service.ts new file mode 100644 index 00000000000..278813c4c30 --- /dev/null +++ b/libs/common/src/auth/two-factor/two-factor-api.service.ts @@ -0,0 +1,292 @@ +import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; +import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; +import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; +import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; +import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; +import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request"; +import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request"; +import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request"; +import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request"; +import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request"; +import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; +import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; +import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; +import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response"; +import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response"; +import { + ChallengeResponse, + TwoFactorWebAuthnResponse, +} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; +import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +/** + * Service abstraction for two-factor authentication API operations. + * Provides methods for managing various two-factor authentication providers including + * authenticator apps (TOTP), email, Duo, YubiKey, WebAuthn (FIDO2), and recovery codes. + * + * All methods that retrieve sensitive configuration data require user verification via + * SecretVerificationRequest. Premium-tier providers (Duo, YubiKey) require an active + * premium subscription. Organization-level methods require appropriate administrative permissions. + */ +export abstract class TwoFactorApiService { + /** + * Gets a list of all enabled two-factor providers for the current user. + * + * @returns A promise that resolves to a list response containing enabled two-factor provider configurations. + */ + abstract getTwoFactorProviders(): Promise>; + + /** + * Gets a list of all enabled two-factor providers for an organization. + * Requires organization administrator permissions. + * + * @param organizationId The ID of the organization. + * @returns A promise that resolves to a list response containing enabled two-factor provider configurations. + */ + abstract getTwoFactorOrganizationProviders( + organizationId: string, + ): Promise>; + + /** + * Gets the authenticator (TOTP) two-factor configuration for the current user. + * Returns the shared secret key and user verification token needed for setup. + * Requires user verification via master password or OTP. + * + * @param request The secret verification request to authorize the operation. + * @returns A promise that resolves to the authenticator configuration including the secret key. + */ + abstract getTwoFactorAuthenticator( + request: SecretVerificationRequest, + ): Promise; + + /** + * Gets the email two-factor configuration for the current user. + * Returns the configured email address and enabled status. + * Requires user verification via master password or OTP. + * + * @param request The secret verification request to authorize the operation. + * @returns A promise that resolves to the email two-factor configuration. + */ + abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise; + + /** + * Gets the Duo two-factor configuration for the current user. + * Returns Duo integration configuration details. + * Requires user verification and an active premium subscription. + * + * @param request The secret verification request to authorize the operation. + * @returns A promise that resolves to the Duo configuration. + */ + abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise; + + /** + * Gets the Duo two-factor configuration for an organization. + * Returns organization-level Duo integration configuration. + * Requires user verification and organization policy management permissions. + * + * @param organizationId The ID of the organization. + * @param request The secret verification request to authorize the operation. + * @returns A promise that resolves to the organization Duo configuration. + */ + abstract getTwoFactorOrganizationDuo( + organizationId: string, + request: SecretVerificationRequest, + ): Promise; + + /** + * Gets the YubiKey OTP two-factor configuration for the current user. + * Returns configured YubiKey device identifiers (multiple keys supported). + * Requires user verification and an active premium subscription. + * + * @param request The secret verification request to authorize the operation. + * @returns A promise that resolves to the YubiKey configuration. + */ + abstract getTwoFactorYubiKey( + request: SecretVerificationRequest, + ): Promise; + + /** + * Gets the WebAuthn (FIDO2) two-factor configuration for the current user. + * Returns a list of registered WebAuthn credentials with their names and IDs. + * Requires user verification via master password or OTP. + * + * @param request The secret verification request to authorize the operation. + * @returns A promise that resolves to the WebAuthn configuration including registered credentials. + */ + abstract getTwoFactorWebAuthn( + request: SecretVerificationRequest, + ): Promise; + + /** + * Gets a WebAuthn challenge for registering a new WebAuthn credential. + * This must be called before putTwoFactorWebAuthn to obtain the cryptographic challenge + * required for credential creation. The challenge is used by the browser's WebAuthn API. + * Requires user verification via master password or OTP. + * + * @param request The secret verification request to authorize the operation. + * @returns A promise that resolves to the credential creation options containing the challenge. + */ + abstract getTwoFactorWebAuthnChallenge( + request: SecretVerificationRequest, + ): Promise; + + /** + * Gets the recovery code configuration for the current user. + * Returns the recovery code that can be used to regain access if other two-factor methods are unavailable. + * The recovery code should be stored securely by the user. + * Requires user verification via master password or OTP. + * + * @param request The secret verification request to authorize the operation. + * @returns A promise that resolves to the recovery code configuration. + */ + abstract getTwoFactorRecover( + request: SecretVerificationRequest, + ): Promise; + + /** + * Enables or updates the authenticator (TOTP) two-factor provider. + * Validates the provided token against the shared secret before enabling. + * The token must be generated by an authenticator app using the secret key. + * + * @param request The request containing the authenticator configuration and verification token. + * @returns A promise that resolves to the updated authenticator configuration. + */ + abstract putTwoFactorAuthenticator( + request: UpdateTwoFactorAuthenticatorRequest, + ): Promise; + + /** + * Disables the authenticator (TOTP) two-factor provider for the current user. + * Requires user verification token to confirm the operation. + * + * @param request The request containing verification credentials to disable the provider. + * @returns A promise that resolves to the updated provider status. + */ + abstract deleteTwoFactorAuthenticator( + request: DisableTwoFactorAuthenticatorRequest, + ): Promise; + + /** + * Enables or updates the email two-factor provider. + * Validates the email verification token sent via postTwoFactorEmailSetup before enabling. + * The token must match the code sent to the specified email address. + * + * @param request The request containing the email configuration and verification token. + * @returns A promise that resolves to the updated email two-factor configuration. + */ + abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise; + + /** + * Enables or updates the Duo two-factor provider for the current user. + * Validates the Duo configuration (client ID, client secret, and host) before enabling. + * Requires user verification and an active premium subscription. + * + * @param request The request containing the Duo integration configuration. + * @returns A promise that resolves to the updated Duo configuration. + */ + abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise; + + /** + * Enables or updates the Duo two-factor provider for an organization. + * Validates the Duo configuration (client ID, client secret, and host) before enabling. + * Requires user verification and organization policy management permissions. + * + * @param organizationId The ID of the organization. + * @param request The request containing the Duo integration configuration. + * @returns A promise that resolves to the updated organization Duo configuration. + */ + abstract putTwoFactorOrganizationDuo( + organizationId: string, + request: UpdateTwoFactorDuoRequest, + ): Promise; + + /** + * Enables or updates the YubiKey OTP two-factor provider. + * Validates each provided YubiKey by testing an OTP from the device. + * Supports up to 5 YubiKey devices. Empty key slots are allowed. + * Requires user verification and an active premium subscription. + * Includes a 2-second delay on validation failure to prevent timing attacks. + * + * @param request The request containing YubiKey device identifiers and test OTPs. + * @returns A promise that resolves to the updated YubiKey configuration. + */ + abstract putTwoFactorYubiKey( + request: UpdateTwoFactorYubikeyOtpRequest, + ): Promise; + + /** + * Registers a new WebAuthn (FIDO2) credential for two-factor authentication. + * Must be called after getTwoFactorWebAuthnChallenge to complete the registration flow. + * The device response contains the signed challenge from the authenticator device. + * Requires user verification via master password or OTP. + * + * @param request The request containing the WebAuthn credential creation response from the browser. + * @returns A promise that resolves to the updated WebAuthn configuration with the new credential. + */ + abstract putTwoFactorWebAuthn( + request: UpdateTwoFactorWebAuthnRequest, + ): Promise; + + /** + * Removes a specific WebAuthn (FIDO2) credential from the user's account. + * The credential will no longer be usable for two-factor authentication. + * Other registered WebAuthn credentials remain active. + * Requires user verification via master password or OTP. + * + * @param request The request containing the credential ID to remove. + * @returns A promise that resolves to the updated WebAuthn configuration. + */ + abstract deleteTwoFactorWebAuthn( + request: UpdateTwoFactorWebAuthnDeleteRequest, + ): Promise; + + /** + * Disables a specific two-factor provider for the current user. + * The provider will no longer be required or usable for authentication. + * Requires user verification via master password or OTP. + * + * @param request The request specifying which provider type to disable. + * @returns A promise that resolves to the updated provider status. + */ + abstract putTwoFactorDisable( + request: TwoFactorProviderRequest, + ): Promise; + + /** + * Disables a specific two-factor provider for an organization. + * The provider will no longer be available for organization members. + * Requires user verification and organization policy management permissions. + * + * @param organizationId The ID of the organization. + * @param request The request specifying which provider type to disable. + * @returns A promise that resolves to the updated provider status. + */ + abstract putTwoFactorOrganizationDisable( + organizationId: string, + request: TwoFactorProviderRequest, + ): Promise; + + /** + * Initiates email two-factor setup by sending a verification code to the specified email address. + * This is the first step in enabling email two-factor authentication. + * The verification code must be provided to putTwoFactorEmail to complete setup. + * Only used during initial configuration, not during login flows. + * Requires user verification via master password or OTP. + * + * @param request The request containing the email address for two-factor setup. + * @returns A promise that resolves when the verification email has been sent. + */ + abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise; + + /** + * Sends a two-factor authentication code via email during the login flow. + * Supports multiple authentication contexts including standard login, SSO, and passwordless flows. + * This is used to deliver codes during authentication, not during initial setup. + * May be called without authentication for login scenarios. + * + * @param request The request to send the two-factor code, optionally including SSO or auth request tokens. + * @returns A promise that resolves when the authentication email has been sent. + */ + abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise; +} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index f000f35f126..3b4fef9c5c4 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -48,8 +48,6 @@ import { import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; -import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; -import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; import { EmailTokenRequest } from "../auth/models/request/email-token.request"; import { EmailRequest } from "../auth/models/request/email.request"; import { DeviceRequest } from "../auth/models/request/identity-token/device.request"; @@ -61,34 +59,15 @@ import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token import { PasswordHintRequest } from "../auth/models/request/password-hint.request"; import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request"; import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request"; -import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request"; -import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request"; import { UpdateProfileRequest } from "../auth/models/request/update-profile.request"; -import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request"; -import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request"; -import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request"; -import { UpdateTwoFactorWebAuthnDeleteRequest } from "../auth/models/request/update-two-factor-web-authn-delete.request"; -import { UpdateTwoFactorWebAuthnRequest } from "../auth/models/request/update-two-factor-web-authn.request"; -import { UpdateTwoFactorYubikeyOtpRequest } from "../auth/models/request/update-two-factor-yubikey-otp.request"; import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; -import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response"; import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; import { PreloginResponse } from "../auth/models/response/prelogin.response"; import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response"; -import { TwoFactorAuthenticatorResponse } from "../auth/models/response/two-factor-authenticator.response"; -import { TwoFactorDuoResponse } from "../auth/models/response/two-factor-duo.response"; -import { TwoFactorEmailResponse } from "../auth/models/response/two-factor-email.response"; -import { TwoFactorProviderResponse } from "../auth/models/response/two-factor-provider.response"; -import { TwoFactorRecoverResponse } from "../auth/models/response/two-factor-recover.response"; -import { - ChallengeResponse, - TwoFactorWebAuthnResponse, -} from "../auth/models/response/two-factor-web-authn.response"; -import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response"; import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request"; import { BillingHistoryResponse } from "../billing/models/response/billing-history.response"; import { PaymentResponse } from "../billing/models/response/payment.response"; @@ -809,205 +788,6 @@ export class ApiService implements ApiServiceAbstraction { return new SyncResponse(r); } - // Two-factor APIs - - async getTwoFactorProviders(): Promise> { - const r = await this.send("GET", "/two-factor", null, true, true); - return new ListResponse(r, TwoFactorProviderResponse); - } - - async getTwoFactorOrganizationProviders( - organizationId: string, - ): Promise> { - const r = await this.send( - "GET", - "/organizations/" + organizationId + "/two-factor", - null, - true, - true, - ); - return new ListResponse(r, TwoFactorProviderResponse); - } - - async getTwoFactorAuthenticator( - request: SecretVerificationRequest, - ): Promise { - const r = await this.send("POST", "/two-factor/get-authenticator", request, true, true); - return new TwoFactorAuthenticatorResponse(r); - } - - async getTwoFactorEmail(request: SecretVerificationRequest): Promise { - const r = await this.send("POST", "/two-factor/get-email", request, true, true); - return new TwoFactorEmailResponse(r); - } - - async getTwoFactorDuo(request: SecretVerificationRequest): Promise { - const r = await this.send("POST", "/two-factor/get-duo", request, true, true); - return new TwoFactorDuoResponse(r); - } - - async getTwoFactorOrganizationDuo( - organizationId: string, - request: SecretVerificationRequest, - ): Promise { - const r = await this.send( - "POST", - "/organizations/" + organizationId + "/two-factor/get-duo", - request, - true, - true, - ); - return new TwoFactorDuoResponse(r); - } - - async getTwoFactorYubiKey(request: SecretVerificationRequest): Promise { - const r = await this.send("POST", "/two-factor/get-yubikey", request, true, true); - return new TwoFactorYubiKeyResponse(r); - } - - async getTwoFactorWebAuthn( - request: SecretVerificationRequest, - ): Promise { - const r = await this.send("POST", "/two-factor/get-webauthn", request, true, true); - return new TwoFactorWebAuthnResponse(r); - } - - async getTwoFactorWebAuthnChallenge( - request: SecretVerificationRequest, - ): Promise { - const r = await this.send("POST", "/two-factor/get-webauthn-challenge", request, true, true); - return new ChallengeResponse(r); - } - - async getTwoFactorRecover(request: SecretVerificationRequest): Promise { - const r = await this.send("POST", "/two-factor/get-recover", request, true, true); - return new TwoFactorRecoverResponse(r); - } - - async putTwoFactorAuthenticator( - request: UpdateTwoFactorAuthenticatorRequest, - ): Promise { - const r = await this.send("PUT", "/two-factor/authenticator", request, true, true); - return new TwoFactorAuthenticatorResponse(r); - } - - async deleteTwoFactorAuthenticator( - request: DisableTwoFactorAuthenticatorRequest, - ): Promise { - const r = await this.send("DELETE", "/two-factor/authenticator", request, true, true); - return new TwoFactorProviderResponse(r); - } - - async putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise { - const r = await this.send("PUT", "/two-factor/email", request, true, true); - return new TwoFactorEmailResponse(r); - } - - async putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise { - const r = await this.send("PUT", "/two-factor/duo", request, true, true); - return new TwoFactorDuoResponse(r); - } - - async putTwoFactorOrganizationDuo( - organizationId: string, - request: UpdateTwoFactorDuoRequest, - ): Promise { - const r = await this.send( - "PUT", - "/organizations/" + organizationId + "/two-factor/duo", - request, - true, - true, - ); - return new TwoFactorDuoResponse(r); - } - - async putTwoFactorYubiKey( - request: UpdateTwoFactorYubikeyOtpRequest, - ): Promise { - const r = await this.send("PUT", "/two-factor/yubikey", request, true, true); - return new TwoFactorYubiKeyResponse(r); - } - - async putTwoFactorWebAuthn( - request: UpdateTwoFactorWebAuthnRequest, - ): Promise { - const response = request.deviceResponse.response as AuthenticatorAttestationResponse; - const data: any = Object.assign({}, request); - - data.deviceResponse = { - id: request.deviceResponse.id, - rawId: btoa(request.deviceResponse.id), - type: request.deviceResponse.type, - extensions: request.deviceResponse.getClientExtensionResults(), - response: { - AttestationObject: Utils.fromBufferToB64(response.attestationObject), - clientDataJson: Utils.fromBufferToB64(response.clientDataJSON), - }, - }; - - const r = await this.send("PUT", "/two-factor/webauthn", data, true, true); - return new TwoFactorWebAuthnResponse(r); - } - - async deleteTwoFactorWebAuthn( - request: UpdateTwoFactorWebAuthnDeleteRequest, - ): Promise { - const r = await this.send("DELETE", "/two-factor/webauthn", request, true, true); - return new TwoFactorWebAuthnResponse(r); - } - - async putTwoFactorDisable(request: TwoFactorProviderRequest): Promise { - const r = await this.send("PUT", "/two-factor/disable", request, true, true); - return new TwoFactorProviderResponse(r); - } - - async putTwoFactorOrganizationDisable( - organizationId: string, - request: TwoFactorProviderRequest, - ): Promise { - const r = await this.send( - "PUT", - "/organizations/" + organizationId + "/two-factor/disable", - request, - true, - true, - ); - return new TwoFactorProviderResponse(r); - } - - postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise { - return this.send("POST", "/two-factor/send-email", request, true, false); - } - - postTwoFactorEmail(request: TwoFactorEmailRequest): Promise { - return this.send("POST", "/two-factor/send-email-login", request, false, false); - } - - async getDeviceVerificationSettings(): Promise { - const r = await this.send( - "GET", - "/two-factor/get-device-verification-settings", - null, - true, - true, - ); - return new DeviceVerificationResponse(r); - } - - async putDeviceVerificationSettings( - request: DeviceVerificationRequest, - ): Promise { - const r = await this.send( - "PUT", - "/two-factor/device-verification-settings", - request, - true, - true, - ); - return new DeviceVerificationResponse(r); - } - // Organization APIs async getCloudCommunicationsEnabled(): Promise { From d082d336e7b6bebd792035a470fd58dc51bfafe0 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:22:49 -0400 Subject: [PATCH 12/37] =?UTF-8?q?Revert=20"feat(two-factor-api-service)=20?= =?UTF-8?q?[PM-26465]:=20(Refactor)=20Two-Factor=20API=20Se=E2=80=A6"=20(#?= =?UTF-8?q?16856)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 886003ba88a51b9672f478894de5a6b1da16b140. --- apps/cli/src/auth/commands/login.command.ts | 6 +- apps/cli/src/program.ts | 2 +- .../service-container/service-container.ts | 4 - .../settings/two-factor-setup.component.ts | 8 +- .../account/change-email.component.spec.ts | 6 +- .../account/change-email.component.ts | 4 +- ...account-verify-devices-dialog.component.ts | 6 +- ...wo-factor-setup-authenticator.component.ts | 10 +- .../two-factor-setup-duo.component.ts | 13 +- .../two-factor-setup-email.component.ts | 10 +- .../two-factor-setup-method-base.component.ts | 15 +- .../two-factor-setup-webauthn.component.ts | 12 +- .../two-factor-setup-yubikey.component.ts | 8 +- .../two-factor/two-factor-setup.component.ts | 6 +- .../two-factor/two-factor-verify.component.ts | 18 +- .../src/services/jslib-services.module.ts | 6 - .../two-factor-auth-email.component.ts | 6 +- libs/common/src/abstractions/api.service.ts | 81 ++ .../request/device-verification.request.ts | 7 + .../response/device-verification.response.ts | 16 + .../default-two-factor-api.service.ts | 272 ------- libs/common/src/auth/two-factor/index.ts | 2 - .../two-factor/two-factor-api.service.spec.ts | 697 ------------------ .../auth/two-factor/two-factor-api.service.ts | 292 -------- libs/common/src/services/api.service.ts | 220 ++++++ 25 files changed, 383 insertions(+), 1344 deletions(-) create mode 100644 libs/common/src/auth/models/request/device-verification.request.ts create mode 100644 libs/common/src/auth/models/response/device-verification.response.ts delete mode 100644 libs/common/src/auth/two-factor/default-two-factor-api.service.ts delete mode 100644 libs/common/src/auth/two-factor/index.ts delete mode 100644 libs/common/src/auth/two-factor/two-factor-api.service.spec.ts delete mode 100644 libs/common/src/auth/two-factor/two-factor-api.service.ts diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index e1a3d123441..7e5058dcff0 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -14,6 +14,7 @@ import { SsoUrlService, UserApiLoginCredentials, } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -28,7 +29,6 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; @@ -62,7 +62,7 @@ export class LoginCommand { constructor( protected loginStrategyService: LoginStrategyServiceAbstraction, protected authService: AuthService, - protected twoFactorApiService: TwoFactorApiService, + protected apiService: ApiService, protected masterPasswordApiService: MasterPasswordApiService, protected cryptoFunctionService: CryptoFunctionService, protected environmentService: EnvironmentService, @@ -279,7 +279,7 @@ export class LoginCommand { const emailReq = new TwoFactorEmailRequest(); emailReq.email = await this.loginStrategyService.getEmail(); emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); - await this.twoFactorApiService.postTwoFactorEmail(emailReq); + await this.apiService.postTwoFactorEmail(emailReq); } if (twoFactorToken == null) { diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index e8c9bff746e..8f202bc0845 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -175,7 +175,7 @@ export class Program extends BaseProgram { const command = new LoginCommand( this.serviceContainer.loginStrategyService, this.serviceContainer.authService, - this.serviceContainer.twoFactorApiService, + this.serviceContainer.apiService, this.serviceContainer.masterPasswordApiService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.environmentService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 7c4b708576b..d13d251bce0 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -49,7 +49,6 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; 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 { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AutofillSettingsService, AutofillSettingsServiceAbstraction, @@ -229,7 +228,6 @@ export class ServiceContainer { tokenService: TokenService; appIdService: AppIdService; apiService: NodeApiService; - twoFactorApiService: TwoFactorApiService; hibpApiService: HibpApiService; environmentService: EnvironmentService; cipherService: CipherService; @@ -530,8 +528,6 @@ export class ServiceContainer { this.configApiService = new ConfigApiService(this.apiService); - this.twoFactorApiService = new DefaultTwoFactorApiService(this.apiService); - this.authService = new AuthService( this.accountService, this.messagingService, diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts index 3151e0a702f..020a16dd932 100644 --- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts @@ -5,6 +5,7 @@ import { ActivatedRoute } from "@angular/router"; import { concatMap, takeUntil, map, lastValueFrom, firstValueFrom } from "rxjs"; import { first, tap } from "rxjs/operators"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { getOrganizationById, OrganizationService, @@ -14,7 +15,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -35,7 +35,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme tabbedHeader = false; constructor( dialogService: DialogService, - twoFactorApiService: TwoFactorApiService, + apiService: ApiService, messagingService: MessagingService, policyService: PolicyService, private route: ActivatedRoute, @@ -47,7 +47,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme ) { super( dialogService, - twoFactorApiService, + apiService, messagingService, policyService, billingAccountProfileStateService, @@ -116,7 +116,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme } protected getTwoFactorProviders() { - return this.twoFactorApiService.getTwoFactorOrganizationProviders(this.organizationId); + return this.apiService.getTwoFactorOrganizationProviders(this.organizationId); } protected filterProvider(type: TwoFactorProviderType): boolean { diff --git a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts index 934de0f6453..bd0d9df9f06 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts @@ -7,7 +7,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -23,14 +22,12 @@ describe("ChangeEmailComponent", () => { let fixture: ComponentFixture; let apiService: MockProxy; - let twoFactorApiService: MockProxy; let accountService: FakeAccountService; let keyService: MockProxy; let kdfConfigService: MockProxy; beforeEach(async () => { apiService = mock(); - twoFactorApiService = mock(); keyService = mock(); kdfConfigService = mock(); accountService = mockAccountServiceWith("UserId" as UserId); @@ -40,7 +37,6 @@ describe("ChangeEmailComponent", () => { providers: [ { provide: AccountService, useValue: accountService }, { provide: ApiService, useValue: apiService }, - { provide: TwoFactorApiService, useValue: twoFactorApiService }, { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: KeyService, useValue: keyService }, { provide: MessagingService, useValue: mock() }, @@ -61,7 +57,7 @@ describe("ChangeEmailComponent", () => { describe("ngOnInit", () => { beforeEach(() => { - twoFactorApiService.getTwoFactorProviders.mockResolvedValue({ + apiService.getTwoFactorProviders.mockResolvedValue({ data: [{ type: TwoFactorProviderType.Email, enabled: true } as TwoFactorProviderResponse], } as ListResponse); }); diff --git a/apps/web/src/app/auth/settings/account/change-email.component.ts b/apps/web/src/app/auth/settings/account/change-email.component.ts index b6ca39c6413..a55846a5c0f 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.ts @@ -8,7 +8,6 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p import { EmailTokenRequest } from "@bitwarden/common/auth/models/request/email-token.request"; import { EmailRequest } from "@bitwarden/common/auth/models/request/email.request"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -38,7 +37,6 @@ export class ChangeEmailComponent implements OnInit { constructor( private accountService: AccountService, private apiService: ApiService, - private twoFactorApiService: TwoFactorApiService, private i18nService: I18nService, private keyService: KeyService, private messagingService: MessagingService, @@ -50,7 +48,7 @@ export class ChangeEmailComponent implements OnInit { async ngOnInit() { this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - const twoFactorProviders = await this.twoFactorApiService.getTwoFactorProviders(); + const twoFactorProviders = await this.apiService.getTwoFactorProviders(); this.showTwoFactorEmailWarning = twoFactorProviders.data.some( (p) => p.type === TwoFactorProviderType.Email && p.enabled, ); diff --git a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts index c66f31f6c3b..63a26f08eee 100644 --- a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts @@ -5,11 +5,11 @@ import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { SetVerifyDevicesRequest } from "@bitwarden/common/auth/models/request/set-verify-devices.request"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -64,7 +64,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy private userVerificationService: UserVerificationService, private dialogRef: DialogRef, private toastService: ToastService, - private twoFactorApiService: TwoFactorApiService, + private apiService: ApiService, ) { this.accountService.accountVerifyNewDeviceLogin$ .pipe(takeUntil(this.destroy$)) @@ -74,7 +74,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy } async ngOnInit() { - const twoFactorProviders = await this.twoFactorApiService.getTwoFactorProviders(); + const twoFactorProviders = await this.apiService.getTwoFactorProviders(); this.has2faConfigured = twoFactorProviders.data.length > 0; } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts index d57d6eca894..698e0911b04 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts @@ -6,13 +6,13 @@ import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angu import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -92,7 +92,7 @@ export class TwoFactorSetupAuthenticatorComponent constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, private dialogRef: DialogRef, - twoFactorApiService: TwoFactorApiService, + apiService: ApiService, i18nService: I18nService, userVerificationService: UserVerificationService, private formBuilder: FormBuilder, @@ -104,7 +104,7 @@ export class TwoFactorSetupAuthenticatorComponent protected toastService: ToastService, ) { super( - twoFactorApiService, + apiService, i18nService, platformUtilsService, logService, @@ -154,7 +154,7 @@ export class TwoFactorSetupAuthenticatorComponent request.key = this.key; request.userVerificationToken = this.userVerificationToken; - const response = await this.twoFactorApiService.putTwoFactorAuthenticator(request); + const response = await this.apiService.putTwoFactorAuthenticator(request); await this.processResponse(response); this.onUpdated.emit(true); } @@ -174,7 +174,7 @@ export class TwoFactorSetupAuthenticatorComponent request.type = this.type; request.key = this.key; request.userVerificationToken = this.userVerificationToken; - await this.twoFactorApiService.deleteTwoFactorAuthenticator(request); + await this.apiService.deleteTwoFactorAuthenticator(request); this.enabled = false; this.toastService.showToast({ variant: "success", diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts index bf820e32917..0efd0c79b4e 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts @@ -2,11 +2,11 @@ import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request"; import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -63,7 +63,7 @@ export class TwoFactorSetupDuoComponent constructor( @Inject(DIALOG_DATA) protected data: TwoFactorDuoComponentConfig, - twoFactorApiService: TwoFactorApiService, + apiService: ApiService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, logService: LogService, @@ -74,7 +74,7 @@ export class TwoFactorSetupDuoComponent protected toastService: ToastService, ) { super( - twoFactorApiService, + apiService, i18nService, platformUtilsService, logService, @@ -139,12 +139,9 @@ export class TwoFactorSetupDuoComponent let response: TwoFactorDuoResponse; if (this.organizationId != null) { - response = await this.twoFactorApiService.putTwoFactorOrganizationDuo( - this.organizationId, - request, - ); + response = await this.apiService.putTwoFactorOrganizationDuo(this.organizationId, request); } else { - response = await this.twoFactorApiService.putTwoFactorDuo(request); + response = await this.apiService.putTwoFactorDuo(request); } this.processResponse(response); diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts index 138d541d551..544f3850ea6 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts @@ -3,13 +3,13 @@ import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { firstValueFrom, map } from "rxjs"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request"; import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -66,7 +66,7 @@ export class TwoFactorSetupEmailComponent constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, - twoFactorApiService: TwoFactorApiService, + apiService: ApiService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, logService: LogService, @@ -78,7 +78,7 @@ export class TwoFactorSetupEmailComponent protected toastService: ToastService, ) { super( - twoFactorApiService, + apiService, i18nService, platformUtilsService, logService, @@ -131,7 +131,7 @@ export class TwoFactorSetupEmailComponent sendEmail = async () => { const request = await this.buildRequestModel(TwoFactorEmailRequest); request.email = this.email; - this.emailPromise = this.twoFactorApiService.postTwoFactorEmailSetup(request); + this.emailPromise = this.apiService.postTwoFactorEmailSetup(request); await this.emailPromise; this.sentEmail = this.email; }; @@ -141,7 +141,7 @@ export class TwoFactorSetupEmailComponent request.email = this.email; request.token = this.token; - const response = await this.twoFactorApiService.putTwoFactorEmail(request); + const response = await this.apiService.putTwoFactorEmail(request); await this.processResponse(response); this.onUpdated.emit(true); } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts index aa3b9e1def3..7569577e781 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts @@ -1,11 +1,11 @@ import { Directive, EventEmitter, Output } from "@angular/core"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponseBase } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -30,7 +30,7 @@ export abstract class TwoFactorSetupMethodBaseComponent { protected componentName = ""; constructor( - protected twoFactorApiService: TwoFactorApiService, + protected apiService: ApiService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, protected logService: LogService, @@ -77,12 +77,9 @@ export abstract class TwoFactorSetupMethodBaseComponent { } request.type = this.type; if (this.organizationId != null) { - promise = this.twoFactorApiService.putTwoFactorOrganizationDisable( - this.organizationId, - request, - ); + promise = this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request); } else { - promise = this.twoFactorApiService.putTwoFactorDisable(request); + promise = this.apiService.putTwoFactorDisable(request); } await promise; this.enabled = false; @@ -114,9 +111,9 @@ export abstract class TwoFactorSetupMethodBaseComponent { } request.type = this.type; if (this.organizationId != null) { - await this.twoFactorApiService.putTwoFactorOrganizationDisable(this.organizationId, request); + await this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request); } else { - await this.twoFactorApiService.putTwoFactorDisable(request); + await this.apiService.putTwoFactorDisable(request); } this.enabled = false; this.toastService.showToast({ diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts index ff0e971461e..66cd3596063 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts @@ -3,6 +3,7 @@ import { Component, Inject, NgZone } from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; @@ -12,7 +13,6 @@ import { ChallengeResponse, TwoFactorWebAuthnResponse, } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -79,7 +79,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, private dialogRef: DialogRef, - twoFactorApiService: TwoFactorApiService, + apiService: ApiService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, private ngZone: NgZone, @@ -89,7 +89,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom toastService: ToastService, ) { super( - twoFactorApiService, + apiService, i18nService, platformUtilsService, logService, @@ -127,7 +127,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom request.id = this.keyIdAvailable; request.name = this.formGroup.value.name || ""; - const response = await this.twoFactorApiService.putTwoFactorWebAuthn(request); + const response = await this.apiService.putTwoFactorWebAuthn(request); this.processResponse(response); this.toastService.showToast({ title: this.i18nService.t("success"), @@ -163,7 +163,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom const request = await this.buildRequestModel(UpdateTwoFactorWebAuthnDeleteRequest); request.id = key.id; try { - key.removePromise = this.twoFactorApiService.deleteTwoFactorWebAuthn(request); + key.removePromise = this.apiService.deleteTwoFactorWebAuthn(request); const response = await key.removePromise; key.removePromise = null; await this.processResponse(response); @@ -177,7 +177,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom return; } const request = await this.buildRequestModel(SecretVerificationRequest); - this.challengePromise = this.twoFactorApiService.getTwoFactorWebAuthnChallenge(request); + this.challengePromise = this.apiService.getTwoFactorWebAuthnChallenge(request); const challenge = await this.challengePromise; this.readDevice(challenge); }; diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts index 4e4691a5f60..0b85d219928 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts @@ -9,11 +9,11 @@ import { } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request"; import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -93,7 +93,7 @@ export class TwoFactorSetupYubiKeyComponent constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, - twoFactorApiService: TwoFactorApiService, + apiService: ApiService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, logService: LogService, @@ -103,7 +103,7 @@ export class TwoFactorSetupYubiKeyComponent protected toastService: ToastService, ) { super( - twoFactorApiService, + apiService, i18nService, platformUtilsService, logService, @@ -176,7 +176,7 @@ export class TwoFactorSetupYubiKeyComponent request.key5 = keys != null && keys.length > 4 ? (keys[4]?.key ?? "") : ""; request.nfc = this.formGroup.value.anyKeyHasNfc ?? false; - this.processResponse(await this.twoFactorApiService.putTwoFactorYubiKey(request)); + this.processResponse(await this.apiService.putTwoFactorYubiKey(request)); this.refreshFormArrayData(); this.toastService.showToast({ title: this.i18nService.t("success"), diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index c3a55ad661e..043c27998cd 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -13,6 +13,7 @@ import { } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -25,7 +26,6 @@ import { TwoFactorWebAuthnResponse } from "@bitwarden/common/auth/models/respons import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; @@ -68,7 +68,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { constructor( protected dialogService: DialogService, - protected twoFactorApiService: TwoFactorApiService, + protected apiService: ApiService, protected messagingService: MessagingService, protected policyService: PolicyService, billingAccountProfileStateService: BillingAccountProfileStateService, @@ -270,7 +270,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { } protected getTwoFactorProviders() { - return this.twoFactorApiService.getTwoFactorProviders(); + return this.apiService.getTwoFactorProviders(); } protected filterProvider(type: TwoFactorProviderType): boolean { diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts index a2c734ed2d5..07939db7eff 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts @@ -2,11 +2,11 @@ import { Component, EventEmitter, Inject, Output } from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { TwoFactorResponse } from "@bitwarden/common/auth/types/two-factor-response"; import { Verification } from "@bitwarden/common/auth/types/verification"; @@ -55,7 +55,7 @@ export class TwoFactorVerifyComponent { constructor( @Inject(DIALOG_DATA) protected data: TwoFactorVerifyDialogData, private dialogRef: DialogRef, - private twoFactorApiService: TwoFactorApiService, + private apiService: ApiService, private i18nService: I18nService, private userVerificationService: UserVerificationService, ) { @@ -116,22 +116,22 @@ export class TwoFactorVerifyComponent { private apiCall(request: SecretVerificationRequest): Promise { switch (this.type) { case -1 as TwoFactorProviderType: - return this.twoFactorApiService.getTwoFactorRecover(request); + return this.apiService.getTwoFactorRecover(request); case TwoFactorProviderType.Duo: case TwoFactorProviderType.OrganizationDuo: if (this.organizationId != null) { - return this.twoFactorApiService.getTwoFactorOrganizationDuo(this.organizationId, request); + return this.apiService.getTwoFactorOrganizationDuo(this.organizationId, request); } else { - return this.twoFactorApiService.getTwoFactorDuo(request); + return this.apiService.getTwoFactorDuo(request); } case TwoFactorProviderType.Email: - return this.twoFactorApiService.getTwoFactorEmail(request); + return this.apiService.getTwoFactorEmail(request); case TwoFactorProviderType.WebAuthn: - return this.twoFactorApiService.getTwoFactorWebAuthn(request); + return this.apiService.getTwoFactorWebAuthn(request); case TwoFactorProviderType.Authenticator: - return this.twoFactorApiService.getTwoFactorAuthenticator(request); + return this.apiService.getTwoFactorAuthenticator(request); case TwoFactorProviderType.Yubikey: - return this.twoFactorApiService.getTwoFactorYubiKey(request); + return this.apiService.getTwoFactorYubiKey(request); default: throw new Error(`Unknown two-factor type: ${this.type}`); } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 3f9657b8115..c66c74a3ea9 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -127,7 +127,6 @@ import { UserVerificationService } from "@bitwarden/common/auth/services/user-ve import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service"; import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service"; import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service"; -import { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AutofillSettingsService, AutofillSettingsServiceAbstraction, @@ -1520,11 +1519,6 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultTwoFactorAuthWebAuthnComponentService, deps: [], }), - safeProvider({ - provide: TwoFactorApiService, - useClass: DefaultTwoFactorApiService, - deps: [ApiServiceAbstraction], - }), safeProvider({ provide: ViewCacheService, useExisting: NoopViewCacheService, diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts index 084e8e6e851..9b402f3a956 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts @@ -4,10 +4,10 @@ import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; -import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -62,7 +62,7 @@ export class TwoFactorAuthEmailComponent implements OnInit { protected loginStrategyService: LoginStrategyServiceAbstraction, protected platformUtilsService: PlatformUtilsService, protected logService: LogService, - protected twoFactorApiService: TwoFactorApiService, + protected apiService: ApiService, protected appIdService: AppIdService, private toastService: ToastService, private cacheService: TwoFactorAuthEmailComponentCacheService, @@ -131,7 +131,7 @@ export class TwoFactorAuthEmailComponent implements OnInit { request.deviceIdentifier = await this.appIdService.getAppId(); request.authRequestAccessCode = (await this.loginStrategyService.getAccessCode()) ?? ""; request.authRequestId = (await this.loginStrategyService.getAuthRequestId()) ?? ""; - this.emailPromise = this.twoFactorApiService.postTwoFactorEmail(request); + this.emailPromise = this.apiService.postTwoFactorEmail(request); await this.emailPromise; this.emailSent = true; diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 93e47a6d9a8..1cab48148e9 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -37,6 +37,8 @@ import { ProviderUserUserDetailsResponse, } from "../admin-console/models/response/provider/provider-user.response"; import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; +import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; +import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; import { EmailTokenRequest } from "../auth/models/request/email-token.request"; import { EmailRequest } from "../auth/models/request/email.request"; import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request"; @@ -46,15 +48,34 @@ import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token import { PasswordHintRequest } from "../auth/models/request/password-hint.request"; import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request"; import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request"; +import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request"; +import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request"; import { UpdateProfileRequest } from "../auth/models/request/update-profile.request"; +import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request"; +import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request"; +import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request"; +import { UpdateTwoFactorWebAuthnDeleteRequest } from "../auth/models/request/update-two-factor-web-authn-delete.request"; +import { UpdateTwoFactorWebAuthnRequest } from "../auth/models/request/update-two-factor-web-authn.request"; +import { UpdateTwoFactorYubikeyOtpRequest } from "../auth/models/request/update-two-factor-yubikey-otp.request"; import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; +import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response"; import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; import { PreloginResponse } from "../auth/models/response/prelogin.response"; import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response"; +import { TwoFactorAuthenticatorResponse } from "../auth/models/response/two-factor-authenticator.response"; +import { TwoFactorDuoResponse } from "../auth/models/response/two-factor-duo.response"; +import { TwoFactorEmailResponse } from "../auth/models/response/two-factor-email.response"; +import { TwoFactorProviderResponse } from "../auth/models/response/two-factor-provider.response"; +import { TwoFactorRecoverResponse } from "../auth/models/response/two-factor-recover.response"; +import { + ChallengeResponse, + TwoFactorWebAuthnResponse, +} from "../auth/models/response/two-factor-web-authn.response"; +import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response"; import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request"; import { BillingHistoryResponse } from "../billing/models/response/billing-history.response"; import { PaymentResponse } from "../billing/models/response/payment.response"; @@ -285,6 +306,66 @@ export abstract class ApiService { abstract getSettingsDomains(): Promise; abstract putSettingsDomains(request: UpdateDomainsRequest): Promise; + abstract getTwoFactorProviders(): Promise>; + abstract getTwoFactorOrganizationProviders( + organizationId: string, + ): Promise>; + abstract getTwoFactorAuthenticator( + request: SecretVerificationRequest, + ): Promise; + abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise; + abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise; + abstract getTwoFactorOrganizationDuo( + organizationId: string, + request: SecretVerificationRequest, + ): Promise; + abstract getTwoFactorYubiKey( + request: SecretVerificationRequest, + ): Promise; + abstract getTwoFactorWebAuthn( + request: SecretVerificationRequest, + ): Promise; + abstract getTwoFactorWebAuthnChallenge( + request: SecretVerificationRequest, + ): Promise; + abstract getTwoFactorRecover( + request: SecretVerificationRequest, + ): Promise; + abstract putTwoFactorAuthenticator( + request: UpdateTwoFactorAuthenticatorRequest, + ): Promise; + abstract deleteTwoFactorAuthenticator( + request: DisableTwoFactorAuthenticatorRequest, + ): Promise; + abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise; + abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise; + abstract putTwoFactorOrganizationDuo( + organizationId: string, + request: UpdateTwoFactorDuoRequest, + ): Promise; + abstract putTwoFactorYubiKey( + request: UpdateTwoFactorYubikeyOtpRequest, + ): Promise; + abstract putTwoFactorWebAuthn( + request: UpdateTwoFactorWebAuthnRequest, + ): Promise; + abstract deleteTwoFactorWebAuthn( + request: UpdateTwoFactorWebAuthnDeleteRequest, + ): Promise; + abstract putTwoFactorDisable( + request: TwoFactorProviderRequest, + ): Promise; + abstract putTwoFactorOrganizationDisable( + organizationId: string, + request: TwoFactorProviderRequest, + ): Promise; + abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise; + abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise; + abstract getDeviceVerificationSettings(): Promise; + abstract putDeviceVerificationSettings( + request: DeviceVerificationRequest, + ): Promise; + abstract getCloudCommunicationsEnabled(): Promise; abstract getOrganizationConnection( id: string, diff --git a/libs/common/src/auth/models/request/device-verification.request.ts b/libs/common/src/auth/models/request/device-verification.request.ts new file mode 100644 index 00000000000..5e119efa190 --- /dev/null +++ b/libs/common/src/auth/models/request/device-verification.request.ts @@ -0,0 +1,7 @@ +export class DeviceVerificationRequest { + unknownDeviceVerificationEnabled: boolean; + + constructor(unknownDeviceVerificationEnabled: boolean) { + this.unknownDeviceVerificationEnabled = unknownDeviceVerificationEnabled; + } +} diff --git a/libs/common/src/auth/models/response/device-verification.response.ts b/libs/common/src/auth/models/response/device-verification.response.ts new file mode 100644 index 00000000000..d703605703f --- /dev/null +++ b/libs/common/src/auth/models/response/device-verification.response.ts @@ -0,0 +1,16 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +export class DeviceVerificationResponse extends BaseResponse { + isDeviceVerificationSectionEnabled: boolean; + unknownDeviceVerificationEnabled: boolean; + + constructor(response: any) { + super(response); + this.isDeviceVerificationSectionEnabled = this.getResponseProperty( + "IsDeviceVerificationSectionEnabled", + ); + this.unknownDeviceVerificationEnabled = this.getResponseProperty( + "UnknownDeviceVerificationEnabled", + ); + } +} diff --git a/libs/common/src/auth/two-factor/default-two-factor-api.service.ts b/libs/common/src/auth/two-factor/default-two-factor-api.service.ts deleted file mode 100644 index 93f7b207922..00000000000 --- a/libs/common/src/auth/two-factor/default-two-factor-api.service.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; -import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; -import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; -import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; -import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; -import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request"; -import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request"; -import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request"; -import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request"; -import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request"; -import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; -import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; -import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; -import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response"; -import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response"; -import { - TwoFactorWebAuthnResponse, - ChallengeResponse, -} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; -import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; -import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; - -import { TwoFactorApiService } from "./two-factor-api.service"; - -export class DefaultTwoFactorApiService implements TwoFactorApiService { - constructor(private apiService: ApiService) {} - - // Providers - - async getTwoFactorProviders(): Promise> { - const response = await this.apiService.send("GET", "/two-factor", null, true, true); - return new ListResponse(response, TwoFactorProviderResponse); - } - - async getTwoFactorOrganizationProviders( - organizationId: string, - ): Promise> { - const response = await this.apiService.send( - "GET", - `/organizations/${organizationId}/two-factor`, - null, - true, - true, - ); - return new ListResponse(response, TwoFactorProviderResponse); - } - - // Authenticator (TOTP) - - async getTwoFactorAuthenticator( - request: SecretVerificationRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - "/two-factor/get-authenticator", - request, - true, - true, - ); - return new TwoFactorAuthenticatorResponse(response); - } - - async putTwoFactorAuthenticator( - request: UpdateTwoFactorAuthenticatorRequest, - ): Promise { - const response = await this.apiService.send( - "PUT", - "/two-factor/authenticator", - request, - true, - true, - ); - return new TwoFactorAuthenticatorResponse(response); - } - - async deleteTwoFactorAuthenticator( - request: DisableTwoFactorAuthenticatorRequest, - ): Promise { - const response = await this.apiService.send( - "DELETE", - "/two-factor/authenticator", - request, - true, - true, - ); - return new TwoFactorProviderResponse(response); - } - - // Email - - async getTwoFactorEmail(request: SecretVerificationRequest): Promise { - const response = await this.apiService.send( - "POST", - "/two-factor/get-email", - request, - true, - true, - ); - return new TwoFactorEmailResponse(response); - } - - async postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise { - return this.apiService.send("POST", "/two-factor/send-email", request, true, false); - } - - async postTwoFactorEmail(request: TwoFactorEmailRequest): Promise { - return this.apiService.send("POST", "/two-factor/send-email-login", request, false, false); - } - - async putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise { - const response = await this.apiService.send("PUT", "/two-factor/email", request, true, true); - return new TwoFactorEmailResponse(response); - } - - // Duo - - async getTwoFactorDuo(request: SecretVerificationRequest): Promise { - const response = await this.apiService.send("POST", "/two-factor/get-duo", request, true, true); - return new TwoFactorDuoResponse(response); - } - - async getTwoFactorOrganizationDuo( - organizationId: string, - request: SecretVerificationRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - `/organizations/${organizationId}/two-factor/get-duo`, - request, - true, - true, - ); - return new TwoFactorDuoResponse(response); - } - - async putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise { - const response = await this.apiService.send("PUT", "/two-factor/duo", request, true, true); - return new TwoFactorDuoResponse(response); - } - - async putTwoFactorOrganizationDuo( - organizationId: string, - request: UpdateTwoFactorDuoRequest, - ): Promise { - const response = await this.apiService.send( - "PUT", - `/organizations/${organizationId}/two-factor/duo`, - request, - true, - true, - ); - return new TwoFactorDuoResponse(response); - } - - // YubiKey - - async getTwoFactorYubiKey(request: SecretVerificationRequest): Promise { - const response = await this.apiService.send( - "POST", - "/two-factor/get-yubikey", - request, - true, - true, - ); - return new TwoFactorYubiKeyResponse(response); - } - - async putTwoFactorYubiKey( - request: UpdateTwoFactorYubikeyOtpRequest, - ): Promise { - const response = await this.apiService.send("PUT", "/two-factor/yubikey", request, true, true); - return new TwoFactorYubiKeyResponse(response); - } - - // WebAuthn - - async getTwoFactorWebAuthn( - request: SecretVerificationRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - "/two-factor/get-webauthn", - request, - true, - true, - ); - return new TwoFactorWebAuthnResponse(response); - } - - async getTwoFactorWebAuthnChallenge( - request: SecretVerificationRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - "/two-factor/get-webauthn-challenge", - request, - true, - true, - ); - return new ChallengeResponse(response); - } - - async putTwoFactorWebAuthn( - request: UpdateTwoFactorWebAuthnRequest, - ): Promise { - const deviceResponse = request.deviceResponse.response as AuthenticatorAttestationResponse; - const body: any = Object.assign({}, request); - - body.deviceResponse = { - id: request.deviceResponse.id, - rawId: btoa(request.deviceResponse.id), - type: request.deviceResponse.type, - extensions: request.deviceResponse.getClientExtensionResults(), - response: { - AttestationObject: Utils.fromBufferToB64(deviceResponse.attestationObject), - clientDataJson: Utils.fromBufferToB64(deviceResponse.clientDataJSON), - }, - }; - - const response = await this.apiService.send("PUT", "/two-factor/webauthn", body, true, true); - return new TwoFactorWebAuthnResponse(response); - } - - async deleteTwoFactorWebAuthn( - request: UpdateTwoFactorWebAuthnDeleteRequest, - ): Promise { - const response = await this.apiService.send( - "DELETE", - "/two-factor/webauthn", - request, - true, - true, - ); - return new TwoFactorWebAuthnResponse(response); - } - - // Recovery Code - - async getTwoFactorRecover(request: SecretVerificationRequest): Promise { - const response = await this.apiService.send( - "POST", - "/two-factor/get-recover", - request, - true, - true, - ); - return new TwoFactorRecoverResponse(response); - } - - // Disable - - async putTwoFactorDisable(request: TwoFactorProviderRequest): Promise { - const response = await this.apiService.send("PUT", "/two-factor/disable", request, true, true); - return new TwoFactorProviderResponse(response); - } - - async putTwoFactorOrganizationDisable( - organizationId: string, - request: TwoFactorProviderRequest, - ): Promise { - const response = await this.apiService.send( - "PUT", - `/organizations/${organizationId}/two-factor/disable`, - request, - true, - true, - ); - return new TwoFactorProviderResponse(response); - } -} diff --git a/libs/common/src/auth/two-factor/index.ts b/libs/common/src/auth/two-factor/index.ts deleted file mode 100644 index 85e072403b7..00000000000 --- a/libs/common/src/auth/two-factor/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { TwoFactorApiService } from "./two-factor-api.service"; -export { DefaultTwoFactorApiService } from "./default-two-factor-api.service"; diff --git a/libs/common/src/auth/two-factor/two-factor-api.service.spec.ts b/libs/common/src/auth/two-factor/two-factor-api.service.spec.ts deleted file mode 100644 index c54790d9f76..00000000000 --- a/libs/common/src/auth/two-factor/two-factor-api.service.spec.ts +++ /dev/null @@ -1,697 +0,0 @@ -import { mock, MockProxy } from "jest-mock-extended"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; -import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; -import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; -import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; -import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; -import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request"; -import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request"; -import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request"; -import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request"; -import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request"; -import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; -import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; -import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; -import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response"; -import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response"; -import { - TwoFactorWebAuthnResponse, - ChallengeResponse, -} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; -import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; -import { ListResponse } from "@bitwarden/common/models/response/list.response"; - -import { DefaultTwoFactorApiService } from "./default-two-factor-api.service"; - -describe("TwoFactorApiService", () => { - let apiService: MockProxy; - let twoFactorApiService: DefaultTwoFactorApiService; - - beforeEach(() => { - apiService = mock(); - twoFactorApiService = new DefaultTwoFactorApiService(apiService); - }); - - describe("Two-Factor Providers", () => { - describe("getTwoFactorProviders", () => { - it("retrieves all enabled two-factor providers for the current user", async () => { - const mockResponse = { - data: [ - { Type: 0, Enabled: true }, - { Type: 1, Enabled: true }, - ], - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorProviders(); - - expect(apiService.send).toHaveBeenCalledWith("GET", "/two-factor", null, true, true); - expect(result).toBeInstanceOf(ListResponse); - expect(result.data).toHaveLength(2); - for (let i = 0; i < result.data.length; i++) { - expect(result.data[i]).toBeInstanceOf(TwoFactorProviderResponse); - expect(result.data[i].type).toBe(i); - expect(result.data[i].enabled).toBe(true); - } - }); - }); - - describe("getTwoFactorOrganizationProviders", () => { - it("retrieves all enabled two-factor providers for a specific organization", async () => { - const organizationId = "org-123"; - const mockResponse = { - data: [{ Type: 6, Enabled: true }], - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorOrganizationProviders(organizationId); - - expect(apiService.send).toHaveBeenCalledWith( - "GET", - `/organizations/${organizationId}/two-factor`, - null, - true, - true, - ); - expect(result).toBeInstanceOf(ListResponse); - expect(result.data[0]).toBeInstanceOf(TwoFactorProviderResponse); - expect(result.data[0].enabled).toBe(true); - expect(result.data[0].type).toBe(6); // Duo - }); - }); - }); - - describe("Authenticator (TOTP) APIs", () => { - describe("getTwoFactorAuthenticator", () => { - it("retrieves authenticator configuration with secret key after user verification", async () => { - const request = new SecretVerificationRequest(); - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: false, - Key: "MFRGGZDFMZTWQ2LK", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorAuthenticator(request); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - "/two-factor/get-authenticator", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorAuthenticatorResponse); - expect(result.enabled).toBe(false); - }); - }); - - describe("putTwoFactorAuthenticator", () => { - it("enables authenticator after validating the provided token", async () => { - const request = new UpdateTwoFactorAuthenticatorRequest(); - request.token = "123456"; - request.key = "MFRGGZDFMZTWQ2LK"; - const mockResponse = { - Enabled: true, - Key: "MFRGGZDFMZTWQ2LK", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.putTwoFactorAuthenticator(request); - - expect(apiService.send).toHaveBeenCalledWith( - "PUT", - "/two-factor/authenticator", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorAuthenticatorResponse); - expect(result.enabled).toBe(true); - expect(result.key).toBeDefined(); - }); - }); - - describe("deleteTwoFactorAuthenticator", () => { - it("disables authenticator two-factor authentication", async () => { - const request = new DisableTwoFactorAuthenticatorRequest(); - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: false, - Type: 0, - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.deleteTwoFactorAuthenticator(request); - - expect(apiService.send).toHaveBeenCalledWith( - "DELETE", - "/two-factor/authenticator", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorProviderResponse); - expect(result.enabled).toBe(false); - expect(result.type).toBe(0); // Authenticator - }); - }); - }); - - describe("Email APIs", () => { - describe("getTwoFactorEmail", () => { - it("retrieves email two-factor configuration after user verification", async () => { - const request = new SecretVerificationRequest(); - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: true, - Email: "user@example.com", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorEmail(request); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - "/two-factor/get-email", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorEmailResponse); - expect(result.enabled).toBe(true); - expect(result.email).toBeDefined(); - }); - }); - - describe("postTwoFactorEmailSetup", () => { - it("sends verification code to email address during two-factor setup", async () => { - const request = new TwoFactorEmailRequest(); - request.email = "user@example.com"; - request.masterPasswordHash = "master-password-hash"; - - await twoFactorApiService.postTwoFactorEmailSetup(request); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - "/two-factor/send-email", - request, - true, - false, - ); - }); - }); - - describe("postTwoFactorEmail", () => { - it("sends two-factor authentication code during login flow", async () => { - const request = new TwoFactorEmailRequest(); - request.email = "user@example.com"; - // Note: masterPasswordHash not required for login flow - - await twoFactorApiService.postTwoFactorEmail(request); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - "/two-factor/send-email-login", - request, - false, - false, - ); - }); - }); - - describe("putTwoFactorEmail", () => { - it("enables email two-factor after validating the verification code", async () => { - const request = new UpdateTwoFactorEmailRequest(); - request.email = "user@example.com"; - request.token = "verification-code"; - const mockResponse = { - Enabled: true, - Email: "user@example.com", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.putTwoFactorEmail(request); - - expect(apiService.send).toHaveBeenCalledWith( - "PUT", - "/two-factor/email", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorEmailResponse); - expect(result.enabled).toBe(true); - expect(result.email).toBeDefined(); - }); - }); - }); - - describe("Duo APIs", () => { - describe("getTwoFactorDuo", () => { - it("retrieves Duo configuration for premium user after verification", async () => { - const request = new SecretVerificationRequest(); - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: true, - Host: "api-abc123.duosecurity.com", - ClientId: "DI9ABC1DEFGH2JKL", - ClientSecret: "client******", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorDuo(request); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - "/two-factor/get-duo", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorDuoResponse); - expect(result.enabled).toBe(true); - expect(result.host).toBeDefined(); - expect(result.clientId).toBeDefined(); - expect(result.clientSecret).toContain("******"); - }); - }); - - describe("getTwoFactorOrganizationDuo", () => { - it("retrieves Duo configuration for organization with admin permissions", async () => { - const organizationId = "org-123"; - const request = new SecretVerificationRequest(); - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: true, - Host: "api-xyz789.duosecurity.com", - ClientId: "DI4XYZ9MNOP3QRS", - ClientSecret: "orgcli******", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorOrganizationDuo( - organizationId, - request, - ); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - `/organizations/${organizationId}/two-factor/get-duo`, - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorDuoResponse); - expect(result.enabled).toBe(true); - expect(result.host).toBeDefined(); - expect(result.clientId).toBeDefined(); - expect(result.clientSecret).toContain("******"); - }); - }); - - describe("putTwoFactorDuo", () => { - it("enables Duo two-factor for premium user with valid integration details", async () => { - const request = new UpdateTwoFactorDuoRequest(); - request.host = "api-abc123.duosecurity.com"; - request.clientId = "DI9ABC1DEFGH2JKL"; - request.clientSecret = "client-secret-value-here"; - const mockResponse = { - Enabled: true, - Host: "api-abc123.duosecurity.com", - ClientId: "DI9ABC1DEFGH2JKL", - ClientSecret: "client******", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.putTwoFactorDuo(request); - - expect(apiService.send).toHaveBeenCalledWith("PUT", "/two-factor/duo", request, true, true); - expect(result).toBeInstanceOf(TwoFactorDuoResponse); - expect(result.enabled).toBe(true); - expect(result.host).toBeDefined(); - expect(result.clientId).toBeDefined(); - expect(result.clientSecret).toContain("******"); - }); - }); - - describe("putTwoFactorOrganizationDuo", () => { - it("enables organization-level Duo with policy management permissions", async () => { - const organizationId = "org-123"; - const request = new UpdateTwoFactorDuoRequest(); - request.host = "api-xyz789.duosecurity.com"; - request.clientId = "DI4XYZ9MNOP3QRS"; - request.clientSecret = "orgcli-secret-value-here"; - const mockResponse = { - Enabled: true, - Host: "api-xyz789.duosecurity.com", - ClientId: "DI4XYZ9MNOP3QRS", - ClientSecret: "orgcli******", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.putTwoFactorOrganizationDuo( - organizationId, - request, - ); - - expect(apiService.send).toHaveBeenCalledWith( - "PUT", - `/organizations/${organizationId}/two-factor/duo`, - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorDuoResponse); - expect(result.enabled).toBe(true); - expect(result.host).toBeDefined(); - expect(result.clientId).toBeDefined(); - expect(result.clientSecret).toContain("******"); - }); - }); - }); - - describe("YubiKey APIs", () => { - describe("getTwoFactorYubiKey", () => { - it("retrieves YubiKey configuration for premium user after verification", async () => { - const request = new SecretVerificationRequest(); - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: true, - Key1: "cccccccccccc", - Key2: "dddddddddddd", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorYubiKey(request); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - "/two-factor/get-yubikey", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorYubiKeyResponse); - expect(result.enabled).toBe(true); - expect(result.key1).toBeDefined(); - expect(result.key2).toBeDefined(); - }); - }); - - describe("putTwoFactorYubiKey", () => { - it("enables YubiKey two-factor for premium user after validating device OTPs", async () => { - const request = new UpdateTwoFactorYubikeyOtpRequest(); - request.key1 = "ccccccccccccjkhbhbhrkcitringjkrjirfjuunlnlvcghnkrtgfj"; - request.key2 = "ddddddddddddvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv"; - const mockResponse = { - Enabled: true, - Key1: "cccccccccccc", - Key2: "dddddddddddd", - Nfc: false, - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.putTwoFactorYubiKey(request); - - expect(apiService.send).toHaveBeenCalledWith( - "PUT", - "/two-factor/yubikey", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorYubiKeyResponse); - expect(result.enabled).toBe(true); - expect(result.key1).toBeDefined(); - expect(result.key2).toBeDefined(); - }); - }); - }); - - describe("WebAuthn APIs", () => { - describe("getTwoFactorWebAuthn", () => { - it("retrieves list of registered WebAuthn credentials after verification", async () => { - const request = new SecretVerificationRequest(); - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: true, - Keys: [ - { Name: "YubiKey 5", Id: 1, Migrated: false }, - { Name: "Security Key", Id: 2, Migrated: true }, - ], - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorWebAuthn(request); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - "/two-factor/get-webauthn", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse); - expect(result.enabled).toBe(true); - expect(result.keys).toHaveLength(2); - result.keys.forEach((key) => { - expect(key).toHaveProperty("name"); - expect(key).toHaveProperty("id"); - expect(key).toHaveProperty("migrated"); - }); - }); - }); - - describe("getTwoFactorWebAuthnChallenge", () => { - it("obtains cryptographic challenge for WebAuthn credential registration", async () => { - const request = new SecretVerificationRequest(); - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - challenge: "Y2hhbGxlbmdlLXN0cmluZw", - rp: { name: "Bitwarden" }, - user: { - id: "dXNlci1pZA", - name: "user@example.com", - displayName: "User", - }, - pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256 - excludeCredentials: [] as PublicKeyCredentialDescriptor[], - timeout: 60000, - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorWebAuthnChallenge(request); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - "/two-factor/get-webauthn-challenge", - request, - true, - true, - ); - expect(result).toBeInstanceOf(ChallengeResponse); - expect(result.challenge).toBeDefined(); - expect(result.rp).toHaveProperty("name", "Bitwarden"); - expect(result.user).toHaveProperty("id"); - expect(result.user).toHaveProperty("name"); - expect(result.user).toHaveProperty("displayName", "User"); - expect(result.pubKeyCredParams).toHaveLength(1); - expect(Number(result.timeout)).toBeTruthy(); - }); - }); - - describe("putTwoFactorWebAuthn", () => { - it("registers new WebAuthn credential by serializing browser credential to JSON", async () => { - const mockAttestationResponse: Partial = { - clientDataJSON: new Uint8Array([1, 2, 3]).buffer, - attestationObject: new Uint8Array([4, 5, 6]).buffer, - }; - - const mockCredential: Partial = { - id: "credential-id", - type: "public-key", - response: mockAttestationResponse as AuthenticatorAttestationResponse, - getClientExtensionResults: jest.fn().mockReturnValue({}), - }; - - const request = new UpdateTwoFactorWebAuthnRequest(); - request.deviceResponse = mockCredential as PublicKeyCredential; - request.name = "My Security Key"; - - const mockResponse = { - Enabled: true, - Keys: [{ Name: "My Security Key", Id: 1, Migrated: false }], - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.putTwoFactorWebAuthn(request); - - expect(apiService.send).toHaveBeenCalledWith( - "PUT", - "/two-factor/webauthn", - expect.objectContaining({ - name: "My Security Key", - deviceResponse: expect.objectContaining({ - id: "credential-id", - rawId: expect.any(String), // base64 encoded - type: "public-key", - extensions: {}, - response: expect.objectContaining({ - AttestationObject: expect.any(String), // base64 encoded - clientDataJson: expect.any(String), // base64 encoded - }), - }), - }), - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse); - expect(result.enabled).toBe(true); - expect(result.keys).toHaveLength(1); - expect(result.keys[0].name).toBeDefined(); - expect(result.keys[0].id).toBeDefined(); - expect(result.keys[0].migrated).toBeDefined(); - }); - - it("preserves original request object without mutation during serialization", async () => { - const mockAttestationResponse: Partial = { - clientDataJSON: new Uint8Array([1, 2, 3]).buffer, - attestationObject: new Uint8Array([4, 5, 6]).buffer, - }; - - const mockCredential: Partial = { - id: "credential-id", - type: "public-key", - response: mockAttestationResponse as AuthenticatorAttestationResponse, - getClientExtensionResults: jest.fn().mockReturnValue({}), - }; - - const request = new UpdateTwoFactorWebAuthnRequest(); - request.deviceResponse = mockCredential as PublicKeyCredential; - request.name = "My Security Key"; - - const originalDeviceResponse = request.deviceResponse; - apiService.send.mockResolvedValue({ enabled: true, keys: [] }); - - await twoFactorApiService.putTwoFactorWebAuthn(request); - - // Do not mutate the original request object - expect(request.deviceResponse).toBe(originalDeviceResponse); - expect(request.deviceResponse.response).toBe(mockAttestationResponse); - }); - }); - - describe("deleteTwoFactorWebAuthn", () => { - it("removes specific WebAuthn credential while preserving other registered keys", async () => { - const request = new UpdateTwoFactorWebAuthnDeleteRequest(); - request.id = 1; - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: true, - Keys: [{ Name: "Security Key", Id: 2, Migrated: true }], // Key with id:1 removed - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.deleteTwoFactorWebAuthn(request); - - expect(apiService.send).toHaveBeenCalledWith( - "DELETE", - "/two-factor/webauthn", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse); - expect(result.keys).toHaveLength(1); - expect(result.keys[0].id).toBe(2); - }); - }); - }); - - describe("Recovery Code APIs", () => { - describe("getTwoFactorRecover", () => { - it("retrieves recovery code for regaining access when two-factor is unavailable", async () => { - const request = new SecretVerificationRequest(); - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Code: "ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ12", - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.getTwoFactorRecover(request); - - expect(apiService.send).toHaveBeenCalledWith( - "POST", - "/two-factor/get-recover", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorRecoverResponse); - expect(result.code).toBeDefined(); - expect(result.code).toMatch(/^[A-Z0-9-]+$/); - }); - }); - }); - - describe("Disable APIs", () => { - describe("putTwoFactorDisable", () => { - it("disables specified two-factor provider for current user", async () => { - const request = new TwoFactorProviderRequest(); - request.type = 0; // Authenticator - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: false, - Type: 0, - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.putTwoFactorDisable(request); - - expect(apiService.send).toHaveBeenCalledWith( - "PUT", - "/two-factor/disable", - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorProviderResponse); - expect(result.enabled).toBe(false); - expect(result.type).toBe(0); // Authenticator - }); - }); - - describe("putTwoFactorOrganizationDisable", () => { - it("disables two-factor provider for organization with policy management permissions", async () => { - const organizationId = "org-123"; - const request = new TwoFactorProviderRequest(); - request.type = 6; // Duo - request.masterPasswordHash = "master-password-hash"; - const mockResponse = { - Enabled: false, - Type: 6, - }; - apiService.send.mockResolvedValue(mockResponse); - - const result = await twoFactorApiService.putTwoFactorOrganizationDisable( - organizationId, - request, - ); - - expect(apiService.send).toHaveBeenCalledWith( - "PUT", - `/organizations/${organizationId}/two-factor/disable`, - request, - true, - true, - ); - expect(result).toBeInstanceOf(TwoFactorProviderResponse); - expect(result.enabled).toBe(false); - expect(result.type).toBe(6); // Duo - }); - }); - }); -}); diff --git a/libs/common/src/auth/two-factor/two-factor-api.service.ts b/libs/common/src/auth/two-factor/two-factor-api.service.ts deleted file mode 100644 index 278813c4c30..00000000000 --- a/libs/common/src/auth/two-factor/two-factor-api.service.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; -import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; -import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; -import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; -import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; -import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request"; -import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request"; -import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request"; -import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request"; -import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request"; -import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; -import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; -import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; -import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response"; -import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response"; -import { - ChallengeResponse, - TwoFactorWebAuthnResponse, -} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; -import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; -import { ListResponse } from "@bitwarden/common/models/response/list.response"; - -/** - * Service abstraction for two-factor authentication API operations. - * Provides methods for managing various two-factor authentication providers including - * authenticator apps (TOTP), email, Duo, YubiKey, WebAuthn (FIDO2), and recovery codes. - * - * All methods that retrieve sensitive configuration data require user verification via - * SecretVerificationRequest. Premium-tier providers (Duo, YubiKey) require an active - * premium subscription. Organization-level methods require appropriate administrative permissions. - */ -export abstract class TwoFactorApiService { - /** - * Gets a list of all enabled two-factor providers for the current user. - * - * @returns A promise that resolves to a list response containing enabled two-factor provider configurations. - */ - abstract getTwoFactorProviders(): Promise>; - - /** - * Gets a list of all enabled two-factor providers for an organization. - * Requires organization administrator permissions. - * - * @param organizationId The ID of the organization. - * @returns A promise that resolves to a list response containing enabled two-factor provider configurations. - */ - abstract getTwoFactorOrganizationProviders( - organizationId: string, - ): Promise>; - - /** - * Gets the authenticator (TOTP) two-factor configuration for the current user. - * Returns the shared secret key and user verification token needed for setup. - * Requires user verification via master password or OTP. - * - * @param request The secret verification request to authorize the operation. - * @returns A promise that resolves to the authenticator configuration including the secret key. - */ - abstract getTwoFactorAuthenticator( - request: SecretVerificationRequest, - ): Promise; - - /** - * Gets the email two-factor configuration for the current user. - * Returns the configured email address and enabled status. - * Requires user verification via master password or OTP. - * - * @param request The secret verification request to authorize the operation. - * @returns A promise that resolves to the email two-factor configuration. - */ - abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise; - - /** - * Gets the Duo two-factor configuration for the current user. - * Returns Duo integration configuration details. - * Requires user verification and an active premium subscription. - * - * @param request The secret verification request to authorize the operation. - * @returns A promise that resolves to the Duo configuration. - */ - abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise; - - /** - * Gets the Duo two-factor configuration for an organization. - * Returns organization-level Duo integration configuration. - * Requires user verification and organization policy management permissions. - * - * @param organizationId The ID of the organization. - * @param request The secret verification request to authorize the operation. - * @returns A promise that resolves to the organization Duo configuration. - */ - abstract getTwoFactorOrganizationDuo( - organizationId: string, - request: SecretVerificationRequest, - ): Promise; - - /** - * Gets the YubiKey OTP two-factor configuration for the current user. - * Returns configured YubiKey device identifiers (multiple keys supported). - * Requires user verification and an active premium subscription. - * - * @param request The secret verification request to authorize the operation. - * @returns A promise that resolves to the YubiKey configuration. - */ - abstract getTwoFactorYubiKey( - request: SecretVerificationRequest, - ): Promise; - - /** - * Gets the WebAuthn (FIDO2) two-factor configuration for the current user. - * Returns a list of registered WebAuthn credentials with their names and IDs. - * Requires user verification via master password or OTP. - * - * @param request The secret verification request to authorize the operation. - * @returns A promise that resolves to the WebAuthn configuration including registered credentials. - */ - abstract getTwoFactorWebAuthn( - request: SecretVerificationRequest, - ): Promise; - - /** - * Gets a WebAuthn challenge for registering a new WebAuthn credential. - * This must be called before putTwoFactorWebAuthn to obtain the cryptographic challenge - * required for credential creation. The challenge is used by the browser's WebAuthn API. - * Requires user verification via master password or OTP. - * - * @param request The secret verification request to authorize the operation. - * @returns A promise that resolves to the credential creation options containing the challenge. - */ - abstract getTwoFactorWebAuthnChallenge( - request: SecretVerificationRequest, - ): Promise; - - /** - * Gets the recovery code configuration for the current user. - * Returns the recovery code that can be used to regain access if other two-factor methods are unavailable. - * The recovery code should be stored securely by the user. - * Requires user verification via master password or OTP. - * - * @param request The secret verification request to authorize the operation. - * @returns A promise that resolves to the recovery code configuration. - */ - abstract getTwoFactorRecover( - request: SecretVerificationRequest, - ): Promise; - - /** - * Enables or updates the authenticator (TOTP) two-factor provider. - * Validates the provided token against the shared secret before enabling. - * The token must be generated by an authenticator app using the secret key. - * - * @param request The request containing the authenticator configuration and verification token. - * @returns A promise that resolves to the updated authenticator configuration. - */ - abstract putTwoFactorAuthenticator( - request: UpdateTwoFactorAuthenticatorRequest, - ): Promise; - - /** - * Disables the authenticator (TOTP) two-factor provider for the current user. - * Requires user verification token to confirm the operation. - * - * @param request The request containing verification credentials to disable the provider. - * @returns A promise that resolves to the updated provider status. - */ - abstract deleteTwoFactorAuthenticator( - request: DisableTwoFactorAuthenticatorRequest, - ): Promise; - - /** - * Enables or updates the email two-factor provider. - * Validates the email verification token sent via postTwoFactorEmailSetup before enabling. - * The token must match the code sent to the specified email address. - * - * @param request The request containing the email configuration and verification token. - * @returns A promise that resolves to the updated email two-factor configuration. - */ - abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise; - - /** - * Enables or updates the Duo two-factor provider for the current user. - * Validates the Duo configuration (client ID, client secret, and host) before enabling. - * Requires user verification and an active premium subscription. - * - * @param request The request containing the Duo integration configuration. - * @returns A promise that resolves to the updated Duo configuration. - */ - abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise; - - /** - * Enables or updates the Duo two-factor provider for an organization. - * Validates the Duo configuration (client ID, client secret, and host) before enabling. - * Requires user verification and organization policy management permissions. - * - * @param organizationId The ID of the organization. - * @param request The request containing the Duo integration configuration. - * @returns A promise that resolves to the updated organization Duo configuration. - */ - abstract putTwoFactorOrganizationDuo( - organizationId: string, - request: UpdateTwoFactorDuoRequest, - ): Promise; - - /** - * Enables or updates the YubiKey OTP two-factor provider. - * Validates each provided YubiKey by testing an OTP from the device. - * Supports up to 5 YubiKey devices. Empty key slots are allowed. - * Requires user verification and an active premium subscription. - * Includes a 2-second delay on validation failure to prevent timing attacks. - * - * @param request The request containing YubiKey device identifiers and test OTPs. - * @returns A promise that resolves to the updated YubiKey configuration. - */ - abstract putTwoFactorYubiKey( - request: UpdateTwoFactorYubikeyOtpRequest, - ): Promise; - - /** - * Registers a new WebAuthn (FIDO2) credential for two-factor authentication. - * Must be called after getTwoFactorWebAuthnChallenge to complete the registration flow. - * The device response contains the signed challenge from the authenticator device. - * Requires user verification via master password or OTP. - * - * @param request The request containing the WebAuthn credential creation response from the browser. - * @returns A promise that resolves to the updated WebAuthn configuration with the new credential. - */ - abstract putTwoFactorWebAuthn( - request: UpdateTwoFactorWebAuthnRequest, - ): Promise; - - /** - * Removes a specific WebAuthn (FIDO2) credential from the user's account. - * The credential will no longer be usable for two-factor authentication. - * Other registered WebAuthn credentials remain active. - * Requires user verification via master password or OTP. - * - * @param request The request containing the credential ID to remove. - * @returns A promise that resolves to the updated WebAuthn configuration. - */ - abstract deleteTwoFactorWebAuthn( - request: UpdateTwoFactorWebAuthnDeleteRequest, - ): Promise; - - /** - * Disables a specific two-factor provider for the current user. - * The provider will no longer be required or usable for authentication. - * Requires user verification via master password or OTP. - * - * @param request The request specifying which provider type to disable. - * @returns A promise that resolves to the updated provider status. - */ - abstract putTwoFactorDisable( - request: TwoFactorProviderRequest, - ): Promise; - - /** - * Disables a specific two-factor provider for an organization. - * The provider will no longer be available for organization members. - * Requires user verification and organization policy management permissions. - * - * @param organizationId The ID of the organization. - * @param request The request specifying which provider type to disable. - * @returns A promise that resolves to the updated provider status. - */ - abstract putTwoFactorOrganizationDisable( - organizationId: string, - request: TwoFactorProviderRequest, - ): Promise; - - /** - * Initiates email two-factor setup by sending a verification code to the specified email address. - * This is the first step in enabling email two-factor authentication. - * The verification code must be provided to putTwoFactorEmail to complete setup. - * Only used during initial configuration, not during login flows. - * Requires user verification via master password or OTP. - * - * @param request The request containing the email address for two-factor setup. - * @returns A promise that resolves when the verification email has been sent. - */ - abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise; - - /** - * Sends a two-factor authentication code via email during the login flow. - * Supports multiple authentication contexts including standard login, SSO, and passwordless flows. - * This is used to deliver codes during authentication, not during initial setup. - * May be called without authentication for login scenarios. - * - * @param request The request to send the two-factor code, optionally including SSO or auth request tokens. - * @returns A promise that resolves when the authentication email has been sent. - */ - abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise; -} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 3b4fef9c5c4..f000f35f126 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -48,6 +48,8 @@ import { import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; +import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; +import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; import { EmailTokenRequest } from "../auth/models/request/email-token.request"; import { EmailRequest } from "../auth/models/request/email.request"; import { DeviceRequest } from "../auth/models/request/identity-token/device.request"; @@ -59,15 +61,34 @@ import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token import { PasswordHintRequest } from "../auth/models/request/password-hint.request"; import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request"; import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request"; +import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request"; +import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request"; import { UpdateProfileRequest } from "../auth/models/request/update-profile.request"; +import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request"; +import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request"; +import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request"; +import { UpdateTwoFactorWebAuthnDeleteRequest } from "../auth/models/request/update-two-factor-web-authn-delete.request"; +import { UpdateTwoFactorWebAuthnRequest } from "../auth/models/request/update-two-factor-web-authn.request"; +import { UpdateTwoFactorYubikeyOtpRequest } from "../auth/models/request/update-two-factor-yubikey-otp.request"; import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; +import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response"; import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; import { PreloginResponse } from "../auth/models/response/prelogin.response"; import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response"; +import { TwoFactorAuthenticatorResponse } from "../auth/models/response/two-factor-authenticator.response"; +import { TwoFactorDuoResponse } from "../auth/models/response/two-factor-duo.response"; +import { TwoFactorEmailResponse } from "../auth/models/response/two-factor-email.response"; +import { TwoFactorProviderResponse } from "../auth/models/response/two-factor-provider.response"; +import { TwoFactorRecoverResponse } from "../auth/models/response/two-factor-recover.response"; +import { + ChallengeResponse, + TwoFactorWebAuthnResponse, +} from "../auth/models/response/two-factor-web-authn.response"; +import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response"; import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request"; import { BillingHistoryResponse } from "../billing/models/response/billing-history.response"; import { PaymentResponse } from "../billing/models/response/payment.response"; @@ -788,6 +809,205 @@ export class ApiService implements ApiServiceAbstraction { return new SyncResponse(r); } + // Two-factor APIs + + async getTwoFactorProviders(): Promise> { + const r = await this.send("GET", "/two-factor", null, true, true); + return new ListResponse(r, TwoFactorProviderResponse); + } + + async getTwoFactorOrganizationProviders( + organizationId: string, + ): Promise> { + const r = await this.send( + "GET", + "/organizations/" + organizationId + "/two-factor", + null, + true, + true, + ); + return new ListResponse(r, TwoFactorProviderResponse); + } + + async getTwoFactorAuthenticator( + request: SecretVerificationRequest, + ): Promise { + const r = await this.send("POST", "/two-factor/get-authenticator", request, true, true); + return new TwoFactorAuthenticatorResponse(r); + } + + async getTwoFactorEmail(request: SecretVerificationRequest): Promise { + const r = await this.send("POST", "/two-factor/get-email", request, true, true); + return new TwoFactorEmailResponse(r); + } + + async getTwoFactorDuo(request: SecretVerificationRequest): Promise { + const r = await this.send("POST", "/two-factor/get-duo", request, true, true); + return new TwoFactorDuoResponse(r); + } + + async getTwoFactorOrganizationDuo( + organizationId: string, + request: SecretVerificationRequest, + ): Promise { + const r = await this.send( + "POST", + "/organizations/" + organizationId + "/two-factor/get-duo", + request, + true, + true, + ); + return new TwoFactorDuoResponse(r); + } + + async getTwoFactorYubiKey(request: SecretVerificationRequest): Promise { + const r = await this.send("POST", "/two-factor/get-yubikey", request, true, true); + return new TwoFactorYubiKeyResponse(r); + } + + async getTwoFactorWebAuthn( + request: SecretVerificationRequest, + ): Promise { + const r = await this.send("POST", "/two-factor/get-webauthn", request, true, true); + return new TwoFactorWebAuthnResponse(r); + } + + async getTwoFactorWebAuthnChallenge( + request: SecretVerificationRequest, + ): Promise { + const r = await this.send("POST", "/two-factor/get-webauthn-challenge", request, true, true); + return new ChallengeResponse(r); + } + + async getTwoFactorRecover(request: SecretVerificationRequest): Promise { + const r = await this.send("POST", "/two-factor/get-recover", request, true, true); + return new TwoFactorRecoverResponse(r); + } + + async putTwoFactorAuthenticator( + request: UpdateTwoFactorAuthenticatorRequest, + ): Promise { + const r = await this.send("PUT", "/two-factor/authenticator", request, true, true); + return new TwoFactorAuthenticatorResponse(r); + } + + async deleteTwoFactorAuthenticator( + request: DisableTwoFactorAuthenticatorRequest, + ): Promise { + const r = await this.send("DELETE", "/two-factor/authenticator", request, true, true); + return new TwoFactorProviderResponse(r); + } + + async putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise { + const r = await this.send("PUT", "/two-factor/email", request, true, true); + return new TwoFactorEmailResponse(r); + } + + async putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise { + const r = await this.send("PUT", "/two-factor/duo", request, true, true); + return new TwoFactorDuoResponse(r); + } + + async putTwoFactorOrganizationDuo( + organizationId: string, + request: UpdateTwoFactorDuoRequest, + ): Promise { + const r = await this.send( + "PUT", + "/organizations/" + organizationId + "/two-factor/duo", + request, + true, + true, + ); + return new TwoFactorDuoResponse(r); + } + + async putTwoFactorYubiKey( + request: UpdateTwoFactorYubikeyOtpRequest, + ): Promise { + const r = await this.send("PUT", "/two-factor/yubikey", request, true, true); + return new TwoFactorYubiKeyResponse(r); + } + + async putTwoFactorWebAuthn( + request: UpdateTwoFactorWebAuthnRequest, + ): Promise { + const response = request.deviceResponse.response as AuthenticatorAttestationResponse; + const data: any = Object.assign({}, request); + + data.deviceResponse = { + id: request.deviceResponse.id, + rawId: btoa(request.deviceResponse.id), + type: request.deviceResponse.type, + extensions: request.deviceResponse.getClientExtensionResults(), + response: { + AttestationObject: Utils.fromBufferToB64(response.attestationObject), + clientDataJson: Utils.fromBufferToB64(response.clientDataJSON), + }, + }; + + const r = await this.send("PUT", "/two-factor/webauthn", data, true, true); + return new TwoFactorWebAuthnResponse(r); + } + + async deleteTwoFactorWebAuthn( + request: UpdateTwoFactorWebAuthnDeleteRequest, + ): Promise { + const r = await this.send("DELETE", "/two-factor/webauthn", request, true, true); + return new TwoFactorWebAuthnResponse(r); + } + + async putTwoFactorDisable(request: TwoFactorProviderRequest): Promise { + const r = await this.send("PUT", "/two-factor/disable", request, true, true); + return new TwoFactorProviderResponse(r); + } + + async putTwoFactorOrganizationDisable( + organizationId: string, + request: TwoFactorProviderRequest, + ): Promise { + const r = await this.send( + "PUT", + "/organizations/" + organizationId + "/two-factor/disable", + request, + true, + true, + ); + return new TwoFactorProviderResponse(r); + } + + postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise { + return this.send("POST", "/two-factor/send-email", request, true, false); + } + + postTwoFactorEmail(request: TwoFactorEmailRequest): Promise { + return this.send("POST", "/two-factor/send-email-login", request, false, false); + } + + async getDeviceVerificationSettings(): Promise { + const r = await this.send( + "GET", + "/two-factor/get-device-verification-settings", + null, + true, + true, + ); + return new DeviceVerificationResponse(r); + } + + async putDeviceVerificationSettings( + request: DeviceVerificationRequest, + ): Promise { + const r = await this.send( + "PUT", + "/two-factor/device-verification-settings", + request, + true, + true, + ); + return new DeviceVerificationResponse(r); + } + // Organization APIs async getCloudCommunicationsEnabled(): Promise { From a063099c61e26f0fbfcc1e3f6522391b0698605d Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Mon, 13 Oct 2025 15:37:31 -0400 Subject: [PATCH 13/37] BRE-1178 - Add Desktop Beta app for the Microsoft Store (#16691) --- .github/workflows/build-desktop.yml | 233 ++++++++++++++++++++++++ apps/desktop/electron-builder.beta.json | 132 ++++++++++++++ apps/desktop/package.json | 1 + apps/desktop/sign.js | 2 +- apps/desktop/src/utils.ts | 3 +- 5 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/electron-builder.beta.json diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 49d9d4c079f..9b06ff019ec 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -665,6 +665,239 @@ jobs: path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml if-no-files-found: error + windows-beta: + name: Windows Beta Build + runs-on: windows-2022 + needs: setup + permissions: + contents: read + id-token: write + defaults: + run: + shell: pwsh + working-directory: apps/desktop + env: + _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} + NODE_OPTIONS: --max_old_space_size=4096 + steps: + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Node + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + cache: 'npm' + cache-dependency-path: '**/package-lock.json' + node-version: ${{ env._NODE_VERSION }} + + - name: Install AST + run: dotnet tool install --global AzureSignTool --version 4.0.1 + + - name: Print environment + run: | + node --version + npm --version + choco --version + rustup show + + - name: Log in to Azure + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Retrieve secrets + id: retrieve-secrets + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: "bitwarden-ci" + secrets: "code-signing-vault-url, + code-signing-client-id, + code-signing-tenant-id, + code-signing-client-secret, + code-signing-cert-name" + + - name: Log out from Azure + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/azure-logout@main + + - name: Install Node dependencies + run: npm ci + working-directory: ./ + + - name: Download SDK Artifacts + if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/download-artifacts@main + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + workflow: build-wasm-internal.yml + workflow_conclusion: success + branch: ${{ inputs.sdk_branch }} + artifacts: sdk-internal + repo: bitwarden/sdk-internal + path: ../sdk-internal + if_no_artifact_found: fail + + - name: Override SDK + if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} + working-directory: ./ + run: | + ls -l ../ + npm link ../sdk-internal + + - name: Cache Native Module + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + id: cache + with: + path: | + apps/desktop/desktop_native/napi/*.node + apps/desktop/desktop_native/dist/* + key: rust-${{ runner.os }}-${{ hashFiles('apps/desktop/desktop_native/**/*') }} + + - name: Build Native Module + if: steps.cache.outputs.cache-hit != 'true' + working-directory: apps/desktop/desktop_native + run: node build.js cross-platform + + - name: Build + run: npm run build + + - name: Pack + if: ${{ needs.setup.outputs.has_secrets == 'false' }} + run: npm run pack:win:beta + + - name: Pack & Sign + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + env: + ELECTRON_BUILDER_SIGN: 1 + SIGNING_VAULT_URL: ${{ steps.retrieve-secrets.outputs.code-signing-vault-url }} + SIGNING_CLIENT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-client-id }} + SIGNING_TENANT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-tenant-id }} + SIGNING_CLIENT_SECRET: ${{ steps.retrieve-secrets.outputs.code-signing-client-secret }} + SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }} + run: npm run pack:win:beta + + - name: Rename appx files for store + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + run: | + Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx" ` + -Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx" + Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx" ` + -Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx" + Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx" ` + -Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx" + + - name: Fix NSIS artifact names for auto-updater + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + run: | + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z ` + -NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z ` + -NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z ` + -NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + + - name: Upload portable exe artifact + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe + path: apps/desktop/dist/Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe + if-no-files-found: error + + - name: Upload installer exe artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe + path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe + if-no-files-found: error + + - name: Upload appx ia32 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx + if-no-files-found: error + + - name: Upload store appx ia32 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx + if-no-files-found: error + + - name: Upload NSIS ia32 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z + path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z + if-no-files-found: error + + - name: Upload appx x64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx + if-no-files-found: error + + - name: Upload store appx x64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx + if-no-files-found: error + + - name: Upload NSIS x64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z + path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z + if-no-files-found: error + + - name: Upload appx ARM64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx + if-no-files-found: error + + - name: Upload store appx ARM64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx + if-no-files-found: error + + - name: Upload NSIS ARM64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + if-no-files-found: error + + - name: Upload auto-update artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: ${{ needs.setup.outputs.release_channel }}-beta.yml + path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml + if-no-files-found: error + macos-build: name: MacOS Build diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json new file mode 100644 index 00000000000..5b792097623 --- /dev/null +++ b/apps/desktop/electron-builder.beta.json @@ -0,0 +1,132 @@ +{ + "extraMetadata": { + "name": "bitwarden-beta" + }, + "productName": "Bitwarden Beta", + "appId": "com.bitwarden.desktop.beta", + "buildDependenciesFromSource": true, + "copyright": "Copyright © 2015-2025 Bitwarden Inc.", + "directories": { + "buildResources": "resources", + "output": "dist", + "app": "build" + }, + "afterSign": "scripts/after-sign.js", + "afterPack": "scripts/after-pack.js", + "asarUnpack": ["**/*.node"], + "files": [ + "**/*", + "!**/node_modules/@bitwarden/desktop-napi/**/*", + "**/node_modules/@bitwarden/desktop-napi/index.js", + "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + ], + "electronVersion": "36.8.1", + "generateUpdatesFilesForAllChannels": true, + "publish": { + "provider": "generic", + "url": "https://artifacts.bitwarden.com/desktop" + }, + "win": { + "electronUpdaterCompatibility": ">=0.0.1", + "target": ["portable", "nsis-web", "appx"], + "signtoolOptions": { + "sign": "./sign.js" + }, + "extraFiles": [ + { + "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "to": "desktop_proxy.exe" + } + ] + }, + "nsisWeb": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": false, + "artifactName": "Bitwarden-Beta-Installer-${version}.${ext}", + "uninstallDisplayName": "${productName}", + "deleteAppDataOnUninstall": true, + "include": "installer.nsh" + }, + "portable": { + "artifactName": "Bitwarden-Beta-Portable-${version}.${ext}" + }, + "appx": { + "artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}", + "backgroundColor": "#175DDC", + "applicationId": "BitwardenBeta", + "identityName": "8bitSolutionsLLC.BitwardenBeta", + "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", + "publisherDisplayName": "Bitwarden Inc", + "languages": [ + "en-US", + "af", + "ar", + "az-latn", + "be", + "bg", + "bn", + "bs", + "ca", + "cs", + "cy", + "da", + "de", + "el", + "en-gb", + "en-in", + "es", + "et", + "eu", + "fa", + "fi", + "fil", + "fr", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ka", + "km", + "kn", + "ko", + "lt", + "lv", + "ml", + "mr", + "nb", + "ne", + "nl", + "nn", + "or", + "pl", + "pt-br", + "pt-pt", + "ro", + "ru", + "si", + "sk", + "sl", + "sr-cyrl", + "sv", + "ta", + "te", + "th", + "tr", + "uk", + "vi", + "zh-cn", + "zh-tw" + ] + }, + "protocols": [ + { + "name": "Bitwarden", + "schemes": ["bitwarden"] + } + ] +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 26d48742b7c..17dad010e46 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,6 +49,7 @@ "pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never", "pack:mac:masdev:with-extension": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", "pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", "dist:dir": "npm run build && npm run pack:dir", "dist:lin": "npm run build && npm run pack:lin", diff --git a/apps/desktop/sign.js b/apps/desktop/sign.js index f0110ea195b..6a42666c46f 100644 --- a/apps/desktop/sign.js +++ b/apps/desktop/sign.js @@ -13,7 +13,7 @@ exports.default = async function (configuration) { `-fd ${configuration.hash} ` + `-du ${configuration.site} ` + `-tr http://timestamp.digicert.com ` + - `${configuration.path}`, + `"${configuration.path}"`, { stdio: "inherit", }, diff --git a/apps/desktop/src/utils.ts b/apps/desktop/src/utils.ts index ba8c1a2dba6..552bc136392 100644 --- a/apps/desktop/src/utils.ts +++ b/apps/desktop/src/utils.ts @@ -53,7 +53,8 @@ export function isWindowsStore() { if ( windows && !windowsStore && - process.resourcesPath?.indexOf("8bitSolutionsLLC.bitwardendesktop_") > -1 + (process.resourcesPath?.indexOf("8bitSolutionsLLC.bitwardendesktop_") > -1 || + process.resourcesPath?.indexOf("8bitSolutionsLLC.BitwardenBeta_") > -1) ) { windowsStore = true; } From 8a76b28e08653fbbd366ff2cbf3eacf2cf06a114 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Mon, 13 Oct 2025 15:38:23 -0400 Subject: [PATCH 14/37] fix text (#16857) --- apps/web/src/locales/en/messages.json | 4 ++-- .../activity-cards/password-change-metric.component.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index fd237992d6b..af0374701ca 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -90,8 +90,8 @@ "assignMembersTasksToMonitorProgress": { "message": "Assign members tasks to monitor progress" }, - "onceYouReviewApplications": { - "message": "Once you review applications and mark them as critical, they will display here." + "onceYouReviewApps": { + "message": "Once you review applications and mark them as critical, you can assign tasks to members to resolve at-risk items and monitor progress here" }, "sendReminders": { "message": "Send reminders" diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html index 9b194954f0e..718efc4c67e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html @@ -11,7 +11,7 @@
- {{ "onceYouReviewApplications" | i18n }} + {{ "onceYouReviewApps" | i18n }}
} From 122c16ecbb272e742a55229fe5d4c84644fe3c60 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 13 Oct 2025 22:50:36 +0200 Subject: [PATCH 15/37] Remove on system lock option in opera on mac (#16786) --- .../src/auth/popup/settings/account-security.component.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 0c9b4634569..a9f43045902 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -172,7 +172,9 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } const showOnLocked = - !this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari(); + !this.platformUtilsService.isFirefox() && + !this.platformUtilsService.isSafari() && + !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel"); this.vaultTimeoutOptions = [ { name: this.i18nService.t("immediately"), value: 0 }, From 67ae047c94821b44ed2a62552400596dad437543 Mon Sep 17 00:00:00 2001 From: Mark Youssef <141061617+mark-youssef-bitwarden@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:03:15 -0700 Subject: [PATCH 16/37] [CL-876] Responsive preferences page (#16828) * Improve responsivness of preferences page * Update max width --- apps/web/src/app/settings/preferences.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index 050d7395caf..40f2f596a13 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -2,7 +2,7 @@

{{ "preferencesDesc" | i18n }}

-
+ {{ From 13f3792f34f248a7b99edb2dd24be3567f6d104d Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:23:37 -0700 Subject: [PATCH 17/37] PM-26439 minor code improvements related to pm 19467 (#16704) * add support for export-scope-callout.component to conditionally render organizational export message * avoid null comparison and remove explicit undefined type parameter --- .../export-scope-callout.component.ts | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts index a85048c23fa..acd7d9129bd 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts @@ -9,7 +9,9 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getById } from "@bitwarden/common/platform/misc/rxjs-operators"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { CalloutModule } from "@bitwarden/components"; +import { ExportFormat } from "@bitwarden/vault-export-core"; @Component({ selector: "tools-export-scope-callout", @@ -25,9 +27,9 @@ export class ExportScopeCalloutComponent { }; /* Optional OrganizationId, if not provided, it will display individual vault export message */ - readonly organizationId = input(); + readonly organizationId = input(); /* Optional export format, determines which individual export description to display */ - readonly exportFormat = input(); + readonly exportFormat = input(); /* The description key to use for organizational exports */ readonly orgExportDescription = input(); @@ -47,13 +49,13 @@ export class ExportScopeCalloutComponent { } private async getScopeMessage( - organizationId: string, - exportFormat: string, + organizationId: OrganizationId | undefined, + exportFormat: ExportFormat | undefined, orgExportDescription: string, ): Promise { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - if (organizationId != null) { + if (organizationId) { // exporting from organizational vault const org = await firstValueFrom( this.organizationService.organizations$(userId).pipe(getById(organizationId)), @@ -64,18 +66,19 @@ export class ExportScopeCalloutComponent { description: orgExportDescription, scopeIdentifier: org?.name ?? "", }; - } else { - this.scopeConfig = { - // exporting from individual vault - title: "exportingPersonalVaultTitle", - description: - exportFormat === "zip" - ? "exportingIndividualVaultWithAttachmentsDescription" - : "exportingIndividualVaultDescription", - scopeIdentifier: - (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email)))) ?? - "", - }; + + return; } + + // exporting from individual vault + this.scopeConfig = { + title: "exportingPersonalVaultTitle", + description: + exportFormat === "zip" + ? "exportingIndividualVaultWithAttachmentsDescription" + : "exportingIndividualVaultDescription", + scopeIdentifier: + (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email)))) ?? "", + }; } } From e9fc230dc1aec06fc4478d1cdd3c2524d7b3a2d9 Mon Sep 17 00:00:00 2001 From: Colton Hurst Date: Mon, 13 Oct 2025 17:34:06 -0400 Subject: [PATCH 18/37] [PM-26505] Fix Autotype update login issue (#16732) --- apps/desktop/src/vault/app/vault/vault-v2.component.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 5f888e081c1..6dda97807be 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -723,9 +723,7 @@ export class VaultV2Component this.cipherId = cipher.id; this.cipher = cipher; - if (this.activeUserId) { - await this.cipherService.clearCache(this.activeUserId).catch(() => {}); - } + await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {}); await this.go().catch(() => {}); await this.vaultItemsComponent?.refresh().catch(() => {}); From 0dd09caef742b7aee68c7f7abecbd8873a810b6f Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Tue, 14 Oct 2025 06:27:10 -0400 Subject: [PATCH 19/37] build(browser): integrate nx (#16712) --- apps/browser/project.json | 494 ++++++++++++++++++ apps/browser/tsconfig.build.json | 5 + apps/browser/tsconfig.json | 3 +- apps/browser/webpack.base.js | 173 ++++-- apps/browser/webpack.config.js | 63 ++- .../bit-browser/tsconfig.build.json | 4 + .../bit-browser/webpack.config.js | 63 ++- 7 files changed, 730 insertions(+), 75 deletions(-) create mode 100644 apps/browser/project.json create mode 100644 apps/browser/tsconfig.build.json create mode 100644 bitwarden_license/bit-browser/tsconfig.build.json diff --git a/apps/browser/project.json b/apps/browser/project.json new file mode 100644 index 00000000000..90b04ad2724 --- /dev/null +++ b/apps/browser/project.json @@ -0,0 +1,494 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "browser", + "projectType": "application", + "sourceRoot": "apps/browser/src", + "tags": ["scope:browser", "type:app"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "chrome-dev", + "options": { + "outputPath": "dist/apps/browser", + "webpackConfig": "apps/browser/webpack.config.js", + "tsConfig": "apps/browser/tsconfig.build.json", + "main": "apps/browser/src/popup/main.ts", + "target": "web", + "compiler": "tsc" + }, + "configurations": { + "chrome": { + "mode": "production", + "outputPath": "dist/apps/browser/chrome", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/chrome-dev", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "edge": { + "mode": "production", + "outputPath": "dist/apps/browser/edge", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/edge-dev", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox": { + "mode": "production", + "outputPath": "dist/apps/browser/firefox", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/firefox-mv2", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-mv2-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "opera": { + "mode": "production", + "outputPath": "dist/apps/browser/opera", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/opera-dev", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "safari": { + "mode": "production", + "outputPath": "dist/apps/browser/safari", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "safari-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/safari-mv2", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-mv2-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-chrome": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-chrome", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-chrome-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-edge": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-edge", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-edge-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-firefox", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-firefox-mv2", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "commercial-firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-opera": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-opera", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-opera-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-safari": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-safari", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-safari-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-safari-mv2", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "commercial-safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + } + } + }, + "serve": { + "executor": "@nx/webpack:webpack", + "defaultConfiguration": "chrome-dev", + "options": { + "outputPath": "dist/apps/browser", + "webpackConfig": "apps/browser/webpack.config.js", + "tsConfig": "apps/browser/tsconfig.build.json", + "main": "apps/browser/src/popup/main.ts", + "target": "web", + "compiler": "tsc", + "watch": true + }, + "configurations": { + "chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/chrome-dev", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-mv2-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-mv2-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/edge-dev", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/opera-dev", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-chrome-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-edge-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-opera-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + } + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/browser/jest.config.js" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/browser/**/*.ts", "apps/browser/**/*.html"] + } + } + } +} diff --git a/apps/browser/tsconfig.build.json b/apps/browser/tsconfig.build.json new file mode 100644 index 00000000000..53e44090f22 --- /dev/null +++ b/apps/browser/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "../../libs/common/src/autofill/constants"], + "exclude": ["**/*.stories.*", "**/*.spec.ts"] +} diff --git a/apps/browser/tsconfig.json b/apps/browser/tsconfig.json index 0fd6cac4230..04f3594f8ce 100644 --- a/apps/browser/tsconfig.json +++ b/apps/browser/tsconfig.json @@ -4,5 +4,6 @@ "src", "../../libs/common/src/autofill/constants", "../../libs/common/custom-matchers.d.ts" - ] + ], + "exclude": ["**/*.stories.*"] } diff --git a/apps/browser/webpack.base.js b/apps/browser/webpack.base.js index 872da6600b4..734a46ac187 100644 --- a/apps/browser/webpack.base.js +++ b/apps/browser/webpack.base.js @@ -10,14 +10,18 @@ const configurator = require("./config/config"); const manifest = require("./webpack/manifest"); const AngularCheckPlugin = require("./webpack/angular-check"); -module.exports.getEnv = function getEnv() { - const ENV = (process.env.ENV = process.env.NODE_ENV); +module.exports.getEnv = function getEnv(params) { + const ENV = params.env || (process.env.ENV = process.env.NODE_ENV); const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2; const browser = process.env.BROWSER ?? "chrome"; return { ENV, manifestVersion, browser }; }; +const DEFAULT_PARAMS = { + outputPath: path.resolve(__dirname, "build"), +}; + /** * @param {{ * configName: string; @@ -29,15 +33,20 @@ module.exports.getEnv = function getEnv() { * entry: string; * }; * tsConfig: string; + * outputPath?: string; + * mode?: string; + * env?: string; * additionalEntries?: { [outputPath: string]: string } * }} params - The input parameters for building the config. */ module.exports.buildConfig = function buildConfig(params) { + params = { ...DEFAULT_PARAMS, ...params }; + if (process.env.NODE_ENV == null) { process.env.NODE_ENV = "development"; } - const { ENV, manifestVersion, browser } = module.exports.getEnv(); + const { ENV, manifestVersion, browser } = module.exports.getEnv(params); console.log(`Building Manifest Version ${manifestVersion} app - ${params.configName} version`); @@ -103,7 +112,7 @@ module.exports.buildConfig = function buildConfig(params) { { loader: "babel-loader", options: { - configFile: "../../babel.config.json", + configFile: path.resolve(__dirname, "../../babel.config.json"), cacheDirectory: ENV === "development", compact: ENV !== "development", }, @@ -130,43 +139,52 @@ module.exports.buildConfig = function buildConfig(params) { const plugins = [ new HtmlWebpackPlugin({ - template: "./src/popup/index.ejs", + template: path.resolve(__dirname, "src/popup/index.ejs"), filename: "popup/index.html", chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"], browser: browser, }), new HtmlWebpackPlugin({ - template: "./src/autofill/notification/bar.html", + template: path.resolve(__dirname, "src/autofill/notification/bar.html"), filename: "notification/bar.html", chunks: ["notification/bar"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/button/button.html", + template: path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/button/button.html", + ), filename: "overlay/menu-button.html", chunks: ["overlay/menu-button"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/list/list.html", + template: path.resolve(__dirname, "src/autofill/overlay/inline-menu/pages/list/list.html"), filename: "overlay/menu-list.html", chunks: ["overlay/menu-list"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", + template: path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", + ), filename: "overlay/menu.html", chunks: ["overlay/menu"], }), new CopyWebpackPlugin({ patterns: [ { - from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json", + from: + manifestVersion == 3 + ? path.resolve(__dirname, "src/manifest.v3.json") + : path.resolve(__dirname, "src/manifest.json"), to: "manifest.json", transform: manifest.transform(browser), }, - { from: "./src/managed_schema.json", to: "managed_schema.json" }, - { from: "./src/_locales", to: "_locales" }, - { from: "./src/images", to: "images" }, - { from: "./src/popup/images", to: "popup/images" }, - { from: "./src/autofill/content/autofill.css", to: "content" }, + { from: path.resolve(__dirname, "src/managed_schema.json"), to: "managed_schema.json" }, + { from: path.resolve(__dirname, "src/_locales"), to: "_locales" }, + { from: path.resolve(__dirname, "src/images"), to: "images" }, + { from: path.resolve(__dirname, "src/popup/images"), to: "popup/images" }, + { from: path.resolve(__dirname, "src/autofill/content/autofill.css"), to: "content" }, ], }), new MiniCssExtractPlugin({ @@ -196,33 +214,76 @@ module.exports.buildConfig = function buildConfig(params) { name: "main", mode: ENV, devtool: false, + entry: { - "popup/polyfills": "./src/popup/polyfills.ts", + "popup/polyfills": path.resolve(__dirname, "src/popup/polyfills.ts"), "popup/main": params.popup.entry, - "content/trigger-autofill-script-injection": - "./src/autofill/content/trigger-autofill-script-injection.ts", - "content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts", - "content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts", - "content/bootstrap-autofill-overlay-menu": - "./src/autofill/content/bootstrap-autofill-overlay-menu.ts", - "content/bootstrap-autofill-overlay-notifications": - "./src/autofill/content/bootstrap-autofill-overlay-notifications.ts", - "content/autofiller": "./src/autofill/content/autofiller.ts", - "content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts", - "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", - "content/content-message-handler": "./src/autofill/content/content-message-handler.ts", - "content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts", - "content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts", - "content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts", - "notification/bar": "./src/autofill/notification/bar.ts", - "overlay/menu-button": - "./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", - "overlay/menu-list": - "./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", - "overlay/menu": - "./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", - "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", - "content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts", + "content/trigger-autofill-script-injection": path.resolve( + __dirname, + "src/autofill/content/trigger-autofill-script-injection.ts", + ), + "content/bootstrap-autofill": path.resolve( + __dirname, + "src/autofill/content/bootstrap-autofill.ts", + ), + "content/bootstrap-autofill-overlay": path.resolve( + __dirname, + "src/autofill/content/bootstrap-autofill-overlay.ts", + ), + "content/bootstrap-autofill-overlay-menu": path.resolve( + __dirname, + "src/autofill/content/bootstrap-autofill-overlay-menu.ts", + ), + "content/bootstrap-autofill-overlay-notifications": path.resolve( + __dirname, + "src/autofill/content/bootstrap-autofill-overlay-notifications.ts", + ), + "content/autofiller": path.resolve(__dirname, "src/autofill/content/autofiller.ts"), + "content/auto-submit-login": path.resolve( + __dirname, + "src/autofill/content/auto-submit-login.ts", + ), + "content/contextMenuHandler": path.resolve( + __dirname, + "src/autofill/content/context-menu-handler.ts", + ), + "content/content-message-handler": path.resolve( + __dirname, + "src/autofill/content/content-message-handler.ts", + ), + "content/fido2-content-script": path.resolve( + __dirname, + "src/autofill/fido2/content/fido2-content-script.ts", + ), + "content/fido2-page-script": path.resolve( + __dirname, + "src/autofill/fido2/content/fido2-page-script.ts", + ), + "content/ipc-content-script": path.resolve( + __dirname, + "src/platform/ipc/content/ipc-content-script.ts", + ), + "notification/bar": path.resolve(__dirname, "src/autofill/notification/bar.ts"), + "overlay/menu-button": path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", + ), + "overlay/menu-list": path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", + ), + "overlay/menu": path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", + ), + "content/send-on-installed-message": path.resolve( + __dirname, + "src/vault/content/send-on-installed-message.ts", + ), + "content/send-popup-open-message": path.resolve( + __dirname, + "src/vault/content/send-popup-open-message.ts", + ), ...params.additionalEntries, }, cache: @@ -291,7 +352,7 @@ module.exports.buildConfig = function buildConfig(params) { resolve: { extensions: [".ts", ".js"], symlinks: false, - modules: [path.resolve("../../node_modules")], + modules: [path.resolve(__dirname, "../../node_modules")], fallback: { assert: false, buffer: require.resolve("buffer/"), @@ -306,7 +367,7 @@ module.exports.buildConfig = function buildConfig(params) { filename: "[name].js", chunkFilename: "assets/[name].js", webassemblyModuleFilename: "assets/[modulehash].wasm", - path: path.resolve(__dirname, "build"), + path: params.outputPath, clean: true, }, module: { @@ -335,7 +396,7 @@ module.exports.buildConfig = function buildConfig(params) { // Manifest V2 uses Background Pages which requires a html page. mainConfig.plugins.push( new HtmlWebpackPlugin({ - template: "./src/platform/background.html", + template: path.resolve(__dirname, "src/platform/background.html"), filename: "background.html", chunks: ["vendor", "background"], }), @@ -344,19 +405,23 @@ module.exports.buildConfig = function buildConfig(params) { // Manifest V2 background pages can be run through the regular build pipeline. // Since it's a standard webpage. mainConfig.entry.background = params.background.entry; - mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = - "./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts"; + mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = path.resolve( + __dirname, + "src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts", + ); configs.push(mainConfig); } else { // Firefox does not use the offscreen API if (browser !== "firefox") { - mainConfig.entry["offscreen-document/offscreen-document"] = - "./src/platform/offscreen-document/offscreen-document.ts"; + mainConfig.entry["offscreen-document/offscreen-document"] = path.resolve( + __dirname, + "src/platform/offscreen-document/offscreen-document.ts", + ); mainConfig.plugins.push( new HtmlWebpackPlugin({ - template: "./src/platform/offscreen-document/index.html", + template: path.resolve(__dirname, "src/platform/offscreen-document/index.html"), filename: "offscreen-document/index.html", chunks: ["offscreen-document/offscreen-document"], }), @@ -372,11 +437,12 @@ module.exports.buildConfig = function buildConfig(params) { name: "background", mode: ENV, devtool: false, + entry: params.background.entry, target: target, output: { filename: "background.js", - path: path.resolve(__dirname, "build"), + path: params.outputPath, }, module: { rules: [ @@ -409,7 +475,7 @@ module.exports.buildConfig = function buildConfig(params) { resolve: { extensions: [".ts", ".js"], symlinks: false, - modules: [path.resolve("../../node_modules")], + modules: [path.resolve(__dirname, "../../node_modules")], plugins: [new TsconfigPathsPlugin()], fallback: { fs: false, @@ -428,8 +494,11 @@ module.exports.buildConfig = function buildConfig(params) { backgroundConfig.plugins.push( new CopyWebpackPlugin({ patterns: [ - { from: "./src/safari/mv3/fake-background.html", to: "background.html" }, - { from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" }, + { + from: path.resolve(__dirname, "src/safari/mv3/fake-background.html"), + to: "background.html", + }, + { from: path.resolve(__dirname, "src/safari/mv3/fake-vendor.js"), to: "vendor.js" }, ], }), ); diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 9eac990ab61..14a36db264f 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -1,13 +1,54 @@ +const path = require("path"); const { buildConfig } = require("./webpack.base"); -module.exports = buildConfig({ - configName: "OSS", - popup: { - entry: "./src/popup/main.ts", - entryModule: "src/popup/app.module#AppModule", - }, - background: { - entry: "./src/platform/background.ts", - }, - tsConfig: "tsconfig.json", -}); +module.exports = (webpackConfig, context) => { + // Detect if called by Nx (context parameter exists) + const isNxBuild = context && context.options; + + if (isNxBuild) { + // Nx build configuration + const mode = context.options.mode || "development"; + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = mode; + } + const ENV = (process.env.ENV = process.env.NODE_ENV); + + // Set environment variables from Nx context + if (context.options.env) { + Object.keys(context.options.env).forEach((key) => { + process.env[key] = context.options.env[key]; + }); + } + + return buildConfig({ + configName: "OSS", + popup: { + entry: path.resolve(__dirname, "src/popup/main.ts"), + entryModule: "src/popup/app.module#AppModule", + }, + background: { + entry: path.resolve(__dirname, "src/platform/background.ts"), + }, + tsConfig: path.resolve(__dirname, "tsconfig.build.json"), + outputPath: + context.context && context.context.root + ? path.resolve(context.context.root, context.options.outputPath) + : context.options.outputPath, + mode: mode, + env: ENV, + }); + } else { + // npm build configuration + return buildConfig({ + configName: "OSS", + popup: { + entry: path.resolve(__dirname, "src/popup/main.ts"), + entryModule: "src/popup/app.module#AppModule", + }, + background: { + entry: path.resolve(__dirname, "src/platform/background.ts"), + }, + tsConfig: "tsconfig.json", + }); + } +}; diff --git a/bitwarden_license/bit-browser/tsconfig.build.json b/bitwarden_license/bit-browser/tsconfig.build.json new file mode 100644 index 00000000000..2a811b3231e --- /dev/null +++ b/bitwarden_license/bit-browser/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.stories.*", "**/*.spec.ts"] +} diff --git a/bitwarden_license/bit-browser/webpack.config.js b/bitwarden_license/bit-browser/webpack.config.js index 294e2f16253..1c6ab51549f 100644 --- a/bitwarden_license/bit-browser/webpack.config.js +++ b/bitwarden_license/bit-browser/webpack.config.js @@ -1,13 +1,54 @@ +const path = require("path"); const { buildConfig } = require("../../apps/browser/webpack.base"); -module.exports = buildConfig({ - configName: "Commercial", - popup: { - entry: "../../bitwarden_license/bit-browser/src/popup/main.ts", - entryModule: "../../bitwarden_license/bit-browser/src/popup/app.module#AppModule", - }, - background: { - entry: "../../bitwarden_license/bit-browser/src/platform/background.ts", - }, - tsConfig: "../../bitwarden_license/bit-browser/tsconfig.json", -}); +module.exports = (webpackConfig, context) => { + // Detect if called by Nx (context parameter exists) + const isNxBuild = context && context.options; + + if (isNxBuild) { + // Nx build configuration + const mode = context.options.mode || "development"; + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = mode; + } + const ENV = (process.env.ENV = process.env.NODE_ENV); + + // Set environment variables from Nx context + if (context.options.env) { + Object.keys(context.options.env).forEach((key) => { + process.env[key] = context.options.env[key]; + }); + } + + return buildConfig({ + configName: "Commercial", + popup: { + entry: path.resolve(__dirname, "src/popup/main.ts"), + entryModule: "bitwarden_license/bit-browser/src/popup/app.module#AppModule", + }, + background: { + entry: path.resolve(__dirname, "src/platform/background.ts"), + }, + tsConfig: path.resolve(__dirname, "tsconfig.build.json"), + outputPath: + context.context && context.context.root + ? path.resolve(context.context.root, context.options.outputPath) + : context.options.outputPath, + mode: mode, + env: ENV, + }); + } else { + // npm build configuration + return buildConfig({ + configName: "Commercial", + popup: { + entry: path.resolve(__dirname, "src/popup/main.ts"), + entryModule: "bitwarden_license/bit-browser/src/popup/app.module#AppModule", + }, + background: { + entry: path.resolve(__dirname, "src/platform/background.ts"), + }, + tsConfig: path.resolve(__dirname, "tsconfig.json"), + }); + } +}; From 0538b587d4d44cac89a7788eec3935afeeac5849 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Tue, 14 Oct 2025 06:27:38 -0400 Subject: [PATCH 20/37] build(desktop): integrate nx (#16860) --- apps/desktop/project.json | 115 +++++++++++++++++++++++++++++++ apps/desktop/scripts/nx-serve.js | 42 +++++++++++ apps/desktop/webpack.base.js | 31 ++++++--- apps/desktop/webpack.config.js | 57 ++++++++++----- 4 files changed, 220 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/project.json create mode 100644 apps/desktop/scripts/nx-serve.js diff --git a/apps/desktop/project.json b/apps/desktop/project.json new file mode 100644 index 00000000000..98f33864046 --- /dev/null +++ b/apps/desktop/project.json @@ -0,0 +1,115 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "desktop", + "projectType": "application", + "sourceRoot": "apps/desktop/src", + "tags": ["scope:desktop", "type:app"], + "targets": { + "build-native": { + "executor": "nx:run-commands", + "options": { + "command": "cd desktop_native && node build.js", + "cwd": "apps/desktop" + } + }, + "build-main": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/apps/desktop"], + "options": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main", + "cwd": "apps/desktop" + }, + "configurations": { + "development": { + "command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main" + }, + "production": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main" + } + } + }, + "build-preload": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/apps/desktop"], + "options": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload", + "cwd": "apps/desktop" + }, + "configurations": { + "development": { + "command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload" + }, + "production": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload" + } + } + }, + "build-renderer": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/apps/desktop"], + "options": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer", + "cwd": "apps/desktop" + }, + "configurations": { + "development": { + "command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer" + }, + "production": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer" + } + } + }, + "build": { + "executor": "nx:run-commands", + "dependsOn": ["build-native"], + "outputs": ["{workspaceRoot}/dist/apps/desktop"], + "options": { + "parallel": true, + "commands": [ + "nx run desktop:build-main", + "nx run desktop:build-preload", + "nx run desktop:build-renderer" + ] + }, + "configurations": { + "development": { + "commands": [ + "nx run desktop:build-main --configuration=development", + "nx run desktop:build-preload --configuration=development", + "nx run desktop:build-renderer --configuration=development" + ] + }, + "production": { + "commands": [ + "nx run desktop:build-main --configuration=production", + "nx run desktop:build-preload --configuration=production", + "nx run desktop:build-renderer --configuration=production" + ] + } + } + }, + "serve": { + "executor": "nx:run-commands", + "dependsOn": ["build-native"], + "options": { + "command": "node scripts/nx-serve.js", + "cwd": "apps/desktop" + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/apps/desktop"], + "options": { + "jestConfig": "apps/desktop/jest.config.js" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/desktop/**/*.ts", "apps/desktop/**/*.html"] + } + } + } +} diff --git a/apps/desktop/scripts/nx-serve.js b/apps/desktop/scripts/nx-serve.js new file mode 100644 index 00000000000..b92a045f8e8 --- /dev/null +++ b/apps/desktop/scripts/nx-serve.js @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const path = require("path"); + +const concurrently = require("concurrently"); +const rimraf = require("rimraf"); +const args = process.argv.splice(2); +const outputPath = path.resolve(__dirname, "../../../dist/apps/desktop"); + +rimraf.sync(outputPath); +require("fs").mkdirSync(outputPath, { recursive: true }); + +concurrently( + [ + { + name: "Main", + command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name main --watch`, + prefixColor: "yellow", + }, + { + name: "Prel", + command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name preload --watch`, + prefixColor: "magenta", + }, + { + name: "Rend", + command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name renderer --watch`, + prefixColor: "cyan", + }, + { + name: "Elec", + command: `npx wait-on ${outputPath}/main.js ${outputPath}/index.html && npx electron --no-sandbox --inspect=5858 ${args.join( + " ", + )} ${outputPath} --watch`, + prefixColor: "green", + }, + ], + { + prefix: "name", + outputStream: process.stdout, + killOthers: ["success", "failure"], + }, +); diff --git a/apps/desktop/webpack.base.js b/apps/desktop/webpack.base.js index fe3079b730f..c9da84cd2e1 100644 --- a/apps/desktop/webpack.base.js +++ b/apps/desktop/webpack.base.js @@ -8,7 +8,7 @@ const { AngularWebpackPlugin } = require("@ngtools/webpack"); const TerserPlugin = require("terser-webpack-plugin"); const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); const { EnvironmentPlugin, DefinePlugin } = require("webpack"); -const configurator = require("./config/config"); +const configurator = require(path.resolve(__dirname, "config/config")); module.exports.getEnv = function getEnv() { const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; @@ -17,6 +17,14 @@ module.exports.getEnv = function getEnv() { return { NODE_ENV, ENV }; }; +const DEFAULT_PARAMS = { + outputPath: process.env.OUTPUT_PATH + ? path.isAbsolute(process.env.OUTPUT_PATH) + ? process.env.OUTPUT_PATH + : path.resolve(__dirname, process.env.OUTPUT_PATH) + : path.resolve(__dirname, "build"), +}; + /** * @param {{ * configName: string; @@ -33,9 +41,11 @@ module.exports.getEnv = function getEnv() { * entry: string; * tsConfig: string; * }; + * outputPath?: string; * }} params */ module.exports.buildConfig = function buildConfig(params) { + params = { ...DEFAULT_PARAMS, ...params }; const { NODE_ENV, ENV } = module.exports.getEnv(); console.log(`Building ${params.configName} Desktop App`); @@ -47,13 +57,16 @@ module.exports.buildConfig = function buildConfig(params) { resolve: { extensions: [".tsx", ".ts", ".js"], symlinks: false, - modules: [path.resolve("../../node_modules")], + modules: [ + path.resolve(__dirname, "../../node_modules"), + path.resolve(process.cwd(), "node_modules"), + ], }, }; const getOutputConfig = (isDev) => ({ filename: "[name].js", - path: path.resolve(__dirname, "build"), + path: params.outputPath, ...(isDev && { devtoolModuleFilenameTemplate: "[absolute-resource-path]" }), }); @@ -96,9 +109,9 @@ module.exports.buildConfig = function buildConfig(params) { plugins: [ new CopyWebpackPlugin({ patterns: [ - "./src/package.json", - { from: "./src/images", to: "images" }, - { from: "./src/locales", to: "locales" }, + path.resolve(__dirname, "src/package.json"), + { from: path.resolve(__dirname, "src/images"), to: "images" }, + { from: path.resolve(__dirname, "src/locales"), to: "locales" }, ], }), new DefinePlugin({ @@ -164,7 +177,7 @@ module.exports.buildConfig = function buildConfig(params) { }, output: { filename: "[name].js", - path: path.resolve(__dirname, "build"), + path: params.outputPath, }, optimization: { minimizer: [ @@ -200,7 +213,7 @@ module.exports.buildConfig = function buildConfig(params) { { loader: "babel-loader", options: { - configFile: "../../babel.config.json", + configFile: path.resolve(__dirname, "../../babel.config.json"), }, }, ], @@ -293,7 +306,7 @@ module.exports.buildConfig = function buildConfig(params) { path.resolve(__dirname, "./src"), ), new HtmlWebpackPlugin({ - template: "./src/index.html", + template: path.resolve(__dirname, "src/index.html"), filename: "index.html", chunks: ["app/vendor", "app/main"], }), diff --git a/apps/desktop/webpack.config.js b/apps/desktop/webpack.config.js index 5ba0df337ee..685196e56c0 100644 --- a/apps/desktop/webpack.config.js +++ b/apps/desktop/webpack.config.js @@ -1,18 +1,43 @@ +const path = require("path"); const { buildConfig } = require("./webpack.base"); -module.exports = buildConfig({ - configName: "OSS", - renderer: { - entry: "./src/app/main.ts", - entryModule: "src/app/app.module#AppModule", - tsConfig: "./tsconfig.renderer.json", - }, - main: { - entry: "./src/entry.ts", - tsConfig: "./tsconfig.json", - }, - preload: { - entry: "./src/preload.ts", - tsConfig: "./tsconfig.json", - }, -}); +module.exports = (webpackConfig, context) => { + const isNxBuild = context && context.options; + + if (isNxBuild) { + return buildConfig({ + configName: "OSS", + renderer: { + entry: path.resolve(__dirname, "src/app/main.ts"), + entryModule: "src/app/app.module#AppModule", + tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.renderer.json"), + }, + main: { + entry: path.resolve(__dirname, "src/entry.ts"), + tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.json"), + }, + preload: { + entry: path.resolve(__dirname, "src/preload.ts"), + tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.json"), + }, + outputPath: path.resolve(context.context.root, context.options.outputPath), + }); + } else { + return buildConfig({ + configName: "OSS", + renderer: { + entry: path.resolve(__dirname, "src/app/main.ts"), + entryModule: "src/app/app.module#AppModule", + tsConfig: path.resolve(__dirname, "tsconfig.renderer.json"), + }, + main: { + entry: path.resolve(__dirname, "src/entry.ts"), + tsConfig: path.resolve(__dirname, "tsconfig.json"), + }, + preload: { + entry: path.resolve(__dirname, "src/preload.ts"), + tsConfig: path.resolve(__dirname, "tsconfig.json"), + }, + }); + } +}; From 60c28ece57f111f4615a5309c59c67fa1ae483c0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:16:46 +0200 Subject: [PATCH 21/37] [deps] Platform: Update @electron/rebuild to v4 (#14724) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 32 +++++++++++++++----------------- package.json | 2 +- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 346f9b9dc9d..8108e04836a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,7 @@ "@babel/preset-env": "7.24.8", "@compodoc/compodoc": "1.1.26", "@electron/notarize": "3.0.1", - "@electron/rebuild": "3.7.2", + "@electron/rebuild": "4.0.1", "@eslint/compat": "1.2.9", "@lit-labs/signals": "0.1.2", "@ngtools/webpack": "19.2.14", @@ -5851,21 +5851,21 @@ } }, "node_modules/@electron/rebuild": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.2.tgz", - "integrity": "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.1.tgz", + "integrity": "sha512-iMGXb6Ib7H/Q3v+BKZJoETgF9g6KMNZVbsO4b7Dmpgb5qTFqyFTzqW9F3TOSHdybv2vKYKzSS9OiZL+dcJb+1Q==", "dev": true, "license": "MIT", "dependencies": { - "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", - "fs-extra": "^10.0.0", "got": "^11.7.0", - "node-abi": "^3.45.0", - "node-api-version": "^0.2.0", + "graceful-fs": "^4.2.11", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", @@ -5876,22 +5876,20 @@ "electron-rebuild": "lib/cli.js" }, "engines": { - "node": ">=12.13.0" + "node": ">=22.12.0" } }, - "node_modules/@electron/rebuild/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "node_modules/@electron/rebuild/node_modules/node-abi": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.14.0.tgz", + "integrity": "sha512-E4n91K4Nk1Rch2KzD+edU2bfZTP4W42GypAUDXU4vu1A+4u9PvUNDkGI0dXbsy8ZeF3WGj0SD/uHxnXD/sW+3w==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "semver": "^7.6.3" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" } }, "node_modules/@electron/universal": { diff --git a/package.json b/package.json index 38ddfc19e21..977b1f71774 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@babel/preset-env": "7.24.8", "@compodoc/compodoc": "1.1.26", "@electron/notarize": "3.0.1", - "@electron/rebuild": "3.7.2", + "@electron/rebuild": "4.0.1", "@eslint/compat": "1.2.9", "@lit-labs/signals": "0.1.2", "@ngtools/webpack": "19.2.14", From cc4c42828a45651051a3eced0c433678159cda7b Mon Sep 17 00:00:00 2001 From: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:04:59 +0200 Subject: [PATCH 22/37] Autofill shadow dom fields (#16769) --- .../src/autofill/services/autofill.service.spec.ts | 13 +++++++++++++ .../src/autofill/services/autofill.service.ts | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index f0ae8856ecd..9b0424c5cdf 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -4105,6 +4105,7 @@ describe("AutofillService", () => { }); it("returns null if the field cannot be hidden", () => { + usernameField.form = "differentFormId"; const result = autofillService["findUsernameField"]( pageDetails, passwordField, @@ -4116,6 +4117,18 @@ describe("AutofillService", () => { expect(result).toBe(null); }); + it("returns the field if the username field is in the form", () => { + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false, + ); + + expect(result).toBe(usernameField); + }); + it("returns the field if the field can be hidden", () => { const result = autofillService["findUsernameField"]( pageDetails, diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 73262962dbc..ea0fb089690 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -2286,11 +2286,16 @@ export default class AutofillService implements AutofillServiceInterface { this.findMatchingFieldIndex(f, AutoFillConstants.UsernameFieldNames) > -1; const isInSameForm = f.form === passwordField.form; + // An email or tel field in the same form as the password field is likely a qualified + // candidate for autofill, even if visibility checks are unreliable + const isQualifiedUsernameField = + f.form === passwordField.form && (f.type === "email" || f.type === "tel"); + if ( !f.disabled && (canBeReadOnly || !f.readonly) && (withoutForm || isInSameForm || includesUsernameFieldName) && - (canBeHidden || f.viewable) && + (canBeHidden || f.viewable || isQualifiedUsernameField) && (f.type === "text" || f.type === "email" || f.type === "tel") ) { // Prioritize fields in the same form as the password field From af529fbf39713609cb4821929428462630dd8ae2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:42:42 -0400 Subject: [PATCH 23/37] [deps]: Update actions/setup-node action to v5 (#16426) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-browser.yml | 6 +++--- .github/workflows/build-cli.yml | 4 ++-- .github/workflows/build-desktop.yml | 14 +++++++------- .github/workflows/chromatic.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/nx.yml | 2 +- .github/workflows/publish-cli.yml | 2 +- .github/workflows/test.yml | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 823cb7e25e0..bece680b9d0 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -147,7 +147,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -248,7 +248,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -359,7 +359,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 22ba3a3e7be..d1df280f764 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -124,7 +124,7 @@ jobs: awk '{print tolower($0)}')" >> $GITHUB_ENV - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -302,7 +302,7 @@ jobs: choco install nasm --no-progress - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 9b06ff019ec..8ba5f981453 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -174,7 +174,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -323,7 +323,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -429,7 +429,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -687,7 +687,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -921,7 +921,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1147,7 +1147,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1405,7 +1405,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index dc4da7d37de..2b7b6394f24 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -57,7 +57,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 738aef899f5..14b5d51d9ef 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -61,7 +61,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 9349239a134..526c2b5d864 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -25,7 +25,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 4f776876f17..121236c0deb 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -205,7 +205,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} npm-version: "11.5.1" # FIXME: npm 11.5.1 or later is required to publish w/ OIDC; move version management to somewhere maintainable by automation diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2770c1257ea..680bfb87cfe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' From 31aa5f4d5bea642cd3a341b62e521c06ef088ab4 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Tue, 14 Oct 2025 11:07:23 -0400 Subject: [PATCH 24/37] refactor(nx): remove unneeded tsconfig.build.json & adjust nx docs (#16864) * cleanup: remove unneeded tsconfig.build.json * cleanup: nx docs --- apps/browser/project.json | 46 +++++++++++++++--------------- apps/browser/tsconfig.build.json | 5 ---- apps/browser/tsconfig.json | 3 +- apps/browser/webpack.config.js | 2 +- docs/using-nx-to-build-projects.md | 7 +++-- 5 files changed, 29 insertions(+), 34 deletions(-) delete mode 100644 apps/browser/tsconfig.build.json diff --git a/apps/browser/project.json b/apps/browser/project.json index 90b04ad2724..9a8df56c170 100644 --- a/apps/browser/project.json +++ b/apps/browser/project.json @@ -12,7 +12,7 @@ "options": { "outputPath": "dist/apps/browser", "webpackConfig": "apps/browser/webpack.config.js", - "tsConfig": "apps/browser/tsconfig.build.json", + "tsConfig": "apps/browser/tsconfig.json", "main": "apps/browser/src/popup/main.ts", "target": "web", "compiler": "tsc" @@ -149,7 +149,7 @@ "outputPath": "dist/apps/browser/commercial-chrome", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "chrome", "MANIFEST_VERSION": "3", @@ -161,7 +161,7 @@ "outputPath": "dist/apps/browser/commercial-chrome-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "chrome", "MANIFEST_VERSION": "3", @@ -173,7 +173,7 @@ "outputPath": "dist/apps/browser/commercial-edge", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "edge", "MANIFEST_VERSION": "3", @@ -185,7 +185,7 @@ "outputPath": "dist/apps/browser/commercial-edge-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "edge", "MANIFEST_VERSION": "3", @@ -197,7 +197,7 @@ "outputPath": "dist/apps/browser/commercial-firefox", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "firefox", "MANIFEST_VERSION": "3", @@ -209,7 +209,7 @@ "outputPath": "dist/apps/browser/commercial-firefox-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "firefox", "MANIFEST_VERSION": "3", @@ -221,7 +221,7 @@ "outputPath": "dist/apps/browser/commercial-firefox-mv2", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "firefox", "MANIFEST_VERSION": "2", @@ -233,7 +233,7 @@ "outputPath": "dist/apps/browser/commercial-firefox-mv2-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "firefox", "MANIFEST_VERSION": "2", @@ -245,7 +245,7 @@ "outputPath": "dist/apps/browser/commercial-opera", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "opera", "MANIFEST_VERSION": "3", @@ -257,7 +257,7 @@ "outputPath": "dist/apps/browser/commercial-opera-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "opera", "MANIFEST_VERSION": "3", @@ -269,7 +269,7 @@ "outputPath": "dist/apps/browser/commercial-safari", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "safari", "MANIFEST_VERSION": "3", @@ -281,7 +281,7 @@ "outputPath": "dist/apps/browser/commercial-safari-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "safari", "MANIFEST_VERSION": "3", @@ -293,7 +293,7 @@ "outputPath": "dist/apps/browser/commercial-safari-mv2", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "safari", "MANIFEST_VERSION": "2", @@ -305,7 +305,7 @@ "outputPath": "dist/apps/browser/commercial-safari-mv2-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "safari", "MANIFEST_VERSION": "2", @@ -320,7 +320,7 @@ "options": { "outputPath": "dist/apps/browser", "webpackConfig": "apps/browser/webpack.config.js", - "tsConfig": "apps/browser/tsconfig.build.json", + "tsConfig": "apps/browser/tsconfig.json", "main": "apps/browser/src/popup/main.ts", "target": "web", "compiler": "tsc", @@ -395,7 +395,7 @@ "outputPath": "dist/apps/browser/commercial-chrome-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "chrome", "MANIFEST_VERSION": "3", @@ -407,7 +407,7 @@ "outputPath": "dist/apps/browser/commercial-firefox-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "firefox", "MANIFEST_VERSION": "3", @@ -419,7 +419,7 @@ "outputPath": "dist/apps/browser/commercial-firefox-mv2-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "firefox", "MANIFEST_VERSION": "2", @@ -431,7 +431,7 @@ "outputPath": "dist/apps/browser/commercial-safari-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "safari", "MANIFEST_VERSION": "3", @@ -443,7 +443,7 @@ "outputPath": "dist/apps/browser/commercial-safari-mv2-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "safari", "MANIFEST_VERSION": "2", @@ -455,7 +455,7 @@ "outputPath": "dist/apps/browser/commercial-edge-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "edge", "MANIFEST_VERSION": "3", @@ -467,7 +467,7 @@ "outputPath": "dist/apps/browser/commercial-opera-dev", "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", "main": "bitwarden_license/bit-browser/src/popup/main.ts", - "tsConfig": "bitwarden_license/bit-browser/tsconfig.build.json", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", "env": { "BROWSER": "opera", "MANIFEST_VERSION": "3", diff --git a/apps/browser/tsconfig.build.json b/apps/browser/tsconfig.build.json deleted file mode 100644 index 53e44090f22..00000000000 --- a/apps/browser/tsconfig.build.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["src", "../../libs/common/src/autofill/constants"], - "exclude": ["**/*.stories.*", "**/*.spec.ts"] -} diff --git a/apps/browser/tsconfig.json b/apps/browser/tsconfig.json index 04f3594f8ce..0fd6cac4230 100644 --- a/apps/browser/tsconfig.json +++ b/apps/browser/tsconfig.json @@ -4,6 +4,5 @@ "src", "../../libs/common/src/autofill/constants", "../../libs/common/custom-matchers.d.ts" - ], - "exclude": ["**/*.stories.*"] + ] } diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 14a36db264f..cb0761c859b 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -29,7 +29,7 @@ module.exports = (webpackConfig, context) => { background: { entry: path.resolve(__dirname, "src/platform/background.ts"), }, - tsConfig: path.resolve(__dirname, "tsconfig.build.json"), + tsConfig: path.resolve(__dirname, "tsconfig.json"), outputPath: context.context && context.context.root ? path.resolve(context.context.root, context.options.outputPath) diff --git a/docs/using-nx-to-build-projects.md b/docs/using-nx-to-build-projects.md index f1fd54e1c20..408a6e92fb1 100644 --- a/docs/using-nx-to-build-projects.md +++ b/docs/using-nx-to-build-projects.md @@ -2,7 +2,7 @@ Bitwarden uses [Nx](https://nx.dev/) to make building projects from the monorepo easier. To build, lint, or test a project you'll want to reference the project's `project.json` file for availible commands and their names. Then you'll run `npx nx [your_command] [your_project] [your_options]`. Run `npx nx --help` to see availible options, there are many. -Please note: the Nx implementation is a work in progress. Not all apps support Nx yet, CI still uses the old npm builds, and we have many "legacy" libraries that use hacks to get them into the Nx project graph. +Please note: the Nx implementation is a work in progress. CI still uses the old npm builds, and we have many "legacy" libraries that use hacks to get them into the Nx project graph. ## Quick Start @@ -11,6 +11,7 @@ Please note: the Nx implementation is a work in progress. Not all apps support N ```bash # Build a project npx nx build cli +npx nx build-native desktop # Some apps have special build commands npx nx build state # Modern libs and apps have simple, all lowercase target names npx nx build @bitwarden/common # Legacy libs have a special naming convention and include the @bitwarden prefix @@ -203,6 +204,6 @@ npx nx reset ## Additional Resources -- [Nx Documentation](https://nx.dev/getting-started/intro) -- [Nx CLI Reference](https://nx.dev/packages/nx/documents/cli) +- [Nx Intro Documentation](https://nx.dev/getting-started/intro) +- [Nx CLI Commands Reference](https://nx.dev/docs/reference/nx-commands) - [Nx Workspace Configuration](https://nx.dev/reference/project-configuration) From e65d572401306788c130f0068c210743347b2699 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:39:37 -0500 Subject: [PATCH 25/37] [PM-24032] - adds a new upgrade plan button to side nav (#16687) * feat(billing): add required messages * feat(billing): Add upgrade from free account dialog * feat(billing): Add payment dialog for premium upgrade * feat(billing): Add Upgrade Payment Service * feat(billing): Add Upgrade flow service * feat(billing): Add purchase premium subscription method to client * fix(billing): allow for nullable taxId for families organizations * fix(billing): Fix Cart Summary Tests * temp-fix(billing): add currency pipe to pricing card component * fix(billing): Fix NX error This should compile just the library files and not its dependency files which was making it error * [PM-24032] new premium upgrade button in navigation * add missing outline none styles to focus state * update story to use correct background colors. change ring color * fixing broken stories by mocking services * updates requested by design and product * fixing tests * new icon for premium * fix: Update any type of private function * update account dialog * [PM-24032] updates to premium upgrade nav button * add margin bottom to prevent button focus outline from being chopped off * adding missing test * feat(billing): add upgrade error message * fix(billing): remove upgrade-flow service * feat(billing): add account billing client * fix(billing): Remove method from subscriber-billing client * fix(billing): rename and update upgrade payment component * fix(billing): Rename and update upgrade payment service * fix(billing): Rename and upgrade upgrade account component * fix(billing): Add unified upgrade dialog component * fix(billing): Update component and service to use new tax service * fix(billing): Update unified upgrade dialog * feat(billing): Add feature flag * feat(billing): Add vault dialog launch logic * fix(billing): Add stricter validation for payment component * fix(billing): Update custom dialog close button * fix(billing): Fix padding in cart summary component * fix(billing): Update payment method component spacing * fix(billing): Update Upgrade Payment component spacing * fix(billing): Update upgrade account component spacing * fix(billing): Fix accurate typing * hide continue button when coming from nav upgrade button * fixing incorrect conflict resolution * fixing tests * pr feedback * removing duplicate icon definition --------- Co-authored-by: Stephon Brown Co-authored-by: Bryan Cunningham --- .../unified-upgrade-dialog.component.html | 7 +- .../unified-upgrade-dialog.component.spec.ts | 240 ++++++++++++++++++ .../unified-upgrade-dialog.component.ts | 10 + .../upgrade-account.component.html | 10 +- .../upgrade-account.component.spec.ts | 42 +++ .../upgrade-account.component.ts | 8 +- .../upgrade-nav-button.component.html | 14 + .../upgrade-nav-button.component.ts | 36 +++ .../upgrade-nav-button.stories.ts | 65 +++++ .../services/upgrade-payment.service.spec.ts | 205 +++++++++++++++ .../services/upgrade-payment.service.ts | 26 +- .../upgrade-payment.component.html | 16 ++ .../upgrade-payment.component.ts | 4 + .../navigation-switcher.component.html | 36 +-- .../navigation-switcher.component.spec.ts | 69 ++++- .../navigation-switcher.component.ts | 3 + .../navigation-switcher.stories.ts | 20 ++ .../product-switcher.module.ts | 10 +- .../product-switcher.stories.ts | 20 ++ .../shared/product-switcher.service.spec.ts | 61 +++++ .../shared/product-switcher.service.ts | 30 ++- .../vault/individual-vault/vault.component.ts | 2 +- apps/web/src/locales/en/messages.json | 9 + libs/common/src/enums/feature-flag.enum.ts | 2 + 24 files changed, 914 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html create mode 100644 apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html index 3e7b797f00f..83c940da97f 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html @@ -1,5 +1,10 @@ @if (step() == PlanSelectionStep) { - + } @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) { (null); + hideContinueWithoutUpgradingButton = input(false); + planSelected = output(); + closeClicked = output(); +} + +@Component({ + selector: "app-upgrade-payment", + template: "", + standalone: true, +}) +class MockUpgradePaymentComponent { + selectedPlanId = input(null); + account = input(null); + goBack = output(); + complete = output(); +} + +describe("UnifiedUpgradeDialogComponent", () => { + let component: UnifiedUpgradeDialogComponent; + let fixture: ComponentFixture; + const mockDialogRef = mock(); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }; + + const defaultDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: null, + selectedPlan: null, + planSelectionStepTitleOverride: null, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: defaultDialogData }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with default values", () => { + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + expect(component["account"]()).toEqual(mockAccount); + expect(component["planSelectionStepTitleOverride"]()).toBeNull(); + }); + + it("should initialize with custom initial step", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: UnifiedUpgradeDialogStep.Payment, + selectedPlan: PersonalSubscriptionPricingTierIds.Premium, + }; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment); + expect(customComponent["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium); + }); + + describe("custom dialog title", () => { + it("should use null as default when no override is provided", () => { + expect(component["planSelectionStepTitleOverride"]()).toBeNull(); + }); + + it("should use custom title when provided in dialog config", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: UnifiedUpgradeDialogStep.PlanSelection, + selectedPlan: null, + planSelectionStepTitleOverride: "upgradeYourPlan", + }; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan"); + }); + }); + + describe("onPlanSelected", () => { + it("should set selected plan and move to payment step", () => { + component["onPlanSelected"](PersonalSubscriptionPricingTierIds.Premium); + + expect(component["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium); + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.Payment); + }); + }); + + describe("previousStep", () => { + it("should go back to plan selection and clear selected plan", () => { + component["step"].set(UnifiedUpgradeDialogStep.Payment); + component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium); + + component["previousStep"](); + + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + }); + }); + + describe("hideContinueWithoutUpgradingButton", () => { + it("should default to false when not provided", () => { + expect(component["hideContinueWithoutUpgradingButton"]()).toBe(false); + }); + + it("should be set to true when provided in dialog config", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: null, + selectedPlan: null, + hideContinueWithoutUpgradingButton: true, + }; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 092e6b163e6..e46c534ebdd 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -48,11 +48,15 @@ export type UnifiedUpgradeDialogResult = { * @property {Account} account - The user account information. * @property {UnifiedUpgradeDialogStep | null} [initialStep] - The initial step to show in the dialog, if any. * @property {PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any. + * @property {string | null} [dialogTitleMessageOverride] - Optional custom i18n key to override the default dialog title. + * @property {boolean} [hideContinueWithoutUpgradingButton] - Whether to hide the "Continue without upgrading" button. */ export type UnifiedUpgradeDialogParams = { account: Account; initialStep?: UnifiedUpgradeDialogStep | null; selectedPlan?: PersonalSubscriptionPricingTierId | null; + planSelectionStepTitleOverride?: string | null; + hideContinueWithoutUpgradingButton?: boolean; }; @Component({ @@ -73,6 +77,8 @@ export class UnifiedUpgradeDialogComponent implements OnInit { protected step = signal(UnifiedUpgradeDialogStep.PlanSelection); protected selectedPlan = signal(null); protected account = signal(null); + protected planSelectionStepTitleOverride = signal(null); + protected hideContinueWithoutUpgradingButton = signal(false); protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment; protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection; @@ -86,6 +92,10 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.account.set(this.params.account); this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection); this.selectedPlan.set(this.params.selectedPlan ?? null); + this.planSelectionStepTitleOverride.set(this.params.planSelectionStepTitleOverride ?? null); + this.hideContinueWithoutUpgradingButton.set( + this.params.hideContinueWithoutUpgradingButton ?? false, + ); } protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html index cdae1743cab..6106c6b08bb 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html @@ -17,7 +17,7 @@

- {{ "individualUpgradeWelcomeMessage" | i18n }} + {{ dialogTitle() | i18n }}

{{ "individualUpgradeDescriptionMessage" | i18n }} @@ -59,9 +59,11 @@

{{ "individualUpgradeTaxInformationMessage" | i18n }}

- + @if (!hideContinueWithoutUpgradingButton()) { + + }
diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts index 93cfa1da20f..27e69fcf0d4 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -146,4 +146,46 @@ describe("UpgradeAccountComponent", () => { expect(result).toBe(false); }); }); + + describe("hideContinueWithoutUpgradingButton", () => { + it("should show the continue without upgrading button by default", () => { + const button = fixture.nativeElement.querySelector('button[bitLink][linkType="primary"]'); + expect(button).toBeTruthy(); + }); + + it("should hide the continue without upgrading button when input is true", async () => { + TestBed.resetTestingModule(); + + mockI18nService.t.mockImplementation((key) => key); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of(mockPricingTiers), + ); + + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + UpgradeAccountComponent, + PricingCardComponent, + CdkTrapFocus, + ], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + ], + }) + .overrideComponent(UpgradeAccountComponent, { + remove: { imports: [BillingServicesModule] }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UpgradeAccountComponent); + customFixture.componentRef.setInput("hideContinueWithoutUpgradingButton", true); + customFixture.detectChanges(); + + const button = customFixture.nativeElement.querySelector( + 'button[bitLink][linkType="primary"]', + ); + expect(button).toBeNull(); + }); + }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index e9cb390d604..a9d9b959282 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -1,6 +1,6 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, OnInit, output, signal } from "@angular/core"; +import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -52,6 +52,8 @@ type CardDetails = { templateUrl: "./upgrade-account.component.html", }) export class UpgradeAccountComponent implements OnInit { + dialogTitleMessageOverride = input(null); + hideContinueWithoutUpgradingButton = input(false); planSelected = output(); closeClicked = output(); protected loading = signal(true); @@ -62,6 +64,10 @@ export class UpgradeAccountComponent implements OnInit { protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium; protected closeStatus = UpgradeAccountStatus.Closed; + protected dialogTitle = computed(() => { + return this.dialogTitleMessageOverride() || "individualUpgradeWelcomeMessage"; + }); + constructor( private i18nService: I18nService, private subscriptionPricingService: SubscriptionPricingService, diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html new file mode 100644 index 00000000000..115c0be86a2 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html @@ -0,0 +1,14 @@ +
+
+ + +
+
diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts new file mode 100644 index 00000000000..c24e4fbdade --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts @@ -0,0 +1,36 @@ +import { Component, inject } from "@angular/core"; +import { firstValueFrom, lastValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { UnifiedUpgradeDialogComponent } from "../../unified-upgrade-dialog/unified-upgrade-dialog.component"; + +@Component({ + selector: "app-upgrade-nav-button", + imports: [I18nPipe], + templateUrl: "./upgrade-nav-button.component.html", + standalone: true, +}) +export class UpgradeNavButtonComponent { + private dialogService = inject(DialogService); + private accountService = inject(AccountService); + + openUpgradeDialog = async () => { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return; + } + + const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: { + account, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }); + + await lastValueFrom(dialogRef.closed); + }; +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts new file mode 100644 index 00000000000..abc48ff2528 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts @@ -0,0 +1,65 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService, I18nMockService } from "@bitwarden/components"; +import { UpgradeNavButtonComponent } from "@bitwarden/web-vault/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component"; + +export default { + title: "Billing/Upgrade Navigation Button", + component: UpgradeNavButtonComponent, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + upgradeYourPlan: "Upgrade your plan", + }); + }, + }, + { + provide: DialogService, + useValue: { + open: () => ({ + closed: of({}), + }), + }, + }, + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }), + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=858-44274&t=EiNqDGuccfhF14on-1", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` +
+ +
+ `, + }), +}; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 49f3e10c582..653a77dccdc 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -1,8 +1,14 @@ import { TestBed } from "@angular/core/testing"; import { mock, mockReset } from "jest-mock-extended"; +import { of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -22,6 +28,8 @@ describe("UpgradePaymentService", () => { const mockLogService = mock(); const mockApiService = mock(); const mockSyncService = mock(); + const mockOrganizationService = mock(); + const mockAccountService = mock(); mockApiService.refreshIdentityToken.mockResolvedValue({}); mockSyncService.fullSync.mockResolvedValue(true); @@ -94,6 +102,11 @@ describe("UpgradePaymentService", () => { mockReset(mockAccountBillingClient); mockReset(mockTaxClient); mockReset(mockLogService); + mockReset(mockOrganizationService); + mockReset(mockAccountService); + + mockAccountService.activeAccount$ = of(null); + mockOrganizationService.organizations$.mockReturnValue(of([])); TestBed.configureTestingModule({ providers: [ @@ -108,12 +121,204 @@ describe("UpgradePaymentService", () => { { provide: LogService, useValue: mockLogService }, { provide: ApiService, useValue: mockApiService }, { provide: SyncService, useValue: mockSyncService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: AccountService, useValue: mockAccountService }, ], }); sut = TestBed.inject(UpgradePaymentService); }); + describe("userIsOwnerOfFreeOrg$", () => { + it("should return true when user is owner of a free organization", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + const paidOrgData = { + id: "org-1", + name: "Paid Org", + useTotp: true, // useTotp = true means NOT free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const freeOrgData = { + id: "org-2", + name: "Free Org", + useTotp: false, // useTotp = false means IS free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const paidOrg = new Organization(paidOrgData); + const freeOrg = new Organization(freeOrgData); + const mockOrganizations = [paidOrg, freeOrg]; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations)); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + ); + + // Act & Assert + service.userIsOwnerOfFreeOrg$.subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return false when user is not owner of any free organization", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + const paidOrgData = { + id: "org-1", + name: "Paid Org", + useTotp: true, // useTotp = true means NOT free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const freeOrgData = { + id: "org-2", + name: "Free Org", + useTotp: false, // useTotp = false means IS free + type: OrganizationUserType.User, // Not owner + } as OrganizationData; + + const paidOrg = new Organization(paidOrgData); + const freeOrg = new Organization(freeOrgData); + const mockOrganizations = [paidOrg, freeOrg]; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations)); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + ); + + // Act & Assert + service.userIsOwnerOfFreeOrg$.subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("should return false when user has no organizations", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of([])); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + ); + + // Act & Assert + service.userIsOwnerOfFreeOrg$.subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); + + describe("adminConsoleRouteForOwnedOrganization$", () => { + it("should return the admin console route for the first free organization the user owns", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + const paidOrgData = { + id: "org-1", + name: "Paid Org", + useTotp: true, // useTotp = true means NOT free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const freeOrgData = { + id: "org-2", + name: "Free Org", + useTotp: false, // useTotp = false means IS free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const paidOrg = new Organization(paidOrgData); + const freeOrg = new Organization(freeOrgData); + const mockOrganizations = [paidOrg, freeOrg]; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations)); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + ); + + // Act & Assert + service.adminConsoleRouteForOwnedOrganization$.subscribe((result) => { + expect(result).toBe("/organizations/org-2/billing/subscription"); + done(); + }); + }); + }); + describe("calculateEstimatedTax", () => { it("should calculate tax for premium plan", async () => { // Arrange diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index cabd148a539..11dd10d4bb8 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -1,8 +1,12 @@ import { Injectable } from "@angular/core"; +import { defaultIfEmpty, find, map, mergeMap, Observable, switchMap } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; -import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationBillingServiceAbstraction, SubscriptionInformation, @@ -53,8 +57,28 @@ export class UpgradePaymentService { private logService: LogService, private apiService: ApiService, private syncService: SyncService, + private organizationService: OrganizationService, + private accountService: AccountService, ) {} + userIsOwnerOfFreeOrg$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((id) => this.organizationService.organizations$(id)), + mergeMap((userOrganizations) => userOrganizations), + find((org) => org.isFreeOrg && org.isOwner), + defaultIfEmpty(false), + map((value) => value instanceof Organization), + ); + + adminConsoleRouteForOwnedOrganization$: Observable = + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((id) => this.organizationService.organizations$(id)), + mergeMap((userOrganizations) => userOrganizations), + find((org) => org.isFreeOrg && org.isOwner), + map((org) => `/organizations/${org!.id}/billing/subscription`), + ); + /** * Calculate estimated tax for the selected plan */ diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index 4230d4038cd..7b92ae10947 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -4,6 +4,22 @@
@if (isFamiliesPlan) { + @if (userIsOwnerOfFreeOrg$ | async) { +
+ + {{ "formWillCreateNewFamiliesOrgMessage" | i18n }} + + {{ "upgradeNow" | i18n }} + + + +
+ }
{{ "organizationName" | i18n }} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 81a4c9191a5..33568435d01 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -104,6 +104,10 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { private upgradePaymentService: UpgradePaymentService, ) {} + protected userIsOwnerOfFreeOrg$ = this.upgradePaymentService.userIsOwnerOfFreeOrg$; + protected adminConsoleRouteForOwnedOrganization$ = + this.upgradePaymentService.adminConsoleRouteForOwnedOrganization$; + async ngOnInit(): Promise { if (!this.isFamiliesPlan) { this.formGroup.controls.organizationName.disable(); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html index f8ebfa60451..d39156ef4a2 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html @@ -1,19 +1,25 @@
- - - - -
1) { + + + + } + + @if (shouldShowPremiumUpgradeButton$ | async) { + + } + + @let moreProducts = moreProducts$ | async; + @if (moreProducts && moreProducts.length > 0) { +
{{ "moreFromBitwarden" | i18n }}
@@ -57,5 +63,5 @@
- + }
diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts index d1b82bc114d..38e7d12f278 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts @@ -1,3 +1,4 @@ +import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { ActivatedRoute, RouterModule } from "@angular/router"; @@ -15,6 +16,13 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s import { NavigationProductSwitcherComponent } from "./navigation-switcher.component"; +@Component({ + selector: "app-upgrade-nav-button", + template: "
Upgrade Nav Button
", + standalone: true, +}) +class MockUpgradeNavButtonComponent {} + Object.defineProperty(window, "matchMedia", { writable: true, value: jest.fn().mockImplementation((query) => ({ @@ -41,13 +49,16 @@ describe("NavigationProductSwitcherComponent", () => { other: [], }); + const mockShouldShowPremiumUpgradeButton$ = new BehaviorSubject(false); + beforeEach(async () => { productSwitcherService = mock(); productSwitcherService.products$ = mockProducts$; + productSwitcherService.shouldShowPremiumUpgradeButton$ = mockShouldShowPremiumUpgradeButton$; mockProducts$.next({ bento: [], other: [] }); await TestBed.configureTestingModule({ - imports: [RouterModule, NavigationModule, IconButtonModule], + imports: [RouterModule, NavigationModule, IconButtonModule, MockUpgradeNavButtonComponent], declarations: [NavigationProductSwitcherComponent, I18nPipe], providers: [ { provide: ProductSwitcherService, useValue: productSwitcherService }, @@ -187,15 +198,23 @@ describe("NavigationProductSwitcherComponent", () => { }, isActive: true, }, + { + name: "Test Product", + icon: "bwi-lock", + marketingRoute: { + route: "https://www.example.com/", + external: true, + }, + }, ], other: [], }); fixture.detectChanges(); - const navItem = fixture.debugElement.query(By.directive(NavItemComponent)); + const navItem = fixture.debugElement.queryAll(By.directive(NavItemComponent)); - expect(navItem.componentInstance.forceActiveStyles()).toBe(true); + expect(navItem[0].componentInstance.forceActiveStyles()).toBe(true); }); }); @@ -218,18 +237,56 @@ describe("NavigationProductSwitcherComponent", () => { expect(links[0].textContent).toContain("Password Manager"); expect(links[1].textContent).toContain("Secret Manager"); }); + + it("does not show products list when there is only one item", () => { + mockProducts$.next({ + bento: [{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }], + other: [], + }); + + fixture.detectChanges(); + + const navItems = fixture.debugElement.queryAll(By.directive(NavItemComponent)); + + expect(navItems.length).toBe(0); + }); }); it("links to `appRoute`", () => { mockProducts$.next({ - bento: [{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }], + bento: [ + { isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }, + { isActive: false, name: "Secret Manager", icon: "bwi-lock", appRoute: "/sm" }, + ], other: [], }); fixture.detectChanges(); - const link = fixture.nativeElement.querySelector("a"); + const links = fixture.nativeElement.querySelectorAll("a"); - expect(link.getAttribute("href")).toBe("/vault"); + expect(links[0].getAttribute("href")).toBe("/vault"); + }); + + describe("upgrade nav button", () => { + it("shows upgrade nav button when shouldShowPremiumUpgradeButton$ is true", () => { + mockShouldShowPremiumUpgradeButton$.next(true); + mockProducts$.next({ + bento: [], + other: [ + { + name: "Organizations", + icon: "bwi-lock", + marketingRoute: { route: "https://www.example.com/", external: true }, + }, + ], + }); + + fixture.detectChanges(); + + const upgradeButton = fixture.nativeElement.querySelector("app-upgrade-nav-button"); + + expect(upgradeButton).toBeTruthy(); + }); }); }); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts index 9d4250087af..8a02fdd7647 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts @@ -11,6 +11,9 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s export class NavigationProductSwitcherComponent { constructor(private productSwitcherService: ProductSwitcherService) {} + protected readonly shouldShowPremiumUpgradeButton$: Observable = + this.productSwitcherService.shouldShowPremiumUpgradeButton$; + protected readonly accessibleProducts$: Observable = this.productSwitcherService.products$.pipe(map((products) => products.bento ?? [])); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index b5f10f9158e..fe2821e3d2c 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -9,6 +9,9 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag, FeatureFlagValueType } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -78,6 +81,18 @@ class MockPlatformUtilsService implements Partial { } } +class MockBillingAccountProfileStateService implements Partial { + hasPremiumFromAnySource$(userId: UserId): Observable { + return of(false); + } +} + +class MockConfigService implements Partial { + getFeatureFlag$(key: Flag): Observable> { + return of(false); + } +} + @Component({ selector: "story-layout", template: ``, @@ -117,6 +132,11 @@ export default { { provide: ProviderService, useClass: MockProviderService }, { provide: SyncService, useClass: MockSyncService }, { provide: PlatformUtilsService, useClass: MockPlatformUtilsService }, + { + provide: BillingAccountProfileStateService, + useClass: MockBillingAccountProfileStateService, + }, + { provide: ConfigService, useClass: MockConfigService }, ProductSwitcherService, { provide: I18nService, diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts index b78b1ce6b96..1d0353105c6 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts @@ -5,6 +5,7 @@ import { RouterModule } from "@angular/router"; import { NavigationModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { UpgradeNavButtonComponent } from "../../billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component"; import { SharedModule } from "../../shared"; import { NavigationProductSwitcherComponent } from "./navigation-switcher/navigation-switcher.component"; @@ -12,7 +13,14 @@ import { ProductSwitcherContentComponent } from "./product-switcher-content.comp import { ProductSwitcherComponent } from "./product-switcher.component"; @NgModule({ - imports: [SharedModule, A11yModule, RouterModule, NavigationModule, I18nPipe], + imports: [ + SharedModule, + A11yModule, + RouterModule, + NavigationModule, + I18nPipe, + UpgradeNavButtonComponent, + ], declarations: [ ProductSwitcherComponent, ProductSwitcherContentComponent, diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 18cb8e26c70..66b6a6fb3cf 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -10,6 +10,9 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag, FeatureFlagValueType } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -78,6 +81,18 @@ class MockPlatformUtilsService implements Partial { } } +class MockBillingAccountProfileStateService implements Partial { + hasPremiumFromAnySource$(userId: UserId): Observable { + return of(false); + } +} + +class MockConfigService implements Partial { + getFeatureFlag$(key: Flag): Observable> { + return of(false); + } +} + @Component({ selector: "story-layout", template: ``, @@ -114,6 +129,11 @@ export default { MockProviderService, { provide: SyncService, useClass: MockSyncService }, { provide: PlatformUtilsService, useClass: MockPlatformUtilsService }, + { + provide: BillingAccountProfileStateService, + useClass: MockBillingAccountProfileStateService, + }, + { provide: ConfigService, useClass: MockConfigService }, MockPlatformUtilsService, ProductSwitcherService, { diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts index efbeb786f77..f7f319f2fab 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts @@ -11,6 +11,8 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -27,6 +29,8 @@ describe("ProductSwitcherService", () => { let providerService: MockProxy; let accountService: FakeAccountService; let platformUtilsService: MockProxy; + let billingAccountProfileStateService: MockProxy; + let configService: MockProxy; let activeRouteParams = convertToParamMap({ organizationId: "1234" }); let singleOrgPolicyEnabled = false; const getLastSync = jest.fn().mockResolvedValue(new Date("2024-05-14")); @@ -48,6 +52,8 @@ describe("ProductSwitcherService", () => { providerService = mock(); accountService = mockAccountServiceWith(userId); platformUtilsService = mock(); + billingAccountProfileStateService = mock(); + configService = mock(); router.url = "/"; router.events = of({}); @@ -85,6 +91,8 @@ describe("ProductSwitcherService", () => { policyAppliesToUser$: () => of(singleOrgPolicyEnabled), }, }, + { provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService }, + { provide: ConfigService, useValue: configService }, ], }); }); @@ -325,4 +333,57 @@ describe("ProductSwitcherService", () => { expect(appRoute).toEqual(["/organizations", "111-22-33"]); }); + + describe("shouldShowPremiumUpgradeButton$", () => { + it("returns false when feature flag is disabled", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + initiateService(); + + const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$); + + expect(shouldShow).toBe(false); + }); + + it("returns false when there is no active account", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + accountService.activeAccount$ = of(null); + billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + initiateService(); + + const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$); + + expect(shouldShow).toBe(false); + }); + + it("returns true when feature flag is enabled, account exists, and user has no premium", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + initiateService(); + + const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$); + + expect(shouldShow).toBe(true); + expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith( + userId, + ); + }); + + it("returns false when feature flag is enabled, account exists, but user has premium", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + + initiateService(); + + const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$); + + expect(shouldShow).toBe(false); + expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith( + userId, + ); + }); + }); }); diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts index 95acf4447e9..6cfecd59403 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts @@ -2,7 +2,16 @@ // @ts-strict-ignore import { Injectable } from "@angular/core"; import { ActivatedRoute, NavigationEnd, NavigationStart, ParamMap, Router } from "@angular/router"; -import { combineLatest, filter, map, Observable, ReplaySubject, startWith, switchMap } from "rxjs"; +import { + combineLatest, + filter, + map, + Observable, + of, + ReplaySubject, + startWith, + switchMap, +} from "rxjs"; import { canAccessOrgAdmin, @@ -15,6 +24,9 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -99,6 +111,8 @@ export class ProductSwitcherService { private platformUtilsService: PlatformUtilsService, private policyService: PolicyService, private i18nService: I18nService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private configService: ConfigService, ) { this.pollUntilSynced(); } @@ -118,6 +132,20 @@ export class ProductSwitcherService { switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)), ); + shouldShowPremiumUpgradeButton$: Observable = combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton), + this.accountService.activeAccount$, + ]).pipe( + switchMap(([featureFlag, account]) => { + if (!featureFlag || !account) { + return of(false); + } + return this.billingAccountProfileStateService + .hasPremiumFromAnySource$(account.id) + .pipe(map((hasPremium) => !hasPremium)); + }), + ); + products$: Observable<{ bento: ProductSwitcherItem[]; other: ProductSwitcherItem[]; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index cdb40c843a2..b507991606f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -91,6 +91,7 @@ import { DefaultCipherFormConfigService, PasswordRepromptService, } from "@bitwarden/vault"; +import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; @@ -103,7 +104,6 @@ import { CollectionDialogTabType, openCollectionDialog, } from "../../admin-console/organizations/shared/components/collection-dialog"; -import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services/unified-upgrade-prompt.service"; import { SharedModule } from "../../shared/shared.module"; import { AssignCollectionsWebComponent } from "../components/assign-collections"; import { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index af0374701ca..3ba0bd95a31 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11805,6 +11805,15 @@ "continueWithoutUpgrading": { "message": "Continue without upgrading" }, + "upgradeYourPlan": { + "message": "Upgrade your plan" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "formWillCreateNewFamiliesOrgMessage": { + "message": "Completing this form will create a new Families organization. You can upgrade your Free organization from the Admin Console." + }, "upgradeErrorMessage": { "message": "We encountered an error while processing your upgrade. Please try again." } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 78113d74cb8..8045a7b55f0 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -25,6 +25,7 @@ export enum FeatureFlag { PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", + PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", @@ -101,6 +102,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, + [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, From 799711d199adad26f2c8878d80d59a2c76ff0c61 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:05:12 -0700 Subject: [PATCH 26/37] [deps] Autofill: Update cross-env to v10.1.0 (#16844) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8108e04836a..8da94affe5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -131,7 +131,7 @@ "chromatic": "13.1.2", "concurrently": "9.2.0", "copy-webpack-plugin": "13.0.0", - "cross-env": "10.0.0", + "cross-env": "10.1.0", "css-loader": "7.1.2", "electron": "36.9.3", "electron-builder": "26.0.12", @@ -19536,9 +19536,9 @@ "peer": true }, "node_modules/cross-env": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.0.0.tgz", - "integrity": "sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 977b1f71774..5f2bb4fdfe6 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "chromatic": "13.1.2", "concurrently": "9.2.0", "copy-webpack-plugin": "13.0.0", - "cross-env": "10.0.0", + "cross-env": "10.1.0", "css-loader": "7.1.2", "electron": "36.9.3", "electron-builder": "26.0.12", From 48c466436e816c40cede130adc7d1ae762138d89 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 14 Oct 2025 16:17:23 -0400 Subject: [PATCH 27/37] Fixed cipherlistview$ to return cipherlistview (#16861) --- .../src/vault/services/cipher.service.ts | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index f0e2f9f9404..8de7f48c2ba 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -17,6 +17,7 @@ import { MessageSender } from "@bitwarden/common/platform/messaging"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { ApiService } from "../../abstractions/api.service"; import { AccountService } from "../../auth/abstractions/account.service"; @@ -158,9 +159,9 @@ export class CipherService implements CipherServiceAbstraction { ), ), switchMap(async (ciphers) => { - const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId); + const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false); await this.setFailedDecryptedCiphers(failures, userId); - return decrypted.sort(this.getLocaleSortingFunction()); + return decrypted; }), ); }), @@ -489,7 +490,7 @@ export class CipherService implements CipherServiceAbstraction { if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) { const decryptStartTime = performance.now(); - const result = await this.decryptCiphersWithSdk(ciphers, userId); + const result = await this.decryptCiphersWithSdk(ciphers, userId, true); this.logService.measure(decryptStartTime, "Vault", "CipherService", "decrypt complete", [ ["Items", ciphers.length], @@ -2059,21 +2060,50 @@ export class CipherService implements CipherServiceAbstraction { } /** - * Decrypts the provided ciphers using the SDK. - * @param ciphers The ciphers to decrypt. - * @param userId The user ID to use for decryption. - * @returns The decrypted ciphers. + * Decrypts the provided ciphers using the SDK with full CipherView decryption. + * @param ciphers The encrypted ciphers to decrypt. + * @param userId The user ID to use for decryption keys. + * @param fullDecryption When true, returns full CipherView objects with all fields decrypted. + * @returns A tuple containing: + * - Array of fully decrypted CipherView objects, sorted by locale + * - Array of CipherView objects that failed to decrypt (marked with decryptionFailure flag) * @private */ private async decryptCiphersWithSdk( ciphers: Cipher[], userId: UserId, - ): Promise<[CipherView[], CipherView[]]> { + fullDecryption: true, + ): Promise<[CipherView[], CipherView[]]>; + /** + * Decrypts the provided ciphers using the SDK with lightweight CipherListView decryption. + * @param ciphers The encrypted ciphers to decrypt. + * @param userId The user ID to use for decryption keys. + * @param fullDecryption When false, returns lightweight CipherListView objects for better performance. + * @returns A tuple containing: + * - Array of lightweight CipherListView objects, sorted by locale + * - Array of CipherView objects that failed to decrypt (marked with decryptionFailure flag) + * @private + */ + private async decryptCiphersWithSdk( + ciphers: Cipher[], + userId: UserId, + fullDecryption: false, + ): Promise<[CipherListView[], CipherView[]]>; + + private async decryptCiphersWithSdk( + ciphers: Cipher[], + userId: UserId, + fullDecryption: boolean = true, + ): Promise<[CipherViewLike[], CipherView[]]> { const [decrypted, failures] = await this.cipherEncryptionService.decryptManyWithFailures( ciphers, userId, ); - const decryptedViews = await Promise.all(decrypted.map((c) => this.getFullCipherView(c))); + + const decryptedViews = fullDecryption + ? await Promise.all(decrypted.map((c) => this.getFullCipherView(c))) + : decrypted; + const failedViews = failures.map((c) => { const cipher_view = new CipherView(c); cipher_view.name = "[error: cannot decrypt]"; From 98af7a13ed1ca812687dd77a5637ac36a5c29ef2 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 14 Oct 2025 17:41:05 -0400 Subject: [PATCH 28/37] [PM-19152] Archive in Web (#16686) * archive and unarchive an individual item * bulk archive and unachive * updates to text strings for archive empty state and tooltips * update translation keys to have an archive verb and noun differentiation * if premium member loses premium and has archive items. apply filter changes, and item more option changes * updating unArchive text * unarchive an archived item on edit if user loses premium * updates for unarchive btn, refactor archive flag for less churn * add services to cipher form stories * add refresh to archive calls in vault, update bulk archive copy * Do not show archive ability for deleted items * add archive check for login menu actions * remove assign to collections for archive filter * update bulk success message * add error handling for archive methods * fix null reference check * add unarchive icon --------- Co-authored-by: Nick Krantz --- apps/browser/src/_locales/en/messages.json | 10 +- .../item-more-options.component.ts | 2 +- .../popup/settings/archive.component.html | 2 +- .../vault/popup/settings/archive.component.ts | 2 +- apps/desktop/src/locales/en/messages.json | 10 +- .../vault-cipher-row.component.html | 16 +- .../vault-items/vault-cipher-row.component.ts | 61 +++++++- .../vault-items/vault-item-event.ts | 4 +- .../vault-items/vault-items.component.html | 18 +++ .../vault-items/vault-items.component.ts | 56 ++++++- .../components/vault-filter.component.ts | 7 +- .../individual-vault/vault.component.html | 2 + .../vault/individual-vault/vault.component.ts | 146 +++++++++++++++++- apps/web/src/locales/en/messages.json | 37 ++++- .../abstractions/cipher-archive.service.ts | 1 + .../default-cipher-archive.service.ts | 4 + .../src/cipher-form/cipher-form.stories.ts | 16 +- .../components/cipher-form.component.spec.ts | 35 ++++- .../components/cipher-form.component.ts | 19 ++- 19 files changed, 414 insertions(+), 34 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index d91a33c6796..a5d0852eed5 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -558,7 +558,7 @@ "message": "Archive", "description": "Verb" }, - "unarchive": { + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -570,11 +570,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index ebcd8707597..83535b09e66 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -302,7 +302,7 @@ export class ItemMoreOptionsComponent { await this.cipherArchiveService.archiveWithServer(this.cipher.id as CipherId, activeUserId); this.toastService.showToast({ variant: "success", - message: this.i18nService.t("itemSentToArchive"), + message: this.i18nService.t("itemWasSentToArchive"), }); } } diff --git a/apps/browser/src/vault/popup/settings/archive.component.html b/apps/browser/src/vault/popup/settings/archive.component.html index 5fb57814fff..faaf0243fc7 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.html +++ b/apps/browser/src/vault/popup/settings/archive.component.html @@ -49,7 +49,7 @@ {{ "clone" | i18n }} - + + @if (showArchiveButton) { + + } + + @if (showUnArchiveButton) { + + } + + + + + + - +
+ +

{{ "phishingPageSummary" | i18n }}

+ + + {{ phishingHost$ | async }} + + + +

+ {{ "phishingPageExplanation1" | i18n }}Phishing.Database{{ "phishingPageExplanation2" | i18n }} +

+ + + {{ "phishingPageLearnMore" | i18n }} + +
+ +
+ + +
diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts index dc6ab2d329e..4712c94c89e 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts @@ -1,10 +1,10 @@ // eslint-disable-next-line no-restricted-imports import { CommonModule } from "@angular/common"; // eslint-disable-next-line no-restricted-imports -import { Component, OnDestroy } from "@angular/core"; +import { Component, inject } from "@angular/core"; // eslint-disable-next-line no-restricted-imports import { ActivatedRoute, RouterModule } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -13,12 +13,16 @@ import { CheckboxModule, FormFieldModule, IconModule, + IconTileComponent, LinkModule, + CalloutComponent, + TypographyModule, } from "@bitwarden/components"; import { PhishingDetectionService } from "../services/phishing-detection.service"; @Component({ + selector: "dirt-phishing-warning", standalone: true, templateUrl: "phishing-warning.component.html", imports: [ @@ -31,18 +35,16 @@ import { PhishingDetectionService } from "../services/phishing-detection.service CheckboxModule, ButtonModule, RouterModule, + IconTileComponent, + CalloutComponent, + TypographyModule, ], }) -export class PhishingWarning implements OnDestroy { - phishingHost = ""; - - private destroy$ = new Subject(); - - constructor(private activatedRoute: ActivatedRoute) { - this.activatedRoute.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => { - this.phishingHost = params.get("phishingHost") || ""; - }); - } +export class PhishingWarning { + private activatedRoute = inject(ActivatedRoute); + protected phishingHost$ = this.activatedRoute.queryParamMap.pipe( + map((params) => params.get("phishingHost") || ""), + ); async closeTab() { await PhishingDetectionService.requestClosePhishingWarningPage(); @@ -50,9 +52,4 @@ export class PhishingWarning implements OnDestroy { async continueAnyway() { await PhishingDetectionService.requestContinueToDangerousUrl(); } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts new file mode 100644 index 00000000000..30d3b7faeee --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts @@ -0,0 +1,137 @@ +// TODO: This needs to be dealt with by moving this folder or updating the lint rule. +/* eslint-disable no-restricted-imports */ +import { ActivatedRoute, RouterModule } from "@angular/router"; +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { BehaviorSubject, of } from "rxjs"; + +import { DeactivatedOrg } from "@bitwarden/assets/svg"; +import { ClientType } from "@bitwarden/common/enums"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components"; + +import { PhishingWarning } from "./phishing-warning.component"; +import { ProtectedByComponent } from "./protected-by-component"; + +class MockPlatformUtilsService implements Partial { + getApplicationVersion = () => Promise.resolve("Version 2024.1.1"); + getClientType = () => ClientType.Web; +} + +/** + * Helper function to create ActivatedRoute mock with query parameters + */ +function mockActivatedRoute(queryParams: Record) { + return { + provide: ActivatedRoute, + useValue: { + queryParamMap: of({ + get: (key: string) => queryParams[key] || null, + }), + queryParams: of(queryParams), + }, + }; +} + +type StoryArgs = { + phishingHost: string; +}; + +export default { + title: "Browser/DIRT/Phishing Warning", + component: PhishingWarning, + decorators: [ + moduleMetadata({ + imports: [AnonLayoutComponent, ProtectedByComponent, RouterModule], + providers: [ + { + provide: PlatformUtilsService, + useClass: MockPlatformUtilsService, + }, + { + provide: I18nService, + useFactory: () => + new I18nMockService({ + accessing: "Accessing", + appLogoLabel: "Bitwarden logo", + phishingPageTitleV2: "Phishing attempt detected", + phishingPageCloseTabV2: "Close this tab", + phishingPageSummary: + "The site you are attempting to visit is a known malicious site and a security risk.", + phishingPageContinueV2: "Continue to this site (not recommended)", + phishingPageExplanation1: "This site was found in ", + phishingPageExplanation2: + ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + phishingPageLearnMore: "Learn more about phishing detection", + protectedBy: (product) => `Protected by ${product}`, + learnMore: "Learn more", + danger: "error", + }), + }, + { + provide: EnvironmentService, + useValue: { + environment$: new BehaviorSubject({ + getHostname() { + return "bitwarden.com"; + }, + }).asObservable(), + }, + }, + mockActivatedRoute({ phishingHost: "malicious-example.com" }), + ], + }), + ], + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + `, + }), + argTypes: { + phishingHost: { + control: "text", + description: "The suspicious host that was blocked", + }, + }, + args: { + phishingHost: "malicious-example.com", + pageIcon: DeactivatedOrg, + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + phishingHost: "malicious-example.com", + }, + decorators: [ + moduleMetadata({ + providers: [mockActivatedRoute({ phishingHost: "malicious-example.com" })], + }), + ], +}; + +export const LongHostname: Story = { + args: { + phishingHost: "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com", + }, + decorators: [ + moduleMetadata({ + providers: [ + mockActivatedRoute({ + phishingHost: + "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com", + }), + ], + }), + ], +}; diff --git a/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html new file mode 100644 index 00000000000..d9f26bc9c90 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html @@ -0,0 +1 @@ +{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }} diff --git a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts similarity index 63% rename from apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts rename to apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts index 1a1e6059204..298c7acd38e 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts +++ b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts @@ -4,13 +4,12 @@ import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ButtonModule } from "@bitwarden/components"; +import { ButtonModule, LinkModule } from "@bitwarden/components"; @Component({ + selector: "dirt-phishing-protected-by", standalone: true, - templateUrl: "learn-more-component.html", - imports: [CommonModule, CommonModule, JslibModule, ButtonModule], + templateUrl: "protected-by-component.html", + imports: [CommonModule, CommonModule, JslibModule, ButtonModule, LinkModule], }) -export class LearnMoreComponent { - constructor() {} -} +export class ProtectedByComponent {} diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 54245ae17b4..179431b155c 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -116,15 +116,15 @@ export class PhishingDetectionService { /** * Sends a message to the phishing detection service to close the warning page */ - static requestClosePhishingWarningPage(): void { - void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close }); + static async requestClosePhishingWarningPage() { + await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close }); } /** * Sends a message to the phishing detection service to continue to the caught url */ static async requestContinueToDangerousUrl() { - void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue }); + await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue }); } /** diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 17a812f451c..cb5e597e78c 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -24,7 +24,6 @@ import { VaultIcon, LockIcon, TwoFactorAuthSecurityKeyIcon, - DeactivatedOrg, } from "@bitwarden/assets/svg"; import { LoginComponent, @@ -54,8 +53,8 @@ import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-doma import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component"; import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component"; -import { LearnMoreComponent } from "../dirt/phishing-detection/pages/learn-more-component"; import { PhishingWarning } from "../dirt/phishing-detection/pages/phishing-warning.component"; +import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected-by-component"; import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service"; @@ -718,14 +717,13 @@ const routes: Routes = [ }, { path: "", - component: LearnMoreComponent, + component: ProtectedByComponent, outlet: "secondary", }, ], data: { - pageIcon: DeactivatedOrg, - pageTitle: "Bitwarden blocked it!", - pageSubtitle: "Bitwarden blocked a known phishing site from loading.", + hideIcon: true, + hideBackgroundIllustration: true, showReadonlyHostname: true, } satisfies AnonLayoutWrapperData, }, diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss index b3d14e65061..01b9d3f05d5 100644 --- a/apps/browser/src/popup/scss/base.scss +++ b/apps/browser/src/popup/scss/base.scss @@ -382,7 +382,7 @@ app-root { } } -main:not(popup-page main) { +main:not(popup-page main):not(auth-anon-layout main) { position: absolute; top: 44px; bottom: 0; diff --git a/libs/angular/src/scss/bwicons/styles/style.scss b/libs/angular/src/scss/bwicons/styles/style.scss index 755088a92a0..93f5856e3df 100644 --- a/libs/angular/src/scss/bwicons/styles/style.scss +++ b/libs/angular/src/scss/bwicons/styles/style.scss @@ -100,6 +100,7 @@ $icomoon-font-path: "~@bitwarden/angular/src/scss/bwicons/fonts/" !default; } // For new icons - add their glyph name and value to the map below +// Also add to `libs/components/src/shared/icon.ts` $icons: ( "angle-down": "\e900", "angle-left": "\e901", diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.html b/libs/components/src/anon-layout/anon-layout-wrapper.component.html index 3509e4dcdb0..b08418c39a1 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.html +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.html @@ -6,6 +6,7 @@ [maxWidth]="maxWidth" [hideCardWrapper]="hideCardWrapper" [hideIcon]="hideIcon" + [hideBackgroundIllustration]="hideBackgroundIllustration" > diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 13c2e727477..5785609189c 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -44,6 +44,10 @@ export interface AnonLayoutWrapperData { * Hide the card that wraps the default content. Defaults to false. */ hideCardWrapper?: boolean; + /** + * Hides the background illustration. Defaults to false. + */ + hideBackgroundIllustration?: boolean; } @Component({ @@ -60,6 +64,7 @@ export class AnonLayoutWrapperComponent implements OnInit { protected maxWidth?: AnonLayoutMaxWidth | null; protected hideCardWrapper?: boolean | null; protected hideIcon?: boolean | null; + protected hideBackgroundIllustration?: boolean | null; constructor( private router: Router, @@ -117,6 +122,7 @@ export class AnonLayoutWrapperComponent implements OnInit { this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); this.maxWidth = firstChildRouteData["maxWidth"]; this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]); + this.hideBackgroundIllustration = Boolean(firstChildRouteData["hideBackgroundIllustration"]); } private listenForServiceDataChanges() { @@ -157,6 +163,10 @@ export class AnonLayoutWrapperComponent implements OnInit { this.hideCardWrapper = data.hideCardWrapper; } + if (data.hideBackgroundIllustration !== undefined) { + this.hideBackgroundIllustration = data.hideBackgroundIllustration; + } + if (data.hideIcon !== undefined) { this.hideIcon = data.hideIcon; } @@ -188,5 +198,6 @@ export class AnonLayoutWrapperComponent implements OnInit { this.maxWidth = null; this.hideCardWrapper = null; this.hideIcon = null; + this.hideBackgroundIllustration = null; } } diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index c66647c482d..84ad8742051 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -68,16 +68,18 @@ -
- -
-
- -
+ @if (!hideBackgroundIllustration()) { +
+ +
+
+ +
+ } diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index c0beb5bce56..9decb7cb4f7 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -51,6 +51,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { readonly hideFooter = input(false); readonly hideIcon = input(false); readonly hideCardWrapper = input(false); + readonly hideBackgroundIllustration = input(false); /** * Max width of the anon layout title, subtitle, and content areas. diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index 15cce39d8b7..3593cb4f30e 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -79,6 +79,7 @@ export default { [hideIcon]="hideIcon" [hideLogo]="hideLogo" [hideFooter]="hideFooter" + [hideBackgroundIllustration]="hideBackgroundIllustration" >
Thin Content
@@ -125,6 +126,7 @@ export default { hideIcon: { control: "boolean" }, hideLogo: { control: "boolean" }, hideFooter: { control: "boolean" }, + hideBackgroundIllustration: { control: "boolean" }, contentLength: { control: "radio", @@ -145,6 +147,7 @@ export default { hideIcon: false, hideLogo: false, hideFooter: false, + hideBackgroundIllustration: false, contentLength: "normal", showSecondary: false, }, @@ -221,6 +224,10 @@ export const NoFooter: Story = { args: { hideFooter: true }, }; +export const NoBackgroundIllustration: Story = { + args: { hideBackgroundIllustration: true }, +}; + export const ReadonlyHostname: Story = { args: { showReadonlyHostname: true }, }; @@ -234,5 +241,6 @@ export const MinimalState: Story = { hideIcon: true, hideLogo: true, hideFooter: true, + hideBackgroundIllustration: true, }, }; diff --git a/libs/components/src/callout/callout.component.ts b/libs/components/src/callout/callout.component.ts index 62321a34d91..c15bc132035 100644 --- a/libs/components/src/callout/callout.component.ts +++ b/libs/components/src/callout/callout.component.ts @@ -36,11 +36,17 @@ let nextId = 0; export class CalloutComponent { readonly type = input("info"); readonly icon = input(); - readonly title = input(); + readonly title = input(); readonly useAlertRole = input(false); - readonly iconComputed = computed(() => this.icon() ?? defaultIcon[this.type()]); + readonly iconComputed = computed(() => + this.icon() === undefined ? defaultIcon[this.type()] : this.icon(), + ); readonly titleComputed = computed(() => { const title = this.title(); + if (title === null) { + return undefined; + } + const type = this.type(); if (title == null && defaultI18n[type] != null) { return this.i18nService.t(defaultI18n[type]); diff --git a/libs/components/src/icon-tile/icon-tile.component.html b/libs/components/src/icon-tile/icon-tile.component.html new file mode 100644 index 00000000000..2dff588243e --- /dev/null +++ b/libs/components/src/icon-tile/icon-tile.component.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/libs/components/src/icon-tile/icon-tile.component.ts b/libs/components/src/icon-tile/icon-tile.component.ts new file mode 100644 index 00000000000..54e92f9f004 --- /dev/null +++ b/libs/components/src/icon-tile/icon-tile.component.ts @@ -0,0 +1,111 @@ +import { NgClass } from "@angular/common"; +import { Component, computed, input } from "@angular/core"; + +import { BitwardenIcon } from "../shared/icon"; + +export type IconTileVariant = "primary" | "success" | "warning" | "danger" | "muted"; + +export type IconTileSize = "small" | "default" | "large"; + +export type IconTileShape = "square" | "circle"; + +const variantStyles: Record = { + primary: ["tw-bg-primary-100", "tw-text-primary-700"], + success: ["tw-bg-success-100", "tw-text-success-700"], + warning: ["tw-bg-warning-100", "tw-text-warning-700"], + danger: ["tw-bg-danger-100", "tw-text-danger-700"], + muted: ["tw-bg-secondary-100", "tw-text-secondary-700"], +}; + +const sizeStyles: Record = { + small: { + container: ["tw-w-6", "tw-h-6"], + icon: ["tw-text-sm"], + }, + default: { + container: ["tw-w-8", "tw-h-8"], + icon: ["tw-text-base"], + }, + large: { + container: ["tw-w-10", "tw-h-10"], + icon: ["tw-text-lg"], + }, +}; + +const shapeStyles: Record> = { + square: { + small: ["tw-rounded"], + default: ["tw-rounded-md"], + large: ["tw-rounded-lg"], + }, + circle: { + small: ["tw-rounded-full"], + default: ["tw-rounded-full"], + large: ["tw-rounded-full"], + }, +}; + +/** + * Icon tiles are static containers that display an icon with a colored background. + * They are similar to icon buttons but are not interactive and are used for visual + * indicators, status representations, or decorative elements. + * + * Use icon tiles to: + * - Display status or category indicators + * - Represent different types of content + * - Create visual hierarchy in lists or cards + * - Show app or service icons in a consistent format + */ +@Component({ + selector: "bit-icon-tile", + templateUrl: "icon-tile.component.html", + imports: [NgClass], +}) +export class IconTileComponent { + /** + * The BWI icon name + */ + readonly icon = input.required(); + + /** + * The visual theme of the icon tile + */ + readonly variant = input("primary"); + + /** + * The size of the icon tile + */ + readonly size = input("default"); + + /** + * The shape of the icon tile + */ + readonly shape = input("square"); + + /** + * Optional aria-label for accessibility when the icon has semantic meaning + */ + readonly ariaLabel = input(); + + protected readonly containerClasses = computed(() => { + const variant = this.variant(); + const size = this.size(); + const shape = this.shape(); + + return [ + "tw-inline-flex", + "tw-items-center", + "tw-justify-center", + "tw-flex-shrink-0", + ...variantStyles[variant], + ...sizeStyles[size].container, + ...shapeStyles[shape][size], + ]; + }); + + protected readonly iconClasses = computed(() => { + const size = this.size(); + + return ["bwi", this.icon(), ...sizeStyles[size].icon]; + }); +} diff --git a/libs/components/src/icon-tile/icon-tile.stories.ts b/libs/components/src/icon-tile/icon-tile.stories.ts new file mode 100644 index 00000000000..2daa0d4289a --- /dev/null +++ b/libs/components/src/icon-tile/icon-tile.stories.ts @@ -0,0 +1,114 @@ +import { Meta, StoryObj } from "@storybook/angular"; + +import { BITWARDEN_ICONS } from "../shared/icon"; + +import { IconTileComponent } from "./icon-tile.component"; + +export default { + title: "Component Library/Icon Tile", + component: IconTileComponent, + args: { + icon: "bwi-star", + variant: "primary", + size: "default", + shape: "square", + }, + argTypes: { + variant: { + options: ["primary", "success", "warning", "danger", "muted"], + control: { type: "select" }, + }, + size: { + options: ["small", "default", "large"], + control: { type: "select" }, + }, + shape: { + options: ["square", "circle"], + control: { type: "select" }, + }, + icon: { + options: BITWARDEN_ICONS, + control: { type: "select" }, + }, + ariaLabel: { + control: { type: "text" }, + }, + }, + parameters: { + design: { + type: "figma", + url: "https://atlassian.design/components/icon/icon-tile/examples", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const AllVariants: Story = { + render: () => ({ + template: ` +
+
+ + Primary +
+
+ + Success +
+
+ + Warning +
+
+ + Danger +
+
+ + Muted +
+
+ `, + }), +}; + +export const AllSizes: Story = { + render: () => ({ + template: ` +
+
+ + Small +
+
+ + Default +
+
+ + Large +
+
+ `, + }), +}; + +export const AllShapes: Story = { + render: () => ({ + template: ` +
+
+ + Square +
+
+ + Circle +
+
+ `, + }), +}; diff --git a/libs/components/src/icon-tile/index.ts b/libs/components/src/icon-tile/index.ts new file mode 100644 index 00000000000..415c9e478cc --- /dev/null +++ b/libs/components/src/icon-tile/index.ts @@ -0,0 +1 @@ +export * from "./icon-tile.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index d231048563c..2384696b770 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -21,6 +21,7 @@ export * from "./drawer"; export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; +export * from "./icon-tile"; export * from "./input"; export * from "./item"; export * from "./layout"; diff --git a/libs/components/src/shared/icon.ts b/libs/components/src/shared/icon.ts new file mode 100644 index 00000000000..6830ce49d7c --- /dev/null +++ b/libs/components/src/shared/icon.ts @@ -0,0 +1,110 @@ +/** + * Array of available Bitwarden Web Icons (bwi) font names. + * These correspond to the actual icon names defined in the bwi-font. + * This array serves as the single source of truth for all available icons. + */ +export const BITWARDEN_ICONS = [ + "bwi-angle-down", + "bwi-angle-left", + "bwi-angle-right", + "bwi-angle-up", + "bwi-archive", + "bwi-bell", + "bwi-billing", + "bwi-bitcoin", + "bwi-browser", + "bwi-browser-alt", + "bwi-brush", + "bwi-bug", + "bwi-business", + "bwi-camera", + "bwi-check", + "bwi-check-circle", + "bwi-cli", + "bwi-clock", + "bwi-clone", + "bwi-close", + "bwi-cog", + "bwi-cog-f", + "bwi-collection", + "bwi-collection-shared", + "bwi-credit-card", + "bwi-dashboard", + "bwi-desktop", + "bwi-dollar", + "bwi-down-solid", + "bwi-download", + "bwi-drag-and-drop", + "bwi-ellipsis-h", + "bwi-ellipsis-v", + "bwi-envelope", + "bwi-error", + "bwi-exclamation-triangle", + "bwi-external-link", + "bwi-eye", + "bwi-eye-slash", + "bwi-family", + "bwi-file", + "bwi-file-text", + "bwi-files", + "bwi-filter", + "bwi-folder", + "bwi-generate", + "bwi-globe", + "bwi-hashtag", + "bwi-id-card", + "bwi-import", + "bwi-info-circle", + "bwi-key", + "bwi-list", + "bwi-list-alt", + "bwi-lock", + "bwi-lock-encrypted", + "bwi-lock-f", + "bwi-minus-circle", + "bwi-mobile", + "bwi-msp", + "bwi-numbered-list", + "bwi-paperclip", + "bwi-passkey", + "bwi-paypal", + "bwi-pencil", + "bwi-pencil-square", + "bwi-plus", + "bwi-plus-circle", + "bwi-popout", + "bwi-provider", + "bwi-puzzle", + "bwi-question-circle", + "bwi-refresh", + "bwi-search", + "bwi-send", + "bwi-share", + "bwi-shield", + "bwi-sign-in", + "bwi-sign-out", + "bwi-sliders", + "bwi-spinner", + "bwi-star", + "bwi-star-f", + "bwi-sticky-note", + "bwi-tag", + "bwi-trash", + "bwi-undo", + "bwi-universal-access", + "bwi-unlock", + "bwi-up-down-btn", + "bwi-up-solid", + "bwi-user", + "bwi-user-monitor", + "bwi-users", + "bwi-vault", + "bwi-wireless", + "bwi-wrench", +] as const; + +/** + * Type-safe icon names derived from the BITWARDEN_ICONS array. + * This ensures type safety while allowing runtime iteration and validation. + */ +export type BitwardenIcon = (typeof BITWARDEN_ICONS)[number]; From c782de57a3903d4e0966e394c3a14165388e141c Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 15 Oct 2025 09:49:24 -0500 Subject: [PATCH 34/37] use optional chaining for possibly undefined cipher when selecting only collections (#16886) --- .../vault-items/vault-items.component.spec.ts | 139 ++++++++++++++++++ .../vault-items/vault-items.component.ts | 2 +- 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts new file mode 100644 index 00000000000..902fc2eb5a2 --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts @@ -0,0 +1,139 @@ +import { ScrollingModule } from "@angular/cdk/scrolling"; +import { TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; + +import { CollectionView } from "@bitwarden/admin-console/common"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { MenuModule, TableModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { VaultItem } from "./vault-item"; +import { VaultItemsComponent } from "./vault-items.component"; + +describe("VaultItemsComponent", () => { + let component: VaultItemsComponent; + + const cipher1: Partial = { + id: "cipher-1", + name: "Cipher 1", + organizationId: undefined, + }; + + const cipher2: Partial = { + id: "cipher-2", + name: "Cipher 2", + organizationId: undefined, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [VaultItemsComponent], + imports: [ScrollingModule, TableModule, I18nPipe, MenuModule], + providers: [ + { + provide: CipherAuthorizationService, + useValue: { + canDeleteCipher$: jest.fn(), + canRestoreCipher$: jest.fn(), + }, + }, + { + provide: RestrictedItemTypesService, + useValue: { + restricted$: of([]), + isCipherRestricted: jest.fn().mockReturnValue(false), + }, + }, + { + provide: I18nService, + useValue: { + t: (key: string) => key, + }, + }, + ], + }); + + const fixture = TestBed.createComponent(VaultItemsComponent); + component = fixture.componentInstance; + }); + + describe("bulkUnarchiveAllowed", () => { + it("returns false when no items are selected", () => { + component["selection"].clear(); + + expect(component.bulkUnarchiveAllowed).toBe(false); + }); + + it("returns false when selecting collections only", () => { + const collection1 = { id: "col-1", name: "Collection 1" } as CollectionView; + const collection2 = { id: "col-2", name: "Collection 2" } as CollectionView; + + const items: VaultItem[] = [ + { collection: collection1 }, + { collection: collection2 }, + ]; + + component["selection"].select(...items); + + expect(component.bulkUnarchiveAllowed).toBe(false); + }); + + it("returns true when selecting archived ciphers without organization", () => { + const archivedCipher1 = { + ...cipher1, + archivedDate: new Date("2024-01-01"), + }; + const archivedCipher2 = { + ...cipher2, + archivedDate: new Date("2024-01-02"), + }; + + const items: VaultItem[] = [ + { cipher: archivedCipher1 as CipherView }, + { cipher: archivedCipher2 as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkUnarchiveAllowed).toBe(true); + }); + + it("returns false when any selected cipher has an organizationId", () => { + const archivedCipher1: Partial = { + ...cipher1, + archivedDate: new Date("2024-01-01"), + organizationId: undefined, + }; + + const archivedCipher2: Partial = { + ...cipher2, + archivedDate: new Date("2024-01-02"), + organizationId: "org-1", + }; + + const items: VaultItem[] = [ + { cipher: archivedCipher1 as CipherView }, + { cipher: archivedCipher2 as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkUnarchiveAllowed).toBe(false); + }); + + it("returns false when any selected cipher is not archived", () => { + const items: VaultItem[] = [ + { cipher: cipher1 as CipherView }, + { cipher: cipher2 as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkUnarchiveAllowed).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index b40124c39e4..67a5069034f 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -213,7 +213,7 @@ export class VaultItemsComponent { } return !this.selection.selected.find( - (item) => !item.cipher.archivedDate || item.cipher.organizationId, + (item) => !item.cipher?.archivedDate || item.cipher?.organizationId, ); } From 2858d120113e0c44acf49024486d27827e720c74 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 15 Oct 2025 09:07:59 -0700 Subject: [PATCH 35/37] add export related code changes (#16789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • export related changes from PR 16399 --- .../src/enums/encrypted-export-type.enum.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts b/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts index 2f416e4a49a..c321a487515 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts @@ -1,6 +1,10 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum EncryptedExportType { - AccountEncrypted = 0, - FileEncrypted = 1, -} +/** A type of encrypted export. */ +export const EncryptedExportType = Object.freeze({ + /** Export is encrypted using the Bitwarden account key. */ + AccountEncrypted: 0, + /** Export is encrypted using a separate file password/key. */ + FileEncrypted: 1, +} as const); + +/** A type of encrypted export. */ +export type EncryptedExportType = (typeof EncryptedExportType)[keyof typeof EncryptedExportType]; From 76e4870aa3d0d8f5190675ce4d8b8ef3f2dcae18 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 15 Oct 2025 09:08:47 -0700 Subject: [PATCH 36/37] PM-22143 Refactor TS enums to be const objects (Import only) (#16770) * Import related changes from PR #16399 --- .../importers/fsecure/fsecure-fsk-importer.ts | 25 ++---- .../importers/fsecure/fsecure-fsk-types.ts | 20 +++-- .../lastpass/access/enums/idp-provider.ts | 26 +++--- .../access/enums/lastpass-login-type.ts | 18 ++-- .../lastpass/access/enums/otp-method.ts | 20 +++-- .../lastpass/access/enums/platform.ts | 18 ++-- .../onepassword/onepassword-1pux-importer.ts | 84 +++++++++--------- .../types/onepassword-1pux-importer-types.ts | 87 +++++++++++-------- .../protonpass/types/protonpass-json-type.ts | 19 ++-- 9 files changed, 180 insertions(+), 137 deletions(-) diff --git a/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts b/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts index f572997cfe7..092a80c3cf0 100644 --- a/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts +++ b/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CipherType } from "@bitwarden/common/vault/enums"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -8,7 +6,7 @@ import { ImportResult } from "../../models/import-result"; import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; -import { FskEntry, FskEntryTypesEnum, FskFile } from "./fsecure-fsk-types"; +import { FskEntry, FskEntryType, FskFile } from "./fsecure-fsk-types"; export class FSecureFskImporter extends BaseImporter implements Importer { parse(data: string): Promise { @@ -19,37 +17,32 @@ export class FSecureFskImporter extends BaseImporter implements Importer { return Promise.resolve(result); } - for (const key in results.data) { - // eslint-disable-next-line - if (!results.data.hasOwnProperty(key)) { - continue; - } - - const value = results.data[key]; + for (const [, value] of Object.entries(results.data)) { const cipher = this.parseEntry(value); - result.ciphers.push(cipher); + if (cipher != undefined) { + result.ciphers.push(cipher); + } } result.success = true; return Promise.resolve(result); } - private parseEntry(entry: FskEntry): CipherView { + private parseEntry(entry: FskEntry): CipherView | undefined { const cipher = this.initLoginCipher(); cipher.name = this.getValueOrDefault(entry.service); cipher.notes = this.getValueOrDefault(entry.notes); cipher.favorite = entry.favorite > 0; switch (entry.type) { - case FskEntryTypesEnum.Login: + case FskEntryType.Login: this.handleLoginEntry(entry, cipher); break; - case FskEntryTypesEnum.CreditCard: + case FskEntryType.CreditCard: this.handleCreditCardEntry(entry, cipher); break; default: - return; - break; + return undefined; } this.convertToNoteIfNeeded(cipher); diff --git a/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts b/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts index 1235426d683..919ae4e8c82 100644 --- a/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts +++ b/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts @@ -6,12 +6,18 @@ export interface Data { [key: string]: FskEntry; } -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum FskEntryTypesEnum { - Login = 1, - CreditCard = 2, -} +/** + * Represents the different types of FSK entries. + */ +export const FskEntryType = Object.freeze({ + Login: 1, + CreditCard: 2, +}); + +/** + * Type representing valid FSK entry type values. + */ +export type FskEntryType = (typeof FskEntryType)[keyof typeof FskEntryType]; export interface FskEntry { color: string; @@ -26,7 +32,7 @@ export interface FskEntry { rev: string | number; service: string; style: string; - type: FskEntryTypesEnum; + type: FskEntryType; url: string; username: string; createdDate: number; // UNIX timestamp diff --git a/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts b/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts index 01c4572fcf9..ace0cda71a1 100644 --- a/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts +++ b/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts @@ -1,10 +1,16 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum IdpProvider { - Azure = 0, - OktaAuthServer = 1, - OktaNoAuthServer = 2, - Google = 3, - PingOne = 4, - OneLogin = 5, -} +/** + * Represents the different identity providers supported for authentication. + */ +export const IdpProvider = Object.freeze({ + Azure: 0, + OktaAuthServer: 1, + OktaNoAuthServer: 2, + Google: 3, + PingOne: 4, + OneLogin: 5, +} as const); + +/** + * Type representing valid identity provider values. + */ +export type IdpProvider = (typeof IdpProvider)[keyof typeof IdpProvider]; diff --git a/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts b/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts index a3be36c790e..8f45852c759 100644 --- a/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts +++ b/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts @@ -1,7 +1,13 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum LastpassLoginType { - MasterPassword = 0, +/** + * Represents LastPass login types. + */ +export const LastpassLoginType = Object.freeze({ + MasterPassword: 0, // Not sure what Types 1 and 2 are? - Federated = 3, -} + Federated: 3, +} as const); + +/** + * Type representing valid LastPass login type values. + */ +export type LastpassLoginType = (typeof LastpassLoginType)[keyof typeof LastpassLoginType]; diff --git a/libs/importer/src/importers/lastpass/access/enums/otp-method.ts b/libs/importer/src/importers/lastpass/access/enums/otp-method.ts index f1237160179..9d7e88798d4 100644 --- a/libs/importer/src/importers/lastpass/access/enums/otp-method.ts +++ b/libs/importer/src/importers/lastpass/access/enums/otp-method.ts @@ -1,7 +1,13 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum OtpMethod { - GoogleAuth, - MicrosoftAuth, - Yubikey, -} +/** + * Represents OTP authentication methods. + */ +export const OtpMethod = Object.freeze({ + GoogleAuth: 0, + MicrosoftAuth: 1, + Yubikey: 2, +} as const); + +/** + * Type representing valid OTP method values. + */ +export type OtpMethod = (typeof OtpMethod)[keyof typeof OtpMethod]; diff --git a/libs/importer/src/importers/lastpass/access/enums/platform.ts b/libs/importer/src/importers/lastpass/access/enums/platform.ts index 6870fc28c24..a58ba37958a 100644 --- a/libs/importer/src/importers/lastpass/access/enums/platform.ts +++ b/libs/importer/src/importers/lastpass/access/enums/platform.ts @@ -1,6 +1,12 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum Platform { - Desktop, - Mobile, -} +/** + * Platform types representing different device categories. + */ +export const Platform = Object.freeze({ + Desktop: 0, + Mobile: 1, +} as const); + +/** + * Type representing valid platform values. + */ +export type Platform = (typeof Platform)[keyof typeof Platform]; diff --git a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts index 053c83f2347..d19b5e7d0f3 100644 --- a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts +++ b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts @@ -14,12 +14,12 @@ import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; import { - CategoryEnum, + Category, Details, ExportData, FieldsEntity, Item, - LoginFieldTypeEnum, + LoginFieldType, Overview, PasswordHistoryEntity, SectionsEntity, @@ -45,38 +45,38 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { const cipher = this.initLoginCipher(); - const category = item.categoryUuid as CategoryEnum; + const category = item.categoryUuid as Category; switch (category) { - case CategoryEnum.Login: - case CategoryEnum.Database: - case CategoryEnum.Password: - case CategoryEnum.WirelessRouter: - case CategoryEnum.Server: - case CategoryEnum.API_Credential: + case Category.Login: + case Category.Database: + case Category.Password: + case Category.WirelessRouter: + case Category.Server: + case Category.API_Credential: cipher.type = CipherType.Login; cipher.login = new LoginView(); break; - case CategoryEnum.CreditCard: - case CategoryEnum.BankAccount: + case Category.CreditCard: + case Category.BankAccount: cipher.type = CipherType.Card; cipher.card = new CardView(); break; - case CategoryEnum.SecureNote: - case CategoryEnum.SoftwareLicense: - case CategoryEnum.EmailAccount: - case CategoryEnum.MedicalRecord: + case Category.SecureNote: + case Category.SoftwareLicense: + case Category.EmailAccount: + case Category.MedicalRecord: // case CategoryEnum.Document: cipher.type = CipherType.SecureNote; cipher.secureNote = new SecureNoteView(); cipher.secureNote.type = SecureNoteType.Generic; break; - case CategoryEnum.Identity: - case CategoryEnum.DriversLicense: - case CategoryEnum.OutdoorLicense: - case CategoryEnum.Membership: - case CategoryEnum.Passport: - case CategoryEnum.RewardsProgram: - case CategoryEnum.SocialSecurityNumber: + case Category.Identity: + case Category.DriversLicense: + case Category.OutdoorLicense: + case Category.Membership: + case Category.Passport: + case Category.RewardsProgram: + case Category.SocialSecurityNumber: cipher.type = CipherType.Identity; cipher.identity = new IdentityView(); break; @@ -166,10 +166,10 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { let fieldValue = loginField.value; let fieldType: FieldType = FieldType.Text; switch (loginField.fieldType) { - case LoginFieldTypeEnum.Password: + case LoginFieldType.Password: fieldType = FieldType.Hidden; break; - case LoginFieldTypeEnum.CheckBox: + case LoginFieldType.CheckBox: fieldValue = loginField.value !== "" ? "true" : "false"; fieldType = FieldType.Boolean; break; @@ -180,8 +180,8 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { }); } - private processDetails(category: CategoryEnum, details: Details, cipher: CipherView) { - if (category !== CategoryEnum.Password) { + private processDetails(category: Category, details: Details, cipher: CipherView) { + if (category !== Category.Password) { return; } @@ -191,7 +191,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { cipher.login.password = details.password; } - private processSections(category: CategoryEnum, sections: SectionsEntity[], cipher: CipherView) { + private processSections(category: Category, sections: SectionsEntity[], cipher: CipherView) { if (sections == null || sections.length === 0) { return; } @@ -206,7 +206,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { } private parseSectionFields( - category: CategoryEnum, + category: Category, fields: FieldsEntity[], cipher: CipherView, sectionTitle: string, @@ -232,20 +232,20 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { } switch (category) { - case CategoryEnum.Login: - case CategoryEnum.Database: - case CategoryEnum.EmailAccount: - case CategoryEnum.WirelessRouter: + case Category.Login: + case Category.Database: + case Category.EmailAccount: + case Category.WirelessRouter: break; - case CategoryEnum.Server: + case Category.Server: if (this.isNullOrWhitespace(cipher.login.uri) && field.id === "url") { cipher.login.uris = this.makeUriArray(fieldValue); return; } break; - case CategoryEnum.API_Credential: + case Category.API_Credential: if (this.fillApiCredentials(field, fieldValue, cipher)) { return; } @@ -258,7 +258,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { return; } - if (category === CategoryEnum.BankAccount) { + if (category === Category.BankAccount) { if (this.fillBankAccount(field, fieldValue, cipher)) { return; } @@ -281,34 +281,34 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { } switch (category) { - case CategoryEnum.Identity: + case Category.Identity: break; - case CategoryEnum.DriversLicense: + case Category.DriversLicense: if (this.fillDriversLicense(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.OutdoorLicense: + case Category.OutdoorLicense: if (this.fillOutdoorLicense(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.Membership: + case Category.Membership: if (this.fillMembership(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.Passport: + case Category.Passport: if (this.fillPassport(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.RewardsProgram: + case Category.RewardsProgram: if (this.fillRewardsProgram(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.SocialSecurityNumber: + case Category.SocialSecurityNumber: if (this.fillSSN(field, fieldValue, cipher)) { return; } diff --git a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts index d7f4dec8f95..43f3bc4f7d6 100644 --- a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts +++ b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts @@ -25,30 +25,36 @@ export interface VaultAttributes { type: string; } -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum CategoryEnum { - Login = "001", - CreditCard = "002", - SecureNote = "003", - Identity = "004", - Password = "005", - Document = "006", - SoftwareLicense = "100", - BankAccount = "101", - Database = "102", - DriversLicense = "103", - OutdoorLicense = "104", - Membership = "105", - Passport = "106", - RewardsProgram = "107", - SocialSecurityNumber = "108", - WirelessRouter = "109", - Server = "110", - EmailAccount = "111", - API_Credential = "112", - MedicalRecord = "113", -} +/** + * Represents the different types of items that can be stored in 1Password. + */ +export const Category = Object.freeze({ + Login: "001", + CreditCard: "002", + SecureNote: "003", + Identity: "004", + Password: "005", + Document: "006", + SoftwareLicense: "100", + BankAccount: "101", + Database: "102", + DriversLicense: "103", + OutdoorLicense: "104", + Membership: "105", + Passport: "106", + RewardsProgram: "107", + SocialSecurityNumber: "108", + WirelessRouter: "109", + Server: "110", + EmailAccount: "111", + API_Credential: "112", + MedicalRecord: "113", +} as const); + +/** + * Represents valid 1Password category values. + */ +export type Category = (typeof Category)[keyof typeof Category]; export interface Item { uuid: string; @@ -69,23 +75,30 @@ export interface Details { password?: string | null; } -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum LoginFieldTypeEnum { - TextOrHtml = "T", - EmailAddress = "E", - URL = "U", - Number = "N", - Password = "P", - TextArea = "A", - PhoneNumber = "TEL", - CheckBox = "C", -} +/** + * Represents 1Password login field types that can be stored in login items. + */ +export const LoginFieldType = Object.freeze({ + TextOrHtml: "T", + EmailAddress: "E", + URL: "U", + Number: "N", + Password: "P", + TextArea: "A", + PhoneNumber: "TEL", + CheckBox: "C", +} as const); + +/** + * Type representing valid 1Password login field type values. + */ +export type LoginFieldType = (typeof LoginFieldType)[keyof typeof LoginFieldType]; + export interface LoginFieldsEntity { value: string; id: string; name: string; - fieldType: LoginFieldTypeEnum | string; + fieldType: LoginFieldType | string; designation?: string | null; } export interface SectionsEntity { diff --git a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts index af2eb15a740..124c95a3d69 100644 --- a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts +++ b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts @@ -27,12 +27,19 @@ export type ProtonPassItem = { pinned: boolean; }; -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum ProtonPassItemState { - ACTIVE = 1, - TRASHED = 2, -} +/** + * Proton Pass item states as a const object. + * Represents the different states an item can be in (active or trashed). + */ +export const ProtonPassItemState = Object.freeze({ + ACTIVE: 1, + TRASHED: 2, +} as const); + +/** + * Type representing valid Proton Pass item state values. + */ +export type ProtonPassItemState = (typeof ProtonPassItemState)[keyof typeof ProtonPassItemState]; export type ProtonPassItemData = { metadata: ProtonPassItemMetadata; From aa9a276591e47fc83696a4a7b98f23458beb48aa Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:56:46 -0500 Subject: [PATCH 37/37] [PM-23246] Add unlock with master password unlock data for lock component (#16204) * Add unlocking with MasterPasswordUnlockData for angular lock component --- .../src/services/jslib-services.module.ts | 7 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../master-password-unlock.service.ts | 13 + .../master-password.service.abstraction.ts | 8 + ...ult-master-password-unlock.service.spec.ts | 154 ++++++ .../default-master-password-unlock.service.ts | 75 +++ .../services/fake-master-password.service.ts | 4 + .../services/master-password.service.spec.ts | 128 +++-- .../services/master-password.service.ts | 10 +- .../src/lock/components/lock.component.html | 144 +++--- .../lock/components/lock.component.spec.ts | 134 ++++- .../src/lock/components/lock.component.ts | 61 ++- .../master-password-lock.component.html | 55 ++ .../master-password-lock.component.spec.ts | 472 ++++++++++++++++++ .../master-password-lock.component.ts | 111 ++++ 15 files changed, 1249 insertions(+), 129 deletions(-) create mode 100644 libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts create mode 100644 libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts create mode 100644 libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts create mode 100644 libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html create mode 100644 libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts create mode 100644 libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index d263e493b87..53da6e9fd8e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -174,10 +174,12 @@ import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarde import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { DefaultMasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/services/default-master-password-unlock.service"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; @@ -1077,6 +1079,11 @@ const safeProviders: SafeProvider[] = [ provide: MasterPasswordServiceAbstraction, useExisting: InternalMasterPasswordServiceAbstraction, }), + safeProvider({ + provide: MasterPasswordUnlockService, + useClass: DefaultMasterPasswordUnlockService, + deps: [InternalMasterPasswordServiceAbstraction, KeyService], + }), safeProvider({ provide: KeyConnectorServiceAbstraction, useClass: KeyConnectorService, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 8045a7b55f0..e2d4b000626 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -33,6 +33,7 @@ export enum FeatureFlag { PrivateKeyRegeneration = "pm-12241-private-key-regeneration", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", + UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", @@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, + [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, diff --git a/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts b/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts new file mode 100644 index 00000000000..4448206b2f6 --- /dev/null +++ b/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts @@ -0,0 +1,13 @@ +import { UserId } from "@bitwarden/user-core"; + +import { UserKey } from "../../../types/key"; + +export abstract class MasterPasswordUnlockService { + /** + * Unlocks the user's account using the master password. + * @param masterPassword The master password provided by the user. + * @param userId The ID of the active user. + * @returns the user's decrypted userKey. + */ + abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise; +} diff --git a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts index 8ef14904bce..f982c2c5ce8 100644 --- a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts +++ b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts @@ -171,4 +171,12 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas masterPasswordUnlockData: MasterPasswordUnlockData, userId: UserId, ): Promise; + + /** + * An observable that emits the master password unlock data for the target user. + * @param userId The user ID. + * @throws If the user ID is null or undefined. + * @returns An observable that emits the master password unlock data or null if not found. + */ + abstract masterPasswordUnlockData$(userId: UserId): Observable; } diff --git a/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts new file mode 100644 index 00000000000..75668e8e6bd --- /dev/null +++ b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts @@ -0,0 +1,154 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { newGuid } from "@bitwarden/guid"; +// eslint-disable-next-line no-restricted-imports +import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { HashPurpose } from "../../../platform/enums"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { MasterKey, UserKey } from "../../../types/key"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { + MasterKeyWrappedUserKey, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "../types/master-password.types"; + +import { DefaultMasterPasswordUnlockService } from "./default-master-password-unlock.service"; + +describe("DefaultMasterPasswordUnlockService", () => { + let sut: DefaultMasterPasswordUnlockService; + + let masterPasswordService: MockProxy; + let keyService: MockProxy; + + const mockMasterPassword = "testExample"; + const mockUserId = newGuid() as UserId; + + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const mockMasterPasswordUnlockData: MasterPasswordUnlockData = new MasterPasswordUnlockData( + "user@example.com" as MasterPasswordSalt, + new Argon2KdfConfig(100000, 64, 1), + "encryptedMasterKeyWrappedUserKey" as MasterKeyWrappedUserKey, + ); + + //Legacy data for tests + const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey; + const mockKeyHash = "localKeyHash"; + + beforeEach(() => { + masterPasswordService = mock(); + keyService = mock(); + + sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService); + + masterPasswordService.masterPasswordUnlockData$.mockReturnValue( + of(mockMasterPasswordUnlockData), + ); + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockResolvedValue(mockUserKey); + + // Legacy state mocking + keyService.makeMasterKey.mockResolvedValue(mockMasterKey); + keyService.hashMasterKey.mockResolvedValue(mockKeyHash); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("unlockWithMasterPassword", () => { + test.each([null as unknown as string, undefined as unknown as string, ""])( + "throws when the provided master password is %s", + async (masterPassword) => { + await expect(sut.unlockWithMasterPassword(masterPassword, mockUserId)).rejects.toThrow( + "Master password is required", + ); + expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled(); + expect( + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData, + ).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided master password is %s", + async (userId) => { + await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow( + "User ID is required", + ); + }, + ); + + it("throws an error when the user doesn't have masterPasswordUnlockData", async () => { + masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null)); + + await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow( + "Master password unlock data was not found for the user " + mockUserId, + ); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect( + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData, + ).not.toHaveBeenCalled(); + }); + + it("returns userKey successfully", async () => { + const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId); + + expect(result).toEqual(mockUserKey); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + }); + + it("sets legacy state on success", async () => { + const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId); + + expect(result).toEqual(mockUserKey); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData.salt, + mockMasterPasswordUnlockData.kdf, + ); + expect(keyService.hashMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterKey, + HashPurpose.LocalAuthorization, + ); + expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockKeyHash, mockUserId); + expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId); + }); + + it("throws an error if masterKey construction fails", async () => { + keyService.makeMasterKey.mockResolvedValue(null as unknown as MasterKey); + + await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow( + "Master key could not be created to set legacy master password state.", + ); + + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData.salt, + mockMasterPasswordUnlockData.kdf, + ); + expect(keyService.hashMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalled(); + expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts new file mode 100644 index 00000000000..87114000abf --- /dev/null +++ b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts @@ -0,0 +1,75 @@ +import { firstValueFrom } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { HashPurpose } from "../../../platform/enums"; +import { UserKey } from "../../../types/key"; +import { MasterPasswordUnlockService } from "../abstractions/master-password-unlock.service"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { MasterPasswordUnlockData } from "../types/master-password.types"; + +export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockService { + constructor( + private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction, + private readonly keyService: KeyService, + ) {} + + async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise { + this.validateInput(masterPassword, userId); + + const masterPasswordUnlockData = await firstValueFrom( + this.masterPasswordService.masterPasswordUnlockData$(userId), + ); + + if (masterPasswordUnlockData == null) { + throw new Error("Master password unlock data was not found for the user " + userId); + } + + const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData( + masterPassword, + masterPasswordUnlockData, + ); + + await this.setLegacyState(masterPassword, masterPasswordUnlockData, userId); + + return userKey; + } + + private validateInput(masterPassword: string, userId: UserId): void { + if (masterPassword == null || masterPassword === "") { + throw new Error("Master password is required"); + } + if (userId == null) { + throw new Error("User ID is required"); + } + } + + // Previously unlocking had the side effect of setting the masterKey and masterPasswordHash in state. + // This is to preserve that behavior, once masterKey and masterPasswordHash state is removed this should be removed as well. + private async setLegacyState( + masterPassword: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise { + const masterKey = await this.keyService.makeMasterKey( + masterPassword, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + ); + + if (!masterKey) { + throw new Error("Master key could not be created to set legacy master password state."); + } + + const localKeyHash = await this.keyService.hashMasterKey( + masterPassword, + masterKey, + HashPurpose.LocalAuthorization, + ); + + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); + await this.masterPasswordService.setMasterKey(masterKey, userId); + } +} diff --git a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts index 81aea5e480a..5db7f178b18 100644 --- a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts @@ -119,4 +119,8 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA ): Promise { return this.mock.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); } + + masterPasswordUnlockData$(userId: UserId): Observable { + return this.mock.masterPasswordUnlockData$(userId); + } } diff --git a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts index 02b4e9a895a..f5fee3be4c5 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts @@ -1,6 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import * as rxjs from "rxjs"; -import { firstValueFrom, of } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { Jsonify } from "type-fest"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; @@ -10,6 +9,7 @@ import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden import { FakeAccountService, + FakeStateProvider, makeSymmetricCryptoKey, mockAccountServiceWith, } from "../../../../spec"; @@ -17,7 +17,6 @@ import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-pa import { LogService } from "../../../platform/abstractions/log.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; import { KeyGenerationService } from "../../crypto"; @@ -30,25 +29,30 @@ import { MasterPasswordUnlockData, } from "../types/master-password.types"; -import { MASTER_PASSWORD_UNLOCK_KEY, MasterPasswordService } from "./master-password.service"; +import { + FORCE_SET_PASSWORD_REASON, + MASTER_KEY_ENCRYPTED_USER_KEY, + MASTER_PASSWORD_UNLOCK_KEY, + MasterPasswordService, +} from "./master-password.service"; describe("MasterPasswordService", () => { let sut: MasterPasswordService; - let stateProvider: MockProxy; let stateService: MockProxy; let keyGenerationService: MockProxy; let encryptService: MockProxy; let logService: MockProxy; let cryptoFunctionService: MockProxy; let accountService: FakeAccountService; + let stateProvider: FakeStateProvider; const userId = "00000000-0000-0000-0000-000000000000" as UserId; - const mockUserState = { - state$: of(null), - update: jest.fn().mockResolvedValue(null), - }; + const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000); + const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3); + const salt = "test@bitwarden.com" as MasterPasswordSalt; + const userKey = makeSymmetricCryptoKey(64, 2) as UserKey; const testUserKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 1); const testMasterKey: MasterKey = makeSymmetricCryptoKey(32, 2); const testStretchedMasterKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 3); @@ -58,17 +62,13 @@ describe("MasterPasswordService", () => { "2.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=|DeUFkhIwgkGdZA08bDnDqMMNmZk21D+H5g8IostPKAY="; beforeEach(() => { - stateProvider = mock(); stateService = mock(); keyGenerationService = mock(); encryptService = mock(); logService = mock(); cryptoFunctionService = mock(); accountService = mockAccountServiceWith(userId); - - stateProvider.getUser.mockReturnValue(mockUserState as any); - - mockUserState.update.mockReset(); + stateProvider = new FakeStateProvider(accountService); sut = new MasterPasswordService( stateProvider, @@ -88,6 +88,10 @@ describe("MasterPasswordService", () => { }); }); + afterEach(() => { + jest.resetAllMocks(); + }); + describe("saltForUser$", () => { it("throws when userid not present", async () => { expect(() => { @@ -111,12 +115,10 @@ describe("MasterPasswordService", () => { await sut.setForceSetPasswordReason(reason, userId); - expect(stateProvider.getUser).toHaveBeenCalled(); - expect(mockUserState.update).toHaveBeenCalled(); - - // Call the update function to verify it returns the correct reason - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toBe(reason); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(reason); }); it("throws an error if reason is null", async () => { @@ -132,31 +134,29 @@ describe("MasterPasswordService", () => { }); it("does not overwrite AdminForcePasswordReset with other reasons except None", async () => { - jest - .spyOn(sut, "forceSetPasswordReason$") - .mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset)); - - jest - .spyOn(rxjs, "firstValueFrom") - .mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset); + stateProvider.singleUser + .getFake(userId, FORCE_SET_PASSWORD_REASON) + .nextState(ForceSetPasswordReason.AdminForcePasswordReset); await sut.setForceSetPasswordReason(ForceSetPasswordReason.WeakMasterPassword, userId); - expect(mockUserState.update).not.toHaveBeenCalled(); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(ForceSetPasswordReason.AdminForcePasswordReset); }); it("allows overwriting AdminForcePasswordReset with None", async () => { - jest - .spyOn(sut, "forceSetPasswordReason$") - .mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset)); - - jest - .spyOn(rxjs, "firstValueFrom") - .mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset); + stateProvider.singleUser + .getFake(userId, FORCE_SET_PASSWORD_REASON) + .nextState(ForceSetPasswordReason.AdminForcePasswordReset); await sut.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); - expect(mockUserState.update).toHaveBeenCalled(); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(ForceSetPasswordReason.None); }); }); describe("decryptUserKeyWithMasterKey", () => { @@ -227,10 +227,10 @@ describe("MasterPasswordService", () => { await sut.setMasterKeyEncryptedUserKey(encryptedKey, userId); - expect(stateProvider.getUser).toHaveBeenCalled(); - expect(mockUserState.update).toHaveBeenCalled(); - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toEqual(encryptedKey.toJSON()); + const state = await firstValueFrom( + stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$, + ); + expect(state).toEqual(encryptedKey.toJSON()); }); }); @@ -328,11 +328,6 @@ describe("MasterPasswordService", () => { }); describe("setMasterPasswordUnlockData", () => { - const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000); - const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3); - const salt = "test@bitwarden.com" as MasterPasswordSalt; - const userKey = makeSymmetricCryptoKey(64, 2) as UserKey; - it.each([kdfPBKDF2, kdfArgon2])( "sets the master password unlock data kdf %o in the state", async (kdfConfig) => { @@ -345,11 +340,10 @@ describe("MasterPasswordService", () => { await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); - expect(stateProvider.getUser).toHaveBeenCalledWith(userId, MASTER_PASSWORD_UNLOCK_KEY); - expect(mockUserState.update).toHaveBeenCalled(); - - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toEqual(masterPasswordUnlockData.toJSON()); + const state = await firstValueFrom( + stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$, + ); + expect(state).toEqual(masterPasswordUnlockData.toJSON()); }, ); @@ -373,6 +367,40 @@ describe("MasterPasswordService", () => { }); }); + describe("masterPasswordUnlockData$", () => { + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided userId is %s", + async (userId) => { + expect(() => sut.masterPasswordUnlockData$(userId)).toThrow("userId is null or undefined."); + }, + ); + + it("returns null when no data is set", async () => { + stateProvider.singleUser.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY).nextState(null); + + const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId)); + + expect(result).toBeNull(); + }); + + it.each([kdfPBKDF2, kdfArgon2])( + "returns the master password unlock data for kdf %o from state", + async (kdfConfig) => { + const masterPasswordUnlockData = await sut.makeMasterPasswordUnlockData( + "test-password", + kdfConfig, + salt, + userKey, + ); + await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); + + const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId)); + + expect(result).toEqual(masterPasswordUnlockData.toJSON()); + }, + ); + }); + describe("MASTER_PASSWORD_UNLOCK_KEY", () => { it("has the correct configuration", () => { expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined(); diff --git a/libs/common/src/key-management/master-password/services/master-password.service.ts b/libs/common/src/key-management/master-password/services/master-password.service.ts index 9f7e054d64c..5cb6bb96a45 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.ts @@ -50,7 +50,7 @@ const MASTER_KEY_HASH = new UserKeyDefinition(MASTER_PASSWORD_DISK, "mas }); /** Disk to persist through lock */ -const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( +export const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( MASTER_PASSWORD_DISK, "masterKeyEncryptedUserKey", { @@ -60,7 +60,7 @@ const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( ); /** Disk to persist through lock and account switches */ -const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( +export const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( MASTER_PASSWORD_DISK, "forceSetPasswordReason", { @@ -344,4 +344,10 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr .getUser(userId, MASTER_PASSWORD_UNLOCK_KEY) .update(() => masterPasswordUnlockData.toJSON()); } + + masterPasswordUnlockData$(userId: UserId): Observable { + assertNonNullish(userId, "userId"); + + return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$; + } } diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index 9a8e8c9f768..77f603204b3 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -120,73 +120,87 @@
- - - - {{ "masterPass" | i18n }} - - - - - - -
- - -

{{ "or" | i18n }}

- - + @if ( + (unlockWithMasterPasswordUnlockDataFlag$ | async) && + unlockOptions.masterPassword.enabled && + activeUnlockOption === UnlockOption.MasterPassword + ) { + + } @else { + + + + {{ "masterPass" | i18n }} + - + bitIconButton + bitSuffix + bitPasswordInputToggle + [(toggled)]="showPassword" + > - - - + + - -
- -
+
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+ + + } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 8c8429d3788..69f949fb843 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -25,6 +25,7 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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"; @@ -91,9 +92,10 @@ describe("LockComponent", () => { const mockLockComponentService = mock(); const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); + const mockConfigService = mock(); beforeEach(async () => { - jest.clearAllMocks(); + jest.resetAllMocks(); // Setup default mock returns mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); @@ -148,6 +150,7 @@ describe("LockComponent", () => { { provide: LockComponentService, useValue: mockLockComponentService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, + { provide: ConfigService, useValue: mockConfigService }, ], }) .overrideProvider(DialogService, { useValue: mockDialogService }) @@ -358,6 +361,135 @@ describe("LockComponent", () => { }); }); + describe("successfulMasterPasswordUnlock", () => { + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const masterPassword = "test-password"; + + beforeEach(async () => { + component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$); + }); + + it.each([ + [undefined as unknown as UserKey, undefined as unknown as string], + [null as unknown as UserKey, null as unknown as string], + [mockUserKey, undefined as unknown as string], + [mockUserKey, null as unknown as string], + [mockUserKey, ""], + [undefined as unknown as UserKey, masterPassword], + [null as unknown as UserKey, masterPassword], + ])( + "logs an error and doesn't unlock when called with invalid data", + async (userKey, masterPassword) => { + await component.successfulMasterPasswordUnlock({ userKey, masterPassword }); + + expect(mockLogService.error).toHaveBeenCalledWith( + "[LockComponent] successfulMasterPasswordUnlock called with invalid data.", + ); + expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [false, undefined, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], + [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], + ])( + "unlocks and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service", + async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { + mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue( + of(masterPasswordPolicyOptions), + ); + const passwordStrengthResult = { score: 1 } as ZXCVBNResult; + mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); + mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId); + if (masterPasswordPolicyOptions?.enforceOnLogin) { + expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( + masterPassword, + component.activeAccount!.email, + ); + expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( + passwordStrengthResult.score, + masterPassword, + masterPasswordPolicyOptions, + ); + } + if (forceSetPassword) { + expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + } else { + expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + [true, ClientType.Browser], + [false, ClientType.Cli], + [false, ClientType.Desktop], + [false, ClientType.Web], + ])( + "unlocks and navigate by url to previous url = %o when client type = %o and previous url was set", + async (shouldNavigate, clientType) => { + const previousUrl = "/test-url"; + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + if (shouldNavigate) { + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl); + } else { + expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + ["/tabs/current", ClientType.Browser], + [undefined, ClientType.Cli], + ["vault", ClientType.Desktop], + ["vault", ClientType.Web], + ])( + "unlocks and navigate to success url = %o when client type = %o", + async (navigateUrl, clientType) => { + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(null); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]); + }, + ); + + it("unlocks and close browser extension popout on firefox extension", async () => { + component.shouldClosePopout = true; + mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled(); + }); + + function assertUnlocked(): void { + expect(mockKeyService.setUserKey).toHaveBeenCalledWith( + mockUserKey, + component.activeAccount!.id, + ); + } + }); + describe("unlockViaMasterPassword", () => { const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey; const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = { diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index c7aa8969660..e7550e34b9f 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -29,10 +29,12 @@ import { MasterPasswordVerificationResponse, } from "@bitwarden/common/auth/types/verification"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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"; @@ -64,6 +66,8 @@ import { UnlockOptionValue, } from "../services/lock-component.service"; +import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component"; + const BroadcasterSubscriptionId = "LockComponent"; const clientTypeToSuccessRouteRecord: Partial> = { @@ -72,6 +76,12 @@ const clientTypeToSuccessRouteRecord: Partial> = { [ClientType.Browser]: "/tabs/current", }; +type AfterUnlockActions = { + passwordEvaluation?: { + masterPassword: string; + }; +}; + /// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible /// Fixes safari autoprompt behavior const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; @@ -87,12 +97,17 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; FormFieldModule, AsyncActionsModule, IconButtonModule, + MasterPasswordLockComponent, ], }) export class LockComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected loading = true; + protected unlockWithMasterPasswordUnlockDataFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.UnlockWithMasterPasswordUnlockData, + ); + activeAccount: Account | null = null; clientType?: ClientType; @@ -160,6 +175,7 @@ export class LockComponent implements OnInit, OnDestroy { private logoutService: LogoutService, private lockComponentService: LockComponentService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private configService: ConfigService, // desktop deps private broadcasterService: BroadcasterService, ) {} @@ -379,7 +395,7 @@ export class LockComponent implements OnInit, OnDestroy { // If user cancels biometric prompt, userKey is undefined. if (userKey) { - await this.setUserKeyAndContinue(userKey, false); + await this.setUserKeyAndContinue(userKey); } this.unlockingViaBiometrics = false; @@ -423,6 +439,7 @@ export class LockComponent implements OnInit, OnDestroy { } } + //TODO PM-25385 This code isn't used and should be removed when removing the UnlockWithMasterPasswordUnlockData feature flag. togglePassword() { this.showPassword = !this.showPassword; const input = document.getElementById( @@ -498,6 +515,7 @@ export class LockComponent implements OnInit, OnDestroy { } } + // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. private validateMasterPassword(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ @@ -511,6 +529,7 @@ export class LockComponent implements OnInit, OnDestroy { return true; } + // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. async unlockViaMasterPassword() { if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) { return; @@ -568,10 +587,33 @@ export class LockComponent implements OnInit, OnDestroy { return; } - await this.setUserKeyAndContinue(userKey, true); + await this.setUserKeyAndContinue(userKey, { + passwordEvaluation: { masterPassword }, + }); } - private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) { + async successfulMasterPasswordUnlock(event: { + userKey: UserKey; + masterPassword: string; + }): Promise { + if (event.userKey == null || !event.masterPassword) { + this.logService.error( + "[LockComponent] successfulMasterPasswordUnlock called with invalid data.", + ); + return; + } + + await this.setUserKeyAndContinue(event.userKey, { + passwordEvaluation: { + masterPassword: event.masterPassword, + }, + }); + } + + protected async setUserKeyAndContinue( + key: UserKey, + afterUnlockActions: AfterUnlockActions = {}, + ): Promise { if (this.activeAccount == null) { throw new Error("No active user."); } @@ -585,10 +627,10 @@ export class LockComponent implements OnInit, OnDestroy { // need to establish trust on the current device await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id); - await this.doContinue(evaluatePasswordAfterUnlock); + await this.doContinue(afterUnlockActions); } - private async doContinue(evaluatePasswordAfterUnlock: boolean) { + private async doContinue(afterUnlockActions: AfterUnlockActions) { if (this.activeAccount == null) { throw new Error("No active user."); } @@ -596,7 +638,7 @@ export class LockComponent implements OnInit, OnDestroy { await this.biometricStateService.resetUserPromptCancelled(); this.messagingService.send("unlocked"); - if (evaluatePasswordAfterUnlock) { + if (afterUnlockActions.passwordEvaluation) { const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; if (userId == null) { throw new Error("No active user."); @@ -613,7 +655,7 @@ export class LockComponent implements OnInit, OnDestroy { ); } - if (this.requirePasswordChange()) { + if (this.requirePasswordChange(afterUnlockActions.passwordEvaluation.masterPassword)) { await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.WeakMasterPassword, userId, @@ -669,18 +711,15 @@ export class LockComponent implements OnInit, OnDestroy { * Checks if the master password meets the enforced policy requirements * If not, returns false */ - private requirePasswordChange(): boolean { + private requirePasswordChange(masterPassword: string): boolean { if ( this.enforcedMasterPasswordOptions == undefined || !this.enforcedMasterPasswordOptions.enforceOnLogin || - this.formGroup == null || this.activeAccount == null ) { return false; } - const masterPassword = this.formGroup.controls.masterPassword.value; - const passwordStrength = this.passwordStrengthService.getPasswordStrength( masterPassword, this.activeAccount.email, diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html new file mode 100644 index 00000000000..185fb0666c4 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -0,0 +1,55 @@ +
+ + {{ "masterPass" | i18n }} + + + + +
+ + +

{{ "or" | i18n }}

+ + @if (showBiometricsSwap()) { + + } + + @if (showPinSwap()) { + + } + + +
+
diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts new file mode 100644 index 00000000000..d40cc98df11 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -0,0 +1,472 @@ +import { DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; + +import { MasterPasswordLockComponent } from "./master-password-lock.component"; + +describe("MasterPasswordLockComponent", () => { + let component: MasterPasswordLockComponent; + let fixture: ComponentFixture; + + const accountService = mock(); + const masterPasswordUnlockService = mock(); + const i18nService = mock(); + const toastService = mock(); + const logService = mock(); + + const mockMasterPassword = "testExample"; + const activeAccount: Account = { + id: "user-id" as UserId, + email: "user@example.com", + emailVerified: true, + name: "User", + }; + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + + const setupComponent = ( + unlockOptions: Partial = {}, + biometricUnlockBtnText: string = "default", + account: Account | null = activeAccount, + ) => { + const defaultOptions: UnlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.NotEnabledLocally, + }, + }; + + accountService.activeAccount$ = of(account); + fixture.componentRef.setInput("unlockOptions", { ...defaultOptions, ...unlockOptions }); + fixture.componentRef.setInput("biometricUnlockBtnText", biometricUnlockBtnText); + fixture.detectChanges(); + + return { + form: fixture.debugElement.query(By.css("form")), + component, + ...getFormElements(fixture.debugElement.query(By.css("form"))), + }; + }; + + const getFormElements = (form: DebugElement) => ({ + masterPasswordInput: form.query(By.css('input[formControlName="masterPassword"]')), + toggleButton: form.query(By.css("button[bitPasswordInputToggle]")), + submitButton: form.query(By.css('button[type="submit"]')), + logoutButton: form.query(By.css('button[type="button"]:not([bitPasswordInputToggle])')), + secondaryButton: form.query(By.css('button[buttonType="secondary"]')), + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + i18nService.t.mockImplementation((key: string) => key); + + await TestBed.configureTestingModule({ + imports: [ + MasterPasswordLockComponent, + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], + providers: [ + FormBuilder, + { provide: AccountService, useValue: accountService }, + { provide: MasterPasswordUnlockService, useValue: masterPasswordUnlockService }, + { provide: I18nService, useValue: i18nService }, + { provide: ToastService, useValue: toastService }, + { provide: LogService, useValue: logService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MasterPasswordLockComponent); + component = fixture.componentInstance; + }); + + describe("form rendering", () => { + let elements: ReturnType; + + beforeEach(() => { + elements = setupComponent(); + }); + + it("creates form with proper structure", () => { + expect(component.formGroup).toBeDefined(); + expect(component.formGroup.controls.masterPassword).toBeDefined(); + }); + + const formElementTests = [ + { + name: "master password input", + selector: "masterPasswordInput", + expectations: (el: HTMLInputElement) => { + expect(el).toMatchObject({ + type: "password", + name: "masterPassword", + required: true, + }); + expect(el.attributes).toHaveProperty("bitInput"); + }, + }, + { + name: "password toggle button", + selector: "toggleButton", + expectations: (el: HTMLButtonElement) => { + expect(el.type).toBe("button"); + expect(el.attributes).toHaveProperty("bitIconButton"); + }, + }, + { + name: "unlock submit button", + selector: "submitButton", + expectations: (el: HTMLButtonElement) => { + expect(el).toMatchObject({ + type: "submit", + textContent: expect.stringContaining("unlock"), + }); + expect(el.attributes).toHaveProperty("bitButton"); + }, + }, + { + name: "logout button", + selector: "logoutButton", + expectations: (el: HTMLButtonElement) => { + expect(el).toMatchObject({ + type: "button", + textContent: expect.stringContaining("logOut"), + }); + expect(el.attributes).toHaveProperty("bitButton"); + }, + }, + ]; + + test.each(formElementTests)("renders $name correctly", ({ selector, expectations }) => { + const element = elements[selector as keyof typeof elements] as DebugElement; + expect(element).toBeTruthy(); + expectations(element.nativeElement); + }); + + const hiddenButtonTests = [ + { + case: "biometrics swap button when biometrics is undefined", + setup: () => + setupComponent( + { + pin: { enabled: false }, + biometrics: { + enabled: undefined as unknown as boolean, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + "swapBiometrics", + ), + expectHidden: true, + }, + { + case: "biometrics swap button when biometrics is disabled", + setup: () => setupComponent({}, "swapBiometrics"), + expectHidden: true, + }, + { + case: "PIN swap button when PIN is disabled", + setup: () => setupComponent({}), + expectHidden: true, + }, + { + case: "PIN swap button when PIN is undefined", + setup: () => + setupComponent({ + pin: { enabled: undefined as unknown as boolean }, + biometrics: { + enabled: undefined as unknown as boolean, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }), + expectHidden: true, + }, + ]; + + test.each(hiddenButtonTests)("doesn't render $case", ({ setup, expectHidden }) => { + const { secondaryButton } = setup(); + expect(!!secondaryButton).toBe(!expectHidden); + }); + }); + + describe("password input", () => { + let setup: ReturnType; + beforeEach(() => { + setup = setupComponent(); + }); + + it("should bind form input to masterPassword form control", async () => { + const input = setup.masterPasswordInput; + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + expect(component.formGroup).toBeTruthy(); + const masterPasswordControl = component.formGroup!.get("masterPassword"); + expect(masterPasswordControl).toBeTruthy(); + + masterPasswordControl!.setValue("test-password"); + fixture.detectChanges(); + + const inputElement = input.nativeElement as HTMLInputElement; + expect(inputElement.value).toEqual("test-password"); + }); + + it("should validate required master password field", async () => { + const formGroup = component.formGroup; + + // Initially form should be invalid (empty required field) + expect(formGroup?.invalid).toEqual(true); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true); + + // Set a value + formGroup?.get("masterPassword")?.setValue("test-password"); + + expect(formGroup?.invalid).toEqual(false); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false); + }); + + it("should toggle password visibility when toggle button is clicked", async () => { + const toggleButton = setup.toggleButton; + expect(toggleButton).toBeTruthy(); + expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; + const input = setup.masterPasswordInput; + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + const inputElement = input.nativeElement as HTMLInputElement; + + // Initially password should be hidden + expect(inputElement.type).toEqual("password"); + + // Click toggle button + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(inputElement.type).toEqual("text"); + + // Click toggle button again + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(inputElement.type).toEqual("password"); + }); + }); + + describe("logout", () => { + it("emits logOut event when logout button is clicked", () => { + const setup = setupComponent(); + let logoutEmitted = false; + component.logOut.subscribe(() => { + logoutEmitted = true; + }); + + expect(setup.logoutButton).toBeTruthy(); + expect(setup.logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const logoutButtonElement = setup.logoutButton.nativeElement as HTMLButtonElement; + + // Click logout button + logoutButtonElement.click(); + + expect(logoutEmitted).toBe(true); + }); + }); + + describe("swap buttons", () => { + const swapButtonScenarios = [ + { + name: "PIN swap button when PIN is enabled", + unlockOptions: { + pin: { enabled: true }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + expectedText: "unlockWithPin", + expectedUnlockOption: UnlockOption.Pin, + shouldShow: true, + shouldEnable: true, + }, + { + name: "PIN swap button when PIN is disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + expectedText: "unlockWithPin", + expectedUnlockOption: UnlockOption.Pin, + shouldShow: false, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics status is available and enabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: true, + shouldEnable: true, + }, + { + name: "biometrics swap button when biometrics status is available and disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: true, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics biometrics status is unsupported and enabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.PlatformUnsupported }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: false, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics status is unsupported and disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: false, + shouldEnable: false, + }, + ]; + + test.each(swapButtonScenarios)( + "renders and handles $name", + ({ unlockOptions, expectedText, expectedUnlockOption, shouldShow, shouldEnable }) => { + const { secondaryButton, component } = setupComponent(unlockOptions, expectedText); + + if (shouldShow) { + expect(secondaryButton).toBeTruthy(); + expect(secondaryButton.nativeElement.textContent?.trim()).toBe(expectedText); + + if (shouldEnable) { + secondaryButton.nativeElement.click(); + expect(component.activeUnlockOption()).toBe(expectedUnlockOption); + } else { + expect(secondaryButton.nativeElement.getAttribute("aria-disabled")).toBe("true"); + } + } else { + expect(secondaryButton).toBeFalsy(); + } + }, + ); + }); + + describe("submit", () => { + test.each([null, undefined as unknown as string, ""])( + "won't unlock and show password invalid toast when master password is %s", + async (value) => { + component.formGroup.controls.masterPassword.setValue(value); + + await component.submit(); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: i18nService.t("errorOccurred"), + message: i18nService.t("masterPasswordRequired"), + }); + expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as Account, undefined as unknown as Account])( + "throws error when active account is %s", + async (value) => { + accountService.activeAccount$ = of(value); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + + await expect(component.submit()).rejects.toThrow("Null or undefined account"); + + expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled(); + }, + ); + + it("shows an error toast and logs the error when unlock with master password fails", async () => { + const customError = new Error("Specialized error message"); + masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue(customError); + accountService.activeAccount$ = of(activeAccount); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + + await component.submit(); + + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: i18nService.t("errorOccurred"), + message: i18nService.t("invalidMasterPassword"), + }); + expect(logService.error).toHaveBeenCalledWith( + "[MasterPasswordLockComponent] Failed to unlock via master password", + customError, + ); + }); + + it("emits userKey when unlock is successful", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + accountService.activeAccount$ = of(activeAccount); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + let emittedEvent: { userKey: UserKey; masterPassword: string } | undefined; + component.successfulUnlock.subscribe( + (event: { userKey: UserKey; masterPassword: string }) => { + emittedEvent = event; + }, + ); + + await component.submit(); + + expect(emittedEvent?.userKey).toEqual(mockUserKey); + expect(emittedEvent?.masterPassword).toEqual(mockMasterPassword); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + }); + }); +}); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts new file mode 100644 index 00000000000..c9399cc3ab2 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -0,0 +1,111 @@ +import { Component, computed, inject, input, model, output } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { + UnlockOption, + UnlockOptions, + UnlockOptionValue, +} from "../../services/lock-component.service"; + +@Component({ + selector: "bit-master-password-lock", + templateUrl: "master-password-lock.component.html", + imports: [ + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], +}) +export class MasterPasswordLockComponent { + private readonly accountService = inject(AccountService); + private readonly masterPasswordUnlockService = inject(MasterPasswordUnlockService); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + private readonly logService = inject(LogService); + UnlockOption = UnlockOption; + + activeUnlockOption = model.required(); + + unlockOptions = input.required(); + biometricUnlockBtnText = input.required(); + showPinSwap = computed(() => this.unlockOptions().pin.enabled ?? false); + biometricsAvailable = computed(() => this.unlockOptions().biometrics.enabled ?? false); + showBiometricsSwap = computed(() => { + const status = this.unlockOptions().biometrics.biometricsStatus; + return ( + status !== BiometricsStatus.PlatformUnsupported && + status !== BiometricsStatus.NotEnabledLocally + ); + }); + + successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); + logOut = output(); + + formGroup = new FormGroup({ + masterPassword: new FormControl("", { + validators: [Validators.required], + updateOn: "submit", + }), + }); + + submit = async () => { + this.formGroup.markAllAsTouched(); + const masterPassword = this.formGroup.controls.masterPassword.value; + if (this.formGroup.invalid || !masterPassword) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("masterPasswordRequired"), + }); + return; + } + + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + await this.unlockViaMasterPassword(masterPassword, activeUserId); + }; + + private async unlockViaMasterPassword( + masterPassword: string, + activeUserId: UserId, + ): Promise { + try { + const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( + masterPassword, + activeUserId, + ); + this.successfulUnlock.emit({ userKey, masterPassword }); + } catch (error) { + this.logService.error( + "[MasterPasswordLockComponent] Failed to unlock via master password", + error, + ); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidMasterPassword"), + }); + } + } +}