mirror of
https://github.com/bitwarden/browser
synced 2026-02-28 10:33:31 +00:00
Merge branch 'main' into uif/cl-958/avatar
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
<!-- Column: Device Name -->
|
||||
<td bitCell class="tw-flex tw-gap-2 tw-items-center tw-h-16">
|
||||
<div class="tw-flex tw-items-center tw-justify-center tw-w-10">
|
||||
<i [class]="device.icon" class="bwi-lg" aria-hidden="true"></i>
|
||||
<bit-icon [name]="device.icon" class="bwi-lg"></bit-icon>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
@@ -21,7 +22,15 @@ import { DeviceDisplayData } from "./device-management.component";
|
||||
standalone: true,
|
||||
selector: "auth-device-management-table",
|
||||
templateUrl: "./device-management-table.component.html",
|
||||
imports: [BadgeModule, ButtonModule, CommonModule, JslibModule, LinkModule, TableModule],
|
||||
imports: [
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
CommonModule,
|
||||
IconModule,
|
||||
JslibModule,
|
||||
LinkModule,
|
||||
TableModule,
|
||||
],
|
||||
})
|
||||
export class DeviceManagementTableComponent implements OnChanges {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[bitPopoverTriggerFor]="infoPopover"
|
||||
position="right-start"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-question-circle"></bit-icon>
|
||||
</button>
|
||||
|
||||
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
|
||||
@@ -23,7 +23,11 @@
|
||||
|
||||
@if (initializing) {
|
||||
<div class="tw-flex tw-justify-center tw-items-center tw-p-4">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-text-2xl" aria-hidden="true"></i>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-text-2xl"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Table View: displays on medium to large screens -->
|
||||
|
||||
@@ -19,7 +19,7 @@ import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { ButtonModule, DialogService, PopoverModule } from "@bitwarden/components";
|
||||
import { ButtonModule, DialogService, IconModule, PopoverModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { LoginApprovalDialogComponent } from "../login-approval";
|
||||
@@ -62,6 +62,7 @@ export interface DeviceDisplayData {
|
||||
DeviceManagementItemGroupComponent,
|
||||
DeviceManagementTableComponent,
|
||||
I18nPipe,
|
||||
IconModule,
|
||||
PopoverModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
|
||||
(click)="toggle(region.key)"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm bwi-check"
|
||||
<bit-icon
|
||||
name="bwi-check"
|
||||
class="bwi-fw bwi-sm"
|
||||
style="padding-bottom: 1px"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="data.selectedRegion === region ? 'visible' : 'hidden'"
|
||||
></i>
|
||||
></bit-icon>
|
||||
<span>{{ region.domain }}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -26,12 +26,12 @@
|
||||
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
|
||||
(click)="toggle(ServerEnvironmentType.SelfHosted)"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm bwi-check"
|
||||
<bit-icon
|
||||
name="bwi-check"
|
||||
class="bwi-fw bwi-sm"
|
||||
style="padding-bottom: 1px"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="data.selectedRegion ? 'hidden' : 'visible'"
|
||||
></i>
|
||||
></bit-icon>
|
||||
<span>{{ "selfHostedServer" | i18n }}</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
@@ -41,7 +41,7 @@
|
||||
<b class="tw-text-primary-600 tw-font-medium">{{
|
||||
data.selectedRegion?.domain || ("selfHostedServer" | i18n)
|
||||
}}</b>
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-angle-down" class="bwi-fw bwi-sm"></bit-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
DialogService,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
MenuModule,
|
||||
ToastService,
|
||||
@@ -26,7 +27,7 @@ import { I18nPipe } from "@bitwarden/ui-common";
|
||||
selector: "environment-selector",
|
||||
templateUrl: "environment-selector.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, I18nPipe, MenuModule, LinkModule, TypographyModule],
|
||||
imports: [CommonModule, I18nPipe, IconModule, LinkModule, MenuModule, TypographyModule],
|
||||
})
|
||||
export class EnvironmentSelectorComponent implements OnDestroy {
|
||||
protected ServerEnvironmentType = Region;
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
<ng-container bitDialogContent>
|
||||
<ng-container *ngIf="loading">
|
||||
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin bwi-3x"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
IconModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
@@ -35,7 +36,7 @@ export interface LoginApprovalDialogParams {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "login-approval-dialog.component.html",
|
||||
imports: [AsyncActionsModule, ButtonModule, CommonModule, DialogModule, JslibModule],
|
||||
imports: [AsyncActionsModule, ButtonModule, CommonModule, DialogModule, IconModule, JslibModule],
|
||||
})
|
||||
export class LoginApprovalDialogComponent implements OnInit, OnDestroy {
|
||||
authRequestId: string;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
@if (initializing) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin bwi-2x tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
} @else {
|
||||
<bit-callout
|
||||
*ngIf="this.forceSetPasswordReason !== ForceSetPasswordReason.AdminForcePasswordReset"
|
||||
|
||||
@@ -23,9 +23,10 @@ import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
DialogService,
|
||||
ToastService,
|
||||
CalloutComponent,
|
||||
DialogService,
|
||||
IconModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -44,7 +45,7 @@ import { ChangePasswordService } from "./change-password.service.abstraction";
|
||||
@Component({
|
||||
selector: "auth-change-password",
|
||||
templateUrl: "change-password.component.html",
|
||||
imports: [InputPasswordComponent, I18nPipe, CalloutComponent, CommonModule],
|
||||
imports: [CalloutComponent, CommonModule, IconModule, InputPasswordComponent, I18nPipe],
|
||||
})
|
||||
export class ChangePasswordComponent implements OnInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
|
||||
@@ -15,11 +15,13 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
|
||||
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
|
||||
import { assertNonNullish, assertTruthy } from "@bitwarden/common/auth/utils";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
@@ -43,8 +45,10 @@ import {
|
||||
InitializeJitPasswordCredentials,
|
||||
SetInitialPasswordCredentials,
|
||||
SetInitialPasswordService,
|
||||
SetInitialPasswordTdeOffboardingCredentials,
|
||||
SetInitialPasswordUserType,
|
||||
SetInitialPasswordTdeOffboardingCredentialsOld,
|
||||
SetInitialPasswordTdeOffboardingCredentials,
|
||||
SetInitialPasswordTdeUserWithPermissionCredentials,
|
||||
} from "./set-initial-password.service.abstraction";
|
||||
|
||||
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
|
||||
@@ -212,13 +216,65 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);
|
||||
|
||||
if (resetPasswordAutoEnroll) {
|
||||
await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId);
|
||||
await this.handleResetPasswordAutoEnrollOld(newServerMasterKeyHash, orgId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
async setInitialPasswordTdeOffboarding(
|
||||
credentials: SetInitialPasswordTdeOffboardingCredentials,
|
||||
userId: UserId,
|
||||
) {
|
||||
const ctx = "Could not set initial password.";
|
||||
assertTruthy(credentials.newPassword, "newPassword", ctx);
|
||||
assertTruthy(credentials.salt, "salt", ctx);
|
||||
assertNonNullish(credentials.kdfConfig, "kdfConfig", ctx);
|
||||
assertNonNullish(credentials.newPasswordHint, "newPasswordHint", ctx);
|
||||
|
||||
if (userId == null) {
|
||||
throw new Error("userId not found. Could not set password.");
|
||||
}
|
||||
|
||||
const { newPassword, salt, kdfConfig, newPasswordHint } = credentials;
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (userKey == null) {
|
||||
throw new Error("userKey not found. Could not set password.");
|
||||
}
|
||||
|
||||
const authenticationData: MasterPasswordAuthenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
newPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
);
|
||||
|
||||
const unlockData: MasterPasswordUnlockData =
|
||||
await this.masterPasswordService.makeMasterPasswordUnlockData(
|
||||
newPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
userKey,
|
||||
);
|
||||
|
||||
const request = UpdateTdeOffboardingPasswordRequest.newConstructorWithHint(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
newPasswordHint,
|
||||
);
|
||||
|
||||
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
|
||||
|
||||
// TODO: investigate removing this call to clear forceSetPasswordReason in https://bitwarden.atlassian.net/browse/PM-32660
|
||||
// Clear force set password reason to allow navigation back to vault.
|
||||
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
async setInitialPasswordTdeOffboardingOld(
|
||||
credentials: SetInitialPasswordTdeOffboardingCredentialsOld,
|
||||
userId: UserId,
|
||||
) {
|
||||
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
@@ -336,6 +392,86 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
);
|
||||
}
|
||||
|
||||
async setInitialPasswordTdeUserWithPermission(
|
||||
credentials: SetInitialPasswordTdeUserWithPermissionCredentials,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
const ctx =
|
||||
"Could not set initial password for TDE user with Manage Account Recovery permission.";
|
||||
|
||||
assertTruthy(credentials.newPassword, "newPassword", ctx);
|
||||
assertTruthy(credentials.salt, "salt", ctx);
|
||||
assertNonNullish(credentials.kdfConfig, "kdfConfig", ctx);
|
||||
assertNonNullish(credentials.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
|
||||
assertTruthy(credentials.orgSsoIdentifier, "orgSsoIdentifier", ctx);
|
||||
assertTruthy(credentials.orgId, "orgId", ctx);
|
||||
assertNonNullish(credentials.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
|
||||
assertTruthy(userId, "userId", ctx);
|
||||
|
||||
const {
|
||||
newPassword,
|
||||
salt,
|
||||
kdfConfig,
|
||||
newPasswordHint,
|
||||
orgSsoIdentifier,
|
||||
orgId,
|
||||
resetPasswordAutoEnroll,
|
||||
} = credentials;
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
if (!userKey) {
|
||||
throw new Error("userKey not found.");
|
||||
}
|
||||
|
||||
const authenticationData: MasterPasswordAuthenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
newPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
);
|
||||
|
||||
const unlockData: MasterPasswordUnlockData =
|
||||
await this.masterPasswordService.makeMasterPasswordUnlockData(
|
||||
newPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
userKey,
|
||||
);
|
||||
|
||||
const request = SetPasswordRequest.newConstructor(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
newPasswordHint,
|
||||
orgSsoIdentifier,
|
||||
null, // no KeysRequest for TDE user because they already have a key pair
|
||||
);
|
||||
|
||||
await this.masterPasswordApiService.setPassword(request);
|
||||
|
||||
// Clear force set password reason to allow navigation back to vault.
|
||||
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
|
||||
|
||||
// User now has a password so update decryption state
|
||||
await this.masterPasswordService.setMasterPasswordUnlockData(unlockData, userId);
|
||||
await this.updateLegacyState(
|
||||
newPassword,
|
||||
unlockData.kdf,
|
||||
new EncString(unlockData.masterKeyWrappedUserKey),
|
||||
userId,
|
||||
unlockData,
|
||||
);
|
||||
|
||||
if (resetPasswordAutoEnroll) {
|
||||
await this.handleResetPasswordAutoEnroll(
|
||||
authenticationData.masterPasswordAuthenticationHash,
|
||||
orgId,
|
||||
userId,
|
||||
userKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
@@ -441,7 +577,19 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
await this.masterPasswordService.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
|
||||
}
|
||||
|
||||
private async handleResetPasswordAutoEnroll(
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*
|
||||
* This method is now deprecated because it is used with the deprecated `setInitialPassword()` method,
|
||||
* which handles both JIT MP and TDE + Permission user flows.
|
||||
*
|
||||
* Since these methods can handle the JIT MP flow - which creates a new user key and sets it to state - we
|
||||
* must retreive that user key here in this method.
|
||||
*
|
||||
* But the new handleResetPasswordAutoEnroll() method is only used in the TDE + Permission user case, in which
|
||||
* case we already have the user key and can simply pass it through via method parameter ( @see handleResetPasswordAutoEnroll )
|
||||
*/
|
||||
private async handleResetPasswordAutoEnrollOld(
|
||||
masterKeyHash: string,
|
||||
orgId: string,
|
||||
userId: UserId,
|
||||
@@ -483,4 +631,43 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
|
||||
enrollmentRequest,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleResetPasswordAutoEnroll(
|
||||
masterKeyHash: string,
|
||||
orgId: string,
|
||||
userId: UserId,
|
||||
userKey: UserKey,
|
||||
) {
|
||||
const organizationKeys = await this.organizationApiService.getKeys(orgId);
|
||||
|
||||
if (organizationKeys == null) {
|
||||
throw new Error(
|
||||
"Organization keys response is null. Could not handle reset password auto enroll.",
|
||||
);
|
||||
}
|
||||
|
||||
const orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
|
||||
|
||||
// RSA encrypt user key with organization public key
|
||||
const orgPublicKeyEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
userKey,
|
||||
orgPublicKey,
|
||||
);
|
||||
|
||||
if (orgPublicKeyEncryptedUserKey == null || !orgPublicKeyEncryptedUserKey.encryptedString) {
|
||||
throw new Error(
|
||||
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
|
||||
);
|
||||
}
|
||||
|
||||
const enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest();
|
||||
enrollmentRequest.masterPasswordHash = masterKeyHash;
|
||||
enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString;
|
||||
|
||||
await this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
|
||||
orgId,
|
||||
userId,
|
||||
enrollmentRequest,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ 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 {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordAuthenticationHash,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
@@ -62,6 +65,8 @@ import {
|
||||
SetInitialPasswordCredentials,
|
||||
SetInitialPasswordService,
|
||||
SetInitialPasswordTdeOffboardingCredentials,
|
||||
SetInitialPasswordTdeOffboardingCredentialsOld,
|
||||
SetInitialPasswordTdeUserWithPermissionCredentials,
|
||||
SetInitialPasswordUserType,
|
||||
} from "./set-initial-password.service.abstraction";
|
||||
|
||||
@@ -237,7 +242,7 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Mock handleResetPasswordAutoEnroll() values
|
||||
// Mock handleResetPasswordAutoEnrollOld() values
|
||||
if (config.resetPasswordAutoEnroll) {
|
||||
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(orgPublicKeyEncryptedUserKey);
|
||||
@@ -753,10 +758,160 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setInitialPasswordTdeOffboarding(...)", () => {
|
||||
// Mock function parameters
|
||||
describe("setInitialPasswordTdeOffboarding()", () => {
|
||||
// Mock method parameters
|
||||
let credentials: SetInitialPasswordTdeOffboardingCredentials;
|
||||
|
||||
// Mock method data
|
||||
let userKey: UserKey;
|
||||
let authenticationData: MasterPasswordAuthenticationData;
|
||||
let unlockData: MasterPasswordUnlockData;
|
||||
let request: UpdateTdeOffboardingPasswordRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
credentials = {
|
||||
newPassword: "new-Password",
|
||||
salt: "salt" as MasterPasswordSalt,
|
||||
kdfConfig: DEFAULT_KDF_CONFIG,
|
||||
newPasswordHint: "newPasswordHint",
|
||||
};
|
||||
|
||||
userKey = makeSymmetricCryptoKey(64) as UserKey;
|
||||
|
||||
authenticationData = {
|
||||
salt: credentials.salt,
|
||||
kdf: credentials.kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
|
||||
unlockData = {
|
||||
salt: credentials.salt,
|
||||
kdf: credentials.kdfConfig,
|
||||
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
request = UpdateTdeOffboardingPasswordRequest.newConstructorWithHint(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
credentials.newPasswordHint,
|
||||
);
|
||||
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
|
||||
});
|
||||
|
||||
describe("general error handling", () => {
|
||||
["newPassword", "salt"].forEach((key) => {
|
||||
it(`should throw if ${key} is an empty string (falsy) on the SetInitialPasswordTdeOffboardingCredentials object`, async () => {
|
||||
// Arrange
|
||||
const invalidCredentials: SetInitialPasswordTdeOffboardingCredentials = {
|
||||
...credentials,
|
||||
[key]: "",
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(invalidCredentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(`${key} is falsy. Could not set initial password.`);
|
||||
});
|
||||
});
|
||||
|
||||
["kdfConfig", "newPasswordHint"].forEach((key) => {
|
||||
it(`should throw if ${key} is null/undefined on the SetInitialPasswordTdeOffboardingCredentials object`, async () => {
|
||||
// Arrange
|
||||
const invalidCredentials: SetInitialPasswordTdeOffboardingCredentials = {
|
||||
...credentials,
|
||||
[key]: null,
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(invalidCredentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
`${key} is null or undefined. Could not set initial password.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it(`should throw if the userId was not passed in`, async () => {
|
||||
// Arrange
|
||||
userId = null;
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("userId not found. Could not set password.");
|
||||
});
|
||||
|
||||
it(`should throw if the userKey was not found`, async () => {
|
||||
// Arrange
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("userKey not found. Could not set password.");
|
||||
});
|
||||
});
|
||||
|
||||
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
credentials.newPassword,
|
||||
credentials.kdfConfig,
|
||||
credentials.salt,
|
||||
);
|
||||
|
||||
expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
credentials.newPassword,
|
||||
credentials.kdfConfig,
|
||||
credentials.salt,
|
||||
userKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the API method to set a master password", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
|
||||
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledWith(
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
it("should set the ForceSetPasswordReason to None", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||
ForceSetPasswordReason.None,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties
|
||||
* in the test setup above that are now un-used and can also be removed.
|
||||
*/
|
||||
describe("setInitialPasswordTdeOffboardingOld(...)", () => {
|
||||
// Mock function parameters
|
||||
let credentials: SetInitialPasswordTdeOffboardingCredentialsOld;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock function parameters
|
||||
credentials = {
|
||||
@@ -781,7 +936,7 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
request.masterPasswordHint = credentials.newPasswordHint;
|
||||
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
await sut.setInitialPasswordTdeOffboardingOld(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
|
||||
@@ -796,7 +951,7 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
setupTdeOffboardingMocks();
|
||||
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
await sut.setInitialPasswordTdeOffboardingOld(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
|
||||
@@ -811,13 +966,13 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
["newMasterKey", "newServerMasterKeyHash", "newPasswordHint"].forEach((key) => {
|
||||
it(`should throw if ${key} is not provided on the SetInitialPasswordTdeOffboardingCredentials object`, async () => {
|
||||
// Arrange
|
||||
const invalidCredentials: SetInitialPasswordTdeOffboardingCredentials = {
|
||||
const invalidCredentials: SetInitialPasswordTdeOffboardingCredentialsOld = {
|
||||
...credentials,
|
||||
[key]: null,
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(invalidCredentials, userId);
|
||||
const promise = sut.setInitialPasswordTdeOffboardingOld(invalidCredentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`);
|
||||
@@ -829,7 +984,7 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
userId = null;
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
const promise = sut.setInitialPasswordTdeOffboardingOld(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("userId not found. Could not set password.");
|
||||
@@ -840,7 +995,7 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
const promise = sut.setInitialPasswordTdeOffboardingOld(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("userKey not found. Could not set password.");
|
||||
@@ -853,7 +1008,7 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
setupTdeOffboardingMocks();
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
const promise = sut.setInitialPasswordTdeOffboardingOld(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
@@ -1104,4 +1259,285 @@ describe("DefaultSetInitialPasswordService", () => {
|
||||
await expect(promise).rejects.toThrow("Unexpected V2 account cryptographic state");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setInitialPasswordTdeUserWithPermission()", () => {
|
||||
// Mock method parameters
|
||||
let credentials: SetInitialPasswordTdeUserWithPermissionCredentials;
|
||||
|
||||
// Mock method data
|
||||
let authenticationData: MasterPasswordAuthenticationData;
|
||||
let unlockData: MasterPasswordUnlockData;
|
||||
let setPasswordRequest: SetPasswordRequest;
|
||||
let userDecryptionOptions: UserDecryptionOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock method parameters
|
||||
credentials = {
|
||||
newPassword: "newPassword123!",
|
||||
salt: "user@example.com" as MasterPasswordSalt,
|
||||
kdfConfig: DEFAULT_KDF_CONFIG,
|
||||
newPasswordHint: "newPasswordHint",
|
||||
orgSsoIdentifier: "orgSsoIdentifier",
|
||||
orgId: "orgId" as OrganizationId,
|
||||
resetPasswordAutoEnroll: false,
|
||||
};
|
||||
|
||||
// Mock method data
|
||||
userKey = makeSymmetricCryptoKey(64) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
|
||||
authenticationData = {
|
||||
salt: credentials.salt,
|
||||
kdf: credentials.kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
|
||||
unlockData = {
|
||||
salt: credentials.salt,
|
||||
kdf: credentials.kdfConfig,
|
||||
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
|
||||
|
||||
setPasswordRequest = SetPasswordRequest.newConstructor(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
credentials.newPasswordHint,
|
||||
credentials.orgSsoIdentifier,
|
||||
null, // no KeysRequest for TDE user because they already have a key pair
|
||||
);
|
||||
|
||||
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: false });
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
of(userDecryptionOptions),
|
||||
);
|
||||
});
|
||||
|
||||
describe("general error handling", () => {
|
||||
["newPassword", "salt", "orgSsoIdentifier", "orgId"].forEach((key) => {
|
||||
it(`should throw if ${key} is an empty string (falsy) on the SetInitialPasswordTdeUserWithPermissionCredentials object`, async () => {
|
||||
// Arrange
|
||||
const invalidCredentials: SetInitialPasswordTdeUserWithPermissionCredentials = {
|
||||
...credentials,
|
||||
[key]: "",
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeUserWithPermission(invalidCredentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
`${key} is falsy. Could not set initial password for TDE user with Manage Account Recovery permission.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
["kdfConfig", "newPasswordHint", "resetPasswordAutoEnroll"].forEach((key) => {
|
||||
it(`should throw if ${key} is null on the SetInitialPasswordTdeUserWithPermissionCredentials object`, async () => {
|
||||
// Arrange
|
||||
const invalidCredentials: SetInitialPasswordTdeUserWithPermissionCredentials = {
|
||||
...credentials,
|
||||
[key]: null,
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeUserWithPermission(invalidCredentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
`${key} is null or undefined. Could not set initial password for TDE user with Manage Account Recovery permission.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if userId is not given", async () => {
|
||||
// Arrange
|
||||
userId = null;
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"userId is falsy. Could not set initial password for TDE user with Manage Account Recovery permission.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if the userKey is not found", async () => {
|
||||
// Arrange
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("userKey not found.");
|
||||
});
|
||||
|
||||
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
credentials.newPassword,
|
||||
credentials.kdfConfig,
|
||||
credentials.salt,
|
||||
);
|
||||
|
||||
expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
credentials.newPassword,
|
||||
credentials.kdfConfig,
|
||||
credentials.salt,
|
||||
userKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the API method to set a master password", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledTimes(1);
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
});
|
||||
|
||||
describe("given the initial password has been successfully set", () => {
|
||||
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||
ForceSetPasswordReason.None,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("should set MasterPasswordUnlockData to state", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
unlockData,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("should update legacy state", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
|
||||
userId,
|
||||
expect.objectContaining({ hasMasterPassword: true }),
|
||||
);
|
||||
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
|
||||
expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
new EncString(unlockData.masterKeyWrappedUserKey),
|
||||
userId,
|
||||
);
|
||||
expect(masterPasswordService.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith(
|
||||
credentials.newPassword,
|
||||
unlockData,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
describe("given resetPasswordAutoEnroll is false", () => {
|
||||
it("should NOT handle reset password (account recovery) auto enroll", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("given resetPasswordAutoEnroll is true", () => {
|
||||
let organizationKeys: OrganizationKeysResponse;
|
||||
let orgPublicKeyEncryptedUserKey: EncString;
|
||||
let enrollmentRequest: OrganizationUserResetPasswordEnrollmentRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
credentials.resetPasswordAutoEnroll = true;
|
||||
|
||||
organizationKeys = {
|
||||
privateKey: "orgPrivateKey",
|
||||
publicKey: "orgPublicKey",
|
||||
} as OrganizationKeysResponse;
|
||||
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
|
||||
|
||||
orgPublicKeyEncryptedUserKey = new EncString("orgPublicKeyEncryptedUserKey");
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(orgPublicKeyEncryptedUserKey);
|
||||
|
||||
enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest();
|
||||
enrollmentRequest.masterPasswordHash =
|
||||
authenticationData.masterPasswordAuthenticationHash;
|
||||
enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString;
|
||||
});
|
||||
|
||||
it("should throw if organization keys are not found", async () => {
|
||||
// Arrange
|
||||
organizationApiService.getKeys.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Organization keys response is null. Could not handle reset password auto enroll.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if orgPublicKeyEncryptedUserKey is not found", async () => {
|
||||
// Arrange
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if orgPublicKeyEncryptedUserKey.encryptedString is not found", async () => {
|
||||
// Arrange
|
||||
orgPublicKeyEncryptedUserKey.encryptedString = null;
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the API method to handle reset password (account recovery) auto enroll", async () => {
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).toHaveBeenCalledWith(credentials.orgId, userId, enrollmentRequest);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
@if (initializing) {
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-3x"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin bwi-3x" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
</div>
|
||||
} @else {
|
||||
@if (userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER_UNTRUSTED_DEVICE) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
ButtonModule,
|
||||
CalloutComponent,
|
||||
DialogService,
|
||||
IconModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -47,6 +48,8 @@ import {
|
||||
SetInitialPasswordCredentials,
|
||||
SetInitialPasswordService,
|
||||
SetInitialPasswordTdeOffboardingCredentials,
|
||||
SetInitialPasswordTdeOffboardingCredentialsOld,
|
||||
SetInitialPasswordTdeUserWithPermissionCredentials,
|
||||
SetInitialPasswordUserType,
|
||||
} from "./set-initial-password.service.abstraction";
|
||||
|
||||
@@ -55,7 +58,14 @@ import {
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "set-initial-password.component.html",
|
||||
imports: [ButtonModule, CalloutComponent, CommonModule, InputPasswordComponent, I18nPipe],
|
||||
imports: [
|
||||
ButtonModule,
|
||||
CalloutComponent,
|
||||
CommonModule,
|
||||
IconModule,
|
||||
InputPasswordComponent,
|
||||
I18nPipe,
|
||||
],
|
||||
})
|
||||
export class SetInitialPasswordComponent implements OnInit {
|
||||
protected inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAuthedUser;
|
||||
@@ -183,10 +193,22 @@ export class SetInitialPasswordComponent implements OnInit {
|
||||
break;
|
||||
}
|
||||
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
|
||||
if (passwordInputResult.newApisWithInputPasswordFlagEnabled) {
|
||||
await this.setInitialPasswordTdeUserWithPermission(passwordInputResult);
|
||||
return; // EARLY RETURN for flagged logic
|
||||
}
|
||||
|
||||
await this.setInitialPassword(passwordInputResult);
|
||||
|
||||
break;
|
||||
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
|
||||
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
|
||||
if (passwordInputResult.newApisWithInputPasswordFlagEnabled) {
|
||||
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.setInitialPasswordTdeOffboardingOld(passwordInputResult);
|
||||
|
||||
break;
|
||||
default:
|
||||
this.logService.error(
|
||||
@@ -382,7 +404,85 @@ export class SetInitialPasswordComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private async setInitialPasswordTdeUserWithPermission(passwordInputResult: PasswordInputResult) {
|
||||
const ctx =
|
||||
"Could not set initial password for TDE user with Manage Account Recovery permission.";
|
||||
|
||||
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
|
||||
assertTruthy(passwordInputResult.salt, "salt", ctx);
|
||||
assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx);
|
||||
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
|
||||
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
|
||||
assertTruthy(this.orgId, "orgId", ctx);
|
||||
assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
|
||||
assertTruthy(this.userId, "userId", ctx);
|
||||
|
||||
try {
|
||||
const credentials: SetInitialPasswordTdeUserWithPermissionCredentials = {
|
||||
newPassword: passwordInputResult.newPassword,
|
||||
salt: passwordInputResult.salt,
|
||||
kdfConfig: passwordInputResult.kdfConfig,
|
||||
newPasswordHint: passwordInputResult.newPasswordHint,
|
||||
orgSsoIdentifier: this.orgSsoIdentifier,
|
||||
orgId: this.orgId as OrganizationId,
|
||||
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
|
||||
};
|
||||
|
||||
await this.setInitialPasswordService.setInitialPasswordTdeUserWithPermission(
|
||||
credentials,
|
||||
this.userId,
|
||||
);
|
||||
|
||||
this.showSuccessToastByUserType();
|
||||
|
||||
this.submitting = false;
|
||||
await this.router.navigate(["vault"]);
|
||||
} catch (e) {
|
||||
this.logService.error("Error setting initial password", e);
|
||||
this.validationService.showError(e);
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async setInitialPasswordTdeOffboarding(passwordInputResult: PasswordInputResult) {
|
||||
const ctx = "Could not set initial password.";
|
||||
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
|
||||
assertTruthy(passwordInputResult.salt, "salt", ctx);
|
||||
assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx);
|
||||
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
|
||||
assertTruthy(this.userId, "userId", ctx);
|
||||
|
||||
try {
|
||||
const credentials: SetInitialPasswordTdeOffboardingCredentials = {
|
||||
newPassword: passwordInputResult.newPassword,
|
||||
salt: passwordInputResult.salt,
|
||||
kdfConfig: passwordInputResult.kdfConfig,
|
||||
newPasswordHint: passwordInputResult.newPasswordHint,
|
||||
};
|
||||
|
||||
await this.setInitialPasswordService.setInitialPasswordTdeOffboarding(
|
||||
credentials,
|
||||
this.userId,
|
||||
);
|
||||
|
||||
this.showSuccessToastByUserType();
|
||||
|
||||
// TODO: investigate refactoring logout and follow-up routing in https://bitwarden.atlassian.net/browse/PM-32660
|
||||
await this.logoutService.logout(this.userId);
|
||||
// navigate to root so redirect guard can properly route next active user or null user to correct page
|
||||
await this.router.navigate(["/"]);
|
||||
} catch (e) {
|
||||
this.logService.error("Error setting initial password during TDE offboarding", e);
|
||||
this.validationService.showError(e);
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
private async setInitialPasswordTdeOffboardingOld(passwordInputResult: PasswordInputResult) {
|
||||
const ctx = "Could not set initial password.";
|
||||
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
|
||||
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
|
||||
@@ -390,13 +490,13 @@ export class SetInitialPasswordComponent implements OnInit {
|
||||
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
|
||||
|
||||
try {
|
||||
const credentials: SetInitialPasswordTdeOffboardingCredentials = {
|
||||
const credentials: SetInitialPasswordTdeOffboardingCredentialsOld = {
|
||||
newMasterKey: passwordInputResult.newMasterKey,
|
||||
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
|
||||
newPasswordHint: passwordInputResult.newPasswordHint,
|
||||
};
|
||||
|
||||
await this.setInitialPasswordService.setInitialPasswordTdeOffboarding(
|
||||
await this.setInitialPasswordService.setInitialPasswordTdeOffboardingOld(
|
||||
credentials,
|
||||
this.userId,
|
||||
);
|
||||
|
||||
@@ -55,12 +55,32 @@ export interface SetInitialPasswordCredentials {
|
||||
salt: MasterPasswordSalt;
|
||||
}
|
||||
|
||||
export interface SetInitialPasswordTdeOffboardingCredentials {
|
||||
export interface SetInitialPasswordTdeUserWithPermissionCredentials {
|
||||
newPassword: string;
|
||||
salt: MasterPasswordSalt;
|
||||
kdfConfig: KdfConfig;
|
||||
newPasswordHint: string;
|
||||
orgSsoIdentifier: string;
|
||||
orgId: OrganizationId;
|
||||
resetPasswordAutoEnroll: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
export interface SetInitialPasswordTdeOffboardingCredentialsOld {
|
||||
newMasterKey: MasterKey;
|
||||
newServerMasterKeyHash: string;
|
||||
newPasswordHint: string;
|
||||
}
|
||||
|
||||
export interface SetInitialPasswordTdeOffboardingCredentials {
|
||||
newPassword: string;
|
||||
salt: MasterPasswordSalt;
|
||||
kdfConfig: KdfConfig;
|
||||
newPasswordHint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials required to initialize a just-in-time (JIT) provisioned user with a master password.
|
||||
*/
|
||||
@@ -104,6 +124,21 @@ export abstract class SetInitialPasswordService {
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets an initial password for an existing authed TDE user who has been given the
|
||||
* Manage Account Recovery permission:
|
||||
* - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP}
|
||||
*
|
||||
* @param credentials An object of the credentials needed to set the initial password
|
||||
* @throws If any property on the `credentials` object not found, or if userKey is not found
|
||||
*/
|
||||
abstract setInitialPasswordTdeUserWithPermission: (
|
||||
credentials: SetInitialPasswordTdeUserWithPermissionCredentials,
|
||||
userId: UserId,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*
|
||||
* Sets an initial password for a user who logs in after their org offboarded from
|
||||
* trusted device encryption and is now a master-password-encryption org:
|
||||
* - {@link SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER}
|
||||
@@ -111,8 +146,8 @@ export abstract class SetInitialPasswordService {
|
||||
* @param passwordInputResult credentials object received from the `InputPasswordComponent`
|
||||
* @param userId the account `userId`
|
||||
*/
|
||||
abstract setInitialPasswordTdeOffboarding: (
|
||||
credentials: SetInitialPasswordTdeOffboardingCredentials,
|
||||
abstract setInitialPasswordTdeOffboardingOld: (
|
||||
credentials: SetInitialPasswordTdeOffboardingCredentialsOld,
|
||||
userId: UserId,
|
||||
) => Promise<void>;
|
||||
|
||||
@@ -125,4 +160,18 @@ export abstract class SetInitialPasswordService {
|
||||
credentials: InitializeJitPasswordCredentials,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets an initial password for a user who logs in after their org offboarded from
|
||||
* trusted device encryption and is now a master-password-encryption org:
|
||||
* - {@link SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER}
|
||||
*
|
||||
* @param credentials An object of the credentials needed to set the initial password
|
||||
* @param userId the account `userId`
|
||||
* @throws if `userId`, `userKey`, or necessary credentials are not found
|
||||
*/
|
||||
abstract setInitialPasswordTdeOffboarding: (
|
||||
credentials: SetInitialPasswordTdeOffboardingCredentials,
|
||||
userId: UserId,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, ElementRef, OnDestroy, OnInit } from "@angular/core";
|
||||
import { NgControl } from "@angular/forms";
|
||||
import { Subscription } from "rxjs";
|
||||
|
||||
@Directive({
|
||||
selector: "[appA11yInvalid]",
|
||||
standalone: false,
|
||||
})
|
||||
export class A11yInvalidDirective implements OnDestroy, OnInit {
|
||||
private sub: Subscription;
|
||||
|
||||
constructor(
|
||||
private el: ElementRef<HTMLInputElement>,
|
||||
private formControlDirective: NgControl,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.sub = this.formControlDirective.control.statusChanges.subscribe((status) => {
|
||||
if (status === "INVALID") {
|
||||
this.el.nativeElement.setAttribute("aria-invalid", "true");
|
||||
} else if (status === "VALID") {
|
||||
this.el.nativeElement.setAttribute("aria-invalid", "false");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.sub?.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, ElementRef, HostListener, Input } from "@angular/core";
|
||||
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
@Directive({
|
||||
selector: "[appCopyText]",
|
||||
standalone: false,
|
||||
})
|
||||
export class CopyTextDirective {
|
||||
constructor(
|
||||
private el: ElementRef,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input("appCopyText") copyText: string;
|
||||
|
||||
@HostListener("copy") onCopy() {
|
||||
if (window == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = this.platformUtilsService.getClientType() === ClientType.Desktop ? 100 : 0;
|
||||
setTimeout(() => {
|
||||
this.platformUtilsService.copyToClipboard(this.copyText, { window: window });
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, ElementRef, HostListener, Input } from "@angular/core";
|
||||
|
||||
@Directive({
|
||||
selector: "[appFallbackSrc]",
|
||||
standalone: false,
|
||||
})
|
||||
export class FallbackSrcDirective {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input("appFallbackSrc") appFallbackSrc: string;
|
||||
|
||||
/** Only try setting the fallback once. This prevents an infinite loop if the fallback itself is missing. */
|
||||
private tryFallback = true;
|
||||
|
||||
constructor(private el: ElementRef) {}
|
||||
|
||||
@HostListener("error") onError() {
|
||||
if (this.tryFallback) {
|
||||
this.el.nativeElement.src = this.appFallbackSrc;
|
||||
this.tryFallback = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Directive, ElementRef, forwardRef, HostListener, Input, Renderer2 } from "@angular/core";
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
|
||||
// ref: https://juristr.com/blog/2018/02/ng-true-value-directive/
|
||||
@Directive({
|
||||
selector: "input[type=checkbox][appTrueFalseValue]",
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => TrueFalseValueDirective),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
standalone: false,
|
||||
})
|
||||
export class TrueFalseValueDirective implements ControlValueAccessor {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() trueValue: boolean | string = true;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() falseValue: boolean | string = false;
|
||||
|
||||
constructor(
|
||||
private elementRef: ElementRef,
|
||||
private renderer: Renderer2,
|
||||
) {}
|
||||
|
||||
@HostListener("change", ["$event"])
|
||||
onHostChange(ev: any) {
|
||||
this.propagateChange(ev.target.checked ? this.trueValue : this.falseValue);
|
||||
}
|
||||
|
||||
writeValue(obj: any): void {
|
||||
if (obj === this.trueValue) {
|
||||
this.renderer.setProperty(this.elementRef.nativeElement, "checked", true);
|
||||
} else {
|
||||
this.renderer.setProperty(this.elementRef.nativeElement, "checked", false);
|
||||
}
|
||||
}
|
||||
|
||||
registerOnChange(fn: any): void {
|
||||
this.propagateChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: any): void {
|
||||
/* nothing */
|
||||
}
|
||||
|
||||
setDisabledState?(isDisabled: boolean): void {
|
||||
/* nothing */
|
||||
}
|
||||
|
||||
private propagateChange = (_: any) => {
|
||||
/* nothing */
|
||||
};
|
||||
}
|
||||
@@ -26,11 +26,8 @@ import {
|
||||
|
||||
import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component";
|
||||
import { NotPremiumDirective } from "./billing/directives/not-premium.directive";
|
||||
import { A11yInvalidDirective } from "./directives/a11y-invalid.directive";
|
||||
import { ApiActionDirective } from "./directives/api-action.directive";
|
||||
import { BoxRowDirective } from "./directives/box-row.directive";
|
||||
import { CopyTextDirective } from "./directives/copy-text.directive";
|
||||
import { FallbackSrcDirective } from "./directives/fallback-src.directive";
|
||||
import { IfFeatureDirective } from "./directives/if-feature.directive";
|
||||
import { InputStripSpacesDirective } from "./directives/input-strip-spaces.directive";
|
||||
import { InputVerbatimDirective } from "./directives/input-verbatim.directive";
|
||||
@@ -38,18 +35,23 @@ import { LaunchClickDirective } from "./directives/launch-click.directive";
|
||||
import { StopClickDirective } from "./directives/stop-click.directive";
|
||||
import { StopPropDirective } from "./directives/stop-prop.directive";
|
||||
import { TextDragDirective } from "./directives/text-drag.directive";
|
||||
import { TrueFalseValueDirective } from "./directives/true-false-value.directive";
|
||||
import { CreditCardNumberPipe } from "./pipes/credit-card-number.pipe";
|
||||
import { PluralizePipe } from "./pipes/pluralize.pipe";
|
||||
import { SearchCiphersPipe } from "./pipes/search-ciphers.pipe";
|
||||
import { SearchPipe } from "./pipes/search.pipe";
|
||||
import { UserNamePipe } from "./pipes/user-name.pipe";
|
||||
import { UserTypePipe } from "./pipes/user-type.pipe";
|
||||
import { EllipsisPipe } from "./platform/pipes/ellipsis.pipe";
|
||||
import { FingerprintPipe } from "./platform/pipes/fingerprint.pipe";
|
||||
import { I18nPipe } from "./platform/pipes/i18n.pipe";
|
||||
import { IconComponent } from "./vault/components/icon.component";
|
||||
|
||||
/**
|
||||
* @deprecated In 95% of cases you want I18nPipe from `@bitwarden/ui-common`. In the other 5%
|
||||
* directly import the relevant directive/pipe/component. If you need one of the non standalone
|
||||
* pipes/directives/components, make it standalone and import directly.
|
||||
*
|
||||
* This module is overly large and adds many unrelated modules to your dependency tree.
|
||||
* https://angular.dev/guide/ngmodules/overview recommends not using `NgModule`s for new code.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
ToastModule.forRoot({
|
||||
@@ -82,57 +84,45 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
AutofocusDirective,
|
||||
],
|
||||
declarations: [
|
||||
A11yInvalidDirective,
|
||||
ApiActionDirective,
|
||||
BoxRowDirective,
|
||||
CopyTextDirective,
|
||||
CreditCardNumberPipe,
|
||||
EllipsisPipe,
|
||||
FallbackSrcDirective,
|
||||
I18nPipe,
|
||||
IconComponent,
|
||||
InputStripSpacesDirective,
|
||||
InputVerbatimDirective,
|
||||
NotPremiumDirective,
|
||||
SearchCiphersPipe,
|
||||
SearchPipe,
|
||||
StopClickDirective,
|
||||
StopPropDirective,
|
||||
TrueFalseValueDirective,
|
||||
LaunchClickDirective,
|
||||
UserNamePipe,
|
||||
UserTypePipe,
|
||||
IfFeatureDirective,
|
||||
FingerprintPipe,
|
||||
TwoFactorIconComponent,
|
||||
],
|
||||
exports: [
|
||||
A11yInvalidDirective,
|
||||
A11yTitleDirective,
|
||||
ApiActionDirective,
|
||||
AutofocusDirective,
|
||||
ToastModule,
|
||||
BoxRowDirective,
|
||||
CopyTextDirective,
|
||||
CreditCardNumberPipe,
|
||||
EllipsisPipe,
|
||||
FallbackSrcDirective,
|
||||
I18nPipe,
|
||||
IconComponent,
|
||||
InputStripSpacesDirective,
|
||||
InputVerbatimDirective,
|
||||
NotPremiumDirective,
|
||||
SearchCiphersPipe,
|
||||
SearchPipe,
|
||||
StopClickDirective,
|
||||
StopPropDirective,
|
||||
TrueFalseValueDirective,
|
||||
CopyClickDirective,
|
||||
LaunchClickDirective,
|
||||
UserNamePipe,
|
||||
UserTypePipe,
|
||||
IfFeatureDirective,
|
||||
FingerprintPipe,
|
||||
TwoFactorIconComponent,
|
||||
TextDragDirective,
|
||||
],
|
||||
@@ -143,7 +133,6 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
SearchPipe,
|
||||
UserNamePipe,
|
||||
UserTypePipe,
|
||||
FingerprintPipe,
|
||||
PluralizePipe,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition<Date>(
|
||||
},
|
||||
);
|
||||
const DISMISS_TIME_HOURS = 24;
|
||||
const VAULT_ROUTES = ["/vault", "/tabs/vault", "/tabs/current"];
|
||||
const VAULT_ROUTES = ["/new-vault", "/vault", "/tabs/vault", "/tabs/current"];
|
||||
|
||||
/**
|
||||
* This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
@Pipe({
|
||||
name: "searchCiphers",
|
||||
standalone: false,
|
||||
})
|
||||
export class SearchCiphersPipe implements PipeTransform {
|
||||
transform(ciphers: CipherView[], searchText: string, deleted = false): CipherView[] {
|
||||
if (ciphers == null || ciphers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (searchText == null || searchText.length < 2) {
|
||||
return ciphers.filter((c) => {
|
||||
return deleted !== c.isDeleted;
|
||||
});
|
||||
}
|
||||
|
||||
searchText = searchText.trim().toLowerCase();
|
||||
return ciphers.filter((c) => {
|
||||
if (deleted !== c.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
if (c.name != null && c.name.toLowerCase().indexOf(searchText) > -1) {
|
||||
return true;
|
||||
}
|
||||
if (searchText.length >= 8 && c.id.startsWith(searchText)) {
|
||||
return true;
|
||||
}
|
||||
if (c.subTitle != null && c.subTitle.toLowerCase().indexOf(searchText) > -1) {
|
||||
return true;
|
||||
}
|
||||
if (c.login && c.login.uri != null && c.login.uri.toLowerCase().indexOf(searchText) > -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Pipe } from "@angular/core";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@Pipe({
|
||||
name: "fingerprint",
|
||||
standalone: false,
|
||||
})
|
||||
export class FingerprintPipe {
|
||||
constructor(private keyService: KeyService) {}
|
||||
|
||||
async transform(publicKey: string | Uint8Array, fingerprintMaterial: string): Promise<string> {
|
||||
try {
|
||||
if (typeof publicKey === "string") {
|
||||
publicKey = Utils.fromB64ToArray(publicKey);
|
||||
}
|
||||
|
||||
const fingerprint = await this.keyService.getFingerprint(fingerprintMaterial, publicKey);
|
||||
|
||||
if (fingerprint != null) {
|
||||
return fingerprint.join("-");
|
||||
}
|
||||
|
||||
return "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ import {
|
||||
UserDecryptionOptionsService,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import {
|
||||
AutomaticUserConfirmationService,
|
||||
DefaultAutomaticUserConfirmationService,
|
||||
} from "@bitwarden/auto-confirm";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
@@ -385,6 +389,7 @@ import {
|
||||
DefaultStateService,
|
||||
} from "@bitwarden/state-internal";
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
import { DefaultUnlockService, UnlockService } from "@bitwarden/unlock";
|
||||
// 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 { PasswordRepromptService } from "@bitwarden/vault";
|
||||
@@ -915,6 +920,22 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultAccountCryptographicStateService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: UnlockService,
|
||||
useClass: DefaultUnlockService,
|
||||
deps: [
|
||||
RegisterSdkService,
|
||||
AccountCryptographicStateService,
|
||||
PinStateServiceAbstraction,
|
||||
KdfConfigService,
|
||||
AccountServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
StateProvider,
|
||||
LogService,
|
||||
BiometricsService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BroadcasterService,
|
||||
useClass: DefaultBroadcasterService,
|
||||
@@ -1060,6 +1081,19 @@ const safeProviders: SafeProvider[] = [
|
||||
PendingAuthRequestsStateService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AutomaticUserConfirmationService,
|
||||
useClass: DefaultAutomaticUserConfirmationService,
|
||||
deps: [
|
||||
ConfigService,
|
||||
ApiServiceAbstraction,
|
||||
OrganizationUserService,
|
||||
StateProvider,
|
||||
InternalOrganizationServiceAbstraction,
|
||||
OrganizationUserApiService,
|
||||
InternalPolicyService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ServerNotificationsService,
|
||||
useClass: devFlagEnabled("noopNotifications")
|
||||
@@ -1079,6 +1113,7 @@ const safeProviders: SafeProvider[] = [
|
||||
AuthRequestAnsweringService,
|
||||
ConfigService,
|
||||
InternalPolicyService,
|
||||
AutomaticUserConfirmationService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1181,6 +1216,7 @@ const safeProviders: SafeProvider[] = [
|
||||
PinServiceAbstraction,
|
||||
KdfConfigService,
|
||||
BiometricsService,
|
||||
MasterPasswordUnlockService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1510,12 +1546,12 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useClass: DefaultOrganizationMetadataService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService, PlatformUtilsServiceAbstraction],
|
||||
deps: [BillingApiServiceAbstraction, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BillingAccountProfileStateService,
|
||||
useClass: DefaultBillingAccountProfileStateService,
|
||||
deps: [StateProvider, PlatformUtilsServiceAbstraction, ApiServiceAbstraction],
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
@@ -1667,7 +1703,7 @@ const safeProviders: SafeProvider[] = [
|
||||
AccountServiceAbstraction,
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
SecurityStateService,
|
||||
AccountCryptographicStateService,
|
||||
ApiServiceAbstraction,
|
||||
StateProvider,
|
||||
ConfigService,
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from "./empty-trash";
|
||||
export * from "./favorites.icon";
|
||||
export * from "./gear";
|
||||
export * from "./generator";
|
||||
export * from "./info-filled.icon";
|
||||
export * from "./item-types";
|
||||
export * from "./lock.icon";
|
||||
export * from "./login-cards";
|
||||
|
||||
7
libs/assets/src/svg/svgs/info-filled.icon.ts
Normal file
7
libs/assets/src/svg/svgs/info-filled.icon.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { svg } from "../svg";
|
||||
|
||||
export const InfoFilledIcon = svg`
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path class="tw-fill-primary-600" d="M12 17C12.2833 17 12.5208 16.9042 12.7125 16.7125C12.9042 16.5208 13 16.2833 13 16V12C13 11.7167 12.9042 11.4792 12.7125 11.2875C12.5208 11.0958 12.2833 11 12 11C11.7167 11 11.4792 11.0958 11.2875 11.2875C11.0958 11.4792 11 11.7167 11 12V16C11 16.2833 11.0958 16.5208 11.2875 16.7125C11.4792 16.9042 11.7167 17 12 17ZM12 9C12.2833 9 12.5208 8.90417 12.7125 8.7125C12.9042 8.52083 13 8.28333 13 8C13 7.71667 12.9042 7.47917 12.7125 7.2875C12.5208 7.09583 12.2833 7 12 7C11.7167 7 11.4792 7.09583 11.2875 7.2875C11.0958 7.47917 11 7.71667 11 8C11 8.28333 11.0958 8.52083 11.2875 8.7125C11.4792 8.90417 11.7167 9 12 9ZM12 22C10.6167 22 9.31667 21.7375 8.1 21.2125C6.88333 20.6875 5.825 19.975 4.925 19.075C4.025 18.175 3.3125 17.1167 2.7875 15.9C2.2625 14.6833 2 13.3833 2 12C2 10.6167 2.2625 9.31667 2.7875 8.1C3.3125 6.88333 4.025 5.825 4.925 4.925C5.825 4.025 6.88333 3.3125 8.1 2.7875C9.31667 2.2625 10.6167 2 12 2C13.3833 2 14.6833 2.2625 15.9 2.7875C17.1167 3.3125 18.175 4.025 19.075 4.925C19.975 5.825 20.6875 6.88333 21.2125 8.1C21.7375 9.31667 22 10.6167 22 12C22 13.3833 21.7375 14.6833 21.2125 15.9C20.6875 17.1167 19.975 18.175 19.075 19.075C18.175 19.975 17.1167 20.6875 15.9 21.2125C14.6833 21.7375 13.3833 22 12 22Z" />
|
||||
</svg>
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
<bit-simple-dialog>
|
||||
<i bitDialogIcon class="bwi bwi-info-circle tw-text-info tw-text-3xl" aria-hidden="true"></i>
|
||||
<bit-icon bitDialogIcon name="bwi-info-circle" class="tw-text-info tw-text-3xl"></bit-icon>
|
||||
<span bitDialogTitle
|
||||
><strong>{{ "yourAccountsFingerprint" | i18n }}:</strong></span
|
||||
>
|
||||
@@ -16,7 +16,7 @@
|
||||
bitDialogClose
|
||||
>
|
||||
{{ "learnMore" | i18n }}
|
||||
<i class="bwi bwi-external-link bwi-fw" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-external-link" class="bwi-fw"></bit-icon>
|
||||
</a>
|
||||
<button bitButton type="button" buttonType="secondary" bitDialogClose>
|
||||
{{ "close" | i18n }}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogModule,
|
||||
DialogService,
|
||||
CenterPositionStrategy,
|
||||
IconModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export type FingerprintDialogData = {
|
||||
@@ -19,7 +20,7 @@ export type FingerprintDialogData = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "fingerprint-dialog.component.html",
|
||||
imports: [JslibModule, ButtonModule, DialogModule],
|
||||
imports: [JslibModule, ButtonModule, DialogModule, IconModule],
|
||||
})
|
||||
export class FingerprintDialogComponent {
|
||||
constructor(@Inject(DIALOG_DATA) protected data: FingerprintDialogData) {}
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
rel="noreferrer"
|
||||
appA11yTitle="{{ 'impactOfRotatingYourEncryptionKey' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-question-circle"></bit-icon>
|
||||
</a>
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
InputModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
@@ -112,6 +113,7 @@ interface InputPasswordForm {
|
||||
CheckboxModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
InputModule,
|
||||
JslibModule,
|
||||
PasswordCalloutComponent,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<ng-container *ngIf="loading">
|
||||
<div class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin bwi-2x tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
IconModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
ToastService,
|
||||
@@ -79,6 +80,7 @@ enum State {
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
CommonModule,
|
||||
IconModule,
|
||||
FormFieldModule,
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -13,16 +13,9 @@
|
||||
|
||||
## Standard Auth Request Flows
|
||||
|
||||
### Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory
|
||||
### Flow 1: This flow was removed
|
||||
|
||||
1. Unauthed user clicks "Login with device"
|
||||
2. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
|
||||
3. Receives approval from a device with authRequestPublicKey(masterKey)
|
||||
4. Decrypts masterKey
|
||||
5. Decrypts userKey
|
||||
6. Proceeds to vault
|
||||
|
||||
### Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory
|
||||
### Flow 2: Unauthed user requests approval from device; Approving device does NOT need to have a masterKey in memory
|
||||
|
||||
1. Unauthed user clicks "Login with device"
|
||||
2. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
|
||||
@@ -33,28 +26,18 @@
|
||||
**Note:** This flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could
|
||||
get into this flow:
|
||||
|
||||
1. An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey
|
||||
1. An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT need to have a masterKey
|
||||
in memory
|
||||
2. The org admin:
|
||||
- Changes the member decryption options from "Trusted devices" to "Master password" AND
|
||||
- Turns off the "Require single sign-on authentication" policy
|
||||
3. On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO
|
||||
4. The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in
|
||||
4. The user approves from the device they had previously logged into with SSO TD, which does NOT need to have a masterKey in
|
||||
memory
|
||||
|
||||
### Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory
|
||||
### Flow 3: This flow was removed
|
||||
|
||||
1. SSO TD user authenticates via SSO
|
||||
2. Navigates to `/login-initiated`
|
||||
3. Clicks "Approve from your other device"
|
||||
4. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
|
||||
5. Receives approval from device with authRequestPublicKey(masterKey)
|
||||
6. Decrypts masterKey
|
||||
7. Decrypts userKey
|
||||
8. Establishes trust (if required)
|
||||
9. Proceeds to vault
|
||||
|
||||
### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory
|
||||
### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT need to have a masterKey in memory
|
||||
|
||||
1. SSO TD user authenticates via SSO
|
||||
2. Navigates to `/login-initiated`
|
||||
@@ -89,9 +72,7 @@ userKey. This is how admins are able to send over the authRequestPublicKey(userK
|
||||
|
||||
| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* |
|
||||
| --------------- | ----------- | ----------------------------------------------------- | --------------------------- | ------------------------------------------------- |
|
||||
| Standard Flow 1 | unauthed | "Login with device" [`/login`] | `/login-with-device` | yes |
|
||||
| Standard Flow 2 | unauthed | "Login with device" [`/login`] | `/login-with-device` | no |
|
||||
| Standard Flow 3 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | yes |
|
||||
| Standard Flow 4 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | no |
|
||||
| Admin Flow | authed | "Request admin approval"<br>[`/login-initiated`] | `/admin-approval-requested` | NA - admin requests always send encrypted userKey |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<ng-container *ngIf="loading">
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin bwi-3x" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
// 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 { ButtonModule, LinkModule, ToastService } from "@bitwarden/components";
|
||||
import { ButtonModule, LinkModule, IconModule, ToastService } from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { AuthRequestApiServiceAbstraction } from "../../common/abstractions/auth-request-api.service";
|
||||
@@ -60,7 +60,7 @@ const matchOptions: IsActiveMatchOptions = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "./login-via-auth-request.component.html",
|
||||
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule],
|
||||
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule, IconModule],
|
||||
providers: [{ provide: LoginViaAuthRequestCacheService }],
|
||||
})
|
||||
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
@@ -605,10 +605,10 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
if (authRequestResponse.requestApproved) {
|
||||
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
|
||||
if (userHasAuthenticatedViaSSO) {
|
||||
// [Standard Flow 3-4] Handle authenticated SSO TD user flows
|
||||
// [Standard Flow 4] Handle authenticated SSO TD user flows
|
||||
return await this.handleAuthenticatedFlows(authRequestResponse);
|
||||
} else {
|
||||
// [Standard Flow 1-2] Handle unauthenticated user flows
|
||||
// [Standard Flow 2] Handle unauthenticated user flows
|
||||
return await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
|
||||
}
|
||||
}
|
||||
@@ -629,7 +629,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
|
||||
// [Standard Flow 3-4] Handle authenticated SSO TD user flows
|
||||
// [Standard Flow 4] Handle authenticated SSO TD user flows
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (!userId) {
|
||||
this.logService.error(
|
||||
@@ -654,7 +654,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
requestId: string,
|
||||
) {
|
||||
// [Standard Flow 1-2] Handle unauthenticated user flows
|
||||
// [Standard Flow 2] Handle unauthenticated user flows
|
||||
const authRequestLoginCredentials = await this.buildAuthRequestLoginCredentials(
|
||||
requestId,
|
||||
authRequestResponse,
|
||||
@@ -676,30 +676,15 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
|
||||
private async decryptViaApprovedAuthRequest(
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
privateKey: ArrayBuffer,
|
||||
privateKey: Uint8Array,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
/**
|
||||
* [Flow Type Detection]
|
||||
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
|
||||
* - If `masterPasswordHash` exists: Standard Flow 1 or 3 (device has masterKey)
|
||||
* - If no `masterPasswordHash`: Standard Flow 2, 4, or Admin Flow (device sends userKey)
|
||||
*/
|
||||
if (authRequestResponse.masterPasswordHash) {
|
||||
// [Standard Flow 1 or 3] Device has masterKey
|
||||
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||
authRequestResponse,
|
||||
privateKey,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
// [Standard Flow 2, 4, or Admin Flow] Device sends userKey
|
||||
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
|
||||
authRequestResponse,
|
||||
privateKey,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
// [Standard Flow 2, 4, or Admin Flow] Device sends userKey
|
||||
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
|
||||
authRequestResponse,
|
||||
privateKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
// [Admin Flow Cleanup] Clear one-time use admin auth request
|
||||
// clear the admin auth request from state so it cannot be used again (it's a one time use)
|
||||
@@ -758,43 +743,13 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* See verifyAndHandleApprovedAuthReq() for flow details.
|
||||
*
|
||||
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
|
||||
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
|
||||
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
|
||||
*/
|
||||
if (authRequestResponse.masterPasswordHash) {
|
||||
// ...in Standard Auth Request Flow 1
|
||||
const { masterKey, masterKeyHash } =
|
||||
await this.authRequestService.decryptPubKeyEncryptedMasterKeyAndHash(
|
||||
authRequestResponse.key,
|
||||
authRequestResponse.masterPasswordHash,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
);
|
||||
|
||||
return new AuthRequestLoginCredentials(
|
||||
this.email,
|
||||
this.accessCode,
|
||||
requestId,
|
||||
null, // no userKey
|
||||
masterKey,
|
||||
masterKeyHash,
|
||||
);
|
||||
} else {
|
||||
// ...in Standard Auth Request Flow 2
|
||||
const userKey = await this.authRequestService.decryptPubKeyEncryptedUserKey(
|
||||
authRequestResponse.key,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
);
|
||||
return new AuthRequestLoginCredentials(
|
||||
this.email,
|
||||
this.accessCode,
|
||||
requestId,
|
||||
userKey,
|
||||
null, // no masterKey
|
||||
null, // no masterKeyHash
|
||||
);
|
||||
}
|
||||
// ...in Standard Auth Request Flow 2
|
||||
const userKey = await this.authRequestService.decryptPubKeyEncryptedUserKey(
|
||||
authRequestResponse.key,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
);
|
||||
return new AuthRequestLoginCredentials(this.email, this.accessCode, requestId, userKey);
|
||||
}
|
||||
|
||||
private async clearExistingAdminAuthRequestAndStartNewRequest(userId: UserId) {
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
[addTooltipToDescribedby]="ssoRequired"
|
||||
[disabled]="ssoRequired"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-passkey" class="tw-mr-1"></bit-icon>
|
||||
{{ "logInWithPasskey" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
@@ -78,7 +78,7 @@
|
||||
[buttonType]="ssoRequired ? 'primary' : 'secondary'"
|
||||
(click)="handleSsoClick()"
|
||||
>
|
||||
<i class="bwi bwi-provider tw-mr-1" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-provider" class="tw-mr-1"></bit-icon>
|
||||
{{ "useSingleSignOn" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
buttonType="secondary"
|
||||
(click)="startAuthRequestLogin()"
|
||||
>
|
||||
<i class="bwi bwi-mobile" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-mobile"></bit-icon>
|
||||
{{ "loginWithDevice" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
CheckboxModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
TooltipDirective,
|
||||
@@ -79,6 +80,7 @@ export enum LoginUiState {
|
||||
CommonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin bwi-3x" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
</div>
|
||||
|
||||
<auth-input-password
|
||||
|
||||
@@ -17,7 +17,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.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 { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
|
||||
import { AnonLayoutWrapperDataService, ToastService, IconModule } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
@@ -43,7 +43,7 @@ type MarketingInitiative = (typeof MarketingInitiative)[keyof typeof MarketingIn
|
||||
@Component({
|
||||
selector: "auth-registration-finish",
|
||||
templateUrl: "./registration-finish.component.html",
|
||||
imports: [CommonModule, JslibModule, RouterModule, InputPasswordComponent],
|
||||
imports: [CommonModule, JslibModule, RouterModule, InputPasswordComponent, IconModule],
|
||||
})
|
||||
export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -16,11 +16,10 @@
|
||||
</bit-form-field>
|
||||
|
||||
<button bitLink linkType="primary" type="button" (click)="showCustomEnv = !showCustomEnv">
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm"
|
||||
[ngClass]="{ 'bwi-angle-right': !showCustomEnv, 'bwi-angle-down': showCustomEnv }"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<bit-icon
|
||||
[name]="showCustomEnv ? 'bwi-angle-down' : 'bwi-angle-right'"
|
||||
class="bwi-fw bwi-sm"
|
||||
></bit-icon>
|
||||
{{ "customEnvironment" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -91,7 +90,7 @@
|
||||
aria-live="assertive"
|
||||
role="alert"
|
||||
>
|
||||
<i class="bwi bwi-error"></i> {{ "selfHostedEnvFormInvalid" | i18n }}
|
||||
<bit-icon name="bwi-error"></bit-icon> {{ "selfHostedEnvFormInvalid" | i18n }}
|
||||
</span>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DialogModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
@@ -85,6 +86,7 @@ function onlyHttpsValidator(): ValidatorFn {
|
||||
JslibModule,
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-container">
|
||||
<div *ngIf="loggingIn">
|
||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
{{ "loading" | i18n }}
|
||||
</div>
|
||||
<div *ngIf="!loggingIn">
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
CheckboxModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
@@ -73,6 +74,7 @@ interface QueryParams {
|
||||
CommonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div id="web-authn-frame" class="tw-mb-3" *ngIf="!webAuthnNewTab">
|
||||
<div *ngIf="!webAuthnReady" class="tw-flex tw-items-center tw-justify-center">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin bwi-3x" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
TypographyModule,
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
@@ -48,6 +49,7 @@ export interface WebAuthnResult {
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
FormsModule,
|
||||
IconModule,
|
||||
],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<ng-container *ngIf="loading">
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin bwi-3x" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
ToastService,
|
||||
IconModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { TwoFactorAuthAuthenticatorComponent } from "./child-components/two-factor-auth-authenticator/two-factor-auth-authenticator.component";
|
||||
@@ -88,6 +89,7 @@ import {
|
||||
AsyncActionsModule,
|
||||
CheckboxModule,
|
||||
ButtonModule,
|
||||
IconModule,
|
||||
TwoFactorAuthAuthenticatorComponent,
|
||||
TwoFactorAuthEmailComponent,
|
||||
TwoFactorAuthDuoComponent,
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
<p class="tw-font-medium tw-mb-1">{{ "verifyWithBiometrics" | i18n }}</p>
|
||||
<div *ngIf="!biometricsVerificationFailed">
|
||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
{{ "awaitingConfirmation" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,7 +116,7 @@
|
||||
</ng-container>
|
||||
<ng-container *ngIf="userVerificationOptions.server.otp">
|
||||
<div class="tw-mb-6" *ngIf="!sentInitialCode">
|
||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
</div>
|
||||
|
||||
<div class="tw-mb-6" *ngIf="sentInitialCode">
|
||||
@@ -128,7 +128,7 @@
|
||||
</button>
|
||||
|
||||
<span class="tw-ml-2 tw-text-success" role="alert" @sent *ngIf="sentCode">
|
||||
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-check-circle"></bit-icon>
|
||||
{{ "codeSent" | i18n }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
SvgModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
@@ -64,6 +65,7 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
LinkModule,
|
||||
ButtonModule,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/a
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
export abstract class AuthRequestServiceAbstraction {
|
||||
/** Emits an auth request id when an auth request has been approved. */
|
||||
@@ -72,18 +72,7 @@ export abstract class AuthRequestServiceAbstraction {
|
||||
*/
|
||||
abstract setUserKeyAfterDecryptingSharedUserKey(
|
||||
authReqResponse: AuthRequestResponse,
|
||||
authReqPrivateKey: ArrayBuffer,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Sets the `MasterKey` and `MasterKeyHash` from an auth request. Auth request must have a `MasterKey` and `MasterKeyHash`.
|
||||
* @param authReqResponse The auth request.
|
||||
* @param authReqPrivateKey The private key corresponding to the public key sent in the auth request.
|
||||
* @param userId The ID of the user for whose account we will set the keys.
|
||||
*/
|
||||
abstract setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||
authReqResponse: AuthRequestResponse,
|
||||
authReqPrivateKey: ArrayBuffer,
|
||||
authReqPrivateKey: Uint8Array,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
/**
|
||||
@@ -94,20 +83,8 @@ export abstract class AuthRequestServiceAbstraction {
|
||||
*/
|
||||
abstract decryptPubKeyEncryptedUserKey(
|
||||
pubKeyEncryptedUserKey: string,
|
||||
privateKey: ArrayBuffer,
|
||||
privateKey: Uint8Array,
|
||||
): Promise<UserKey>;
|
||||
/**
|
||||
* Decrypts a `MasterKey` and `MasterKeyHash` from a public key encrypted `MasterKey` and `MasterKeyHash`.
|
||||
* @param pubKeyEncryptedMasterKey The public key encrypted `MasterKey`.
|
||||
* @param pubKeyEncryptedMasterKeyHash The public key encrypted `MasterKeyHash`.
|
||||
* @param privateKey The private key corresponding to the public key used to encrypt the `MasterKey` and `MasterKeyHash`.
|
||||
* @returns The decrypted `MasterKey` and `MasterKeyHash`.
|
||||
*/
|
||||
abstract decryptPubKeyEncryptedMasterKeyAndHash(
|
||||
pubKeyEncryptedMasterKey: string,
|
||||
pubKeyEncryptedMasterKeyHash: string,
|
||||
privateKey: ArrayBuffer,
|
||||
): Promise<{ masterKey: MasterKey; masterKeyHash: string }>;
|
||||
|
||||
/**
|
||||
* Handles incoming auth request push server notifications.
|
||||
|
||||
@@ -26,7 +26,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym
|
||||
import { makeEncString, FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
@@ -73,11 +73,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
const email = "EMAIL";
|
||||
const accessCode = "ACCESS_CODE";
|
||||
const authRequestId = "AUTH_REQUEST_ID";
|
||||
const decMasterKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).buffer as CsprngArray,
|
||||
) as MasterKey;
|
||||
const decUserKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
|
||||
const decMasterKeyHash = "LOCAL_PASSWORD_HASH";
|
||||
|
||||
beforeEach(async () => {
|
||||
keyService = mock<KeyService>();
|
||||
@@ -150,42 +146,6 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets keys after a successful authentication when masterKey and masterKeyHash provided in login credentials", async () => {
|
||||
credentials = new AuthRequestLoginCredentials(
|
||||
email,
|
||||
accessCode,
|
||||
authRequestId,
|
||||
null,
|
||||
decMasterKey,
|
||||
decMasterKeyHash,
|
||||
);
|
||||
|
||||
const masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
|
||||
|
||||
masterPasswordService.masterKeySubject.next(masterKey);
|
||||
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
||||
tokenService.decodeAccessToken.mockResolvedValue({ sub: mockUserId });
|
||||
|
||||
await authRequestLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(masterKey, mockUserId);
|
||||
expect(masterPasswordService.mock.setMasterKeyHash).toHaveBeenCalledWith(
|
||||
decMasterKeyHash,
|
||||
mockUserId,
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
tokenResponse.key,
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, mockUserId);
|
||||
expect(deviceTrustService.trustDeviceIfRequired).toHaveBeenCalled();
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
|
||||
{ V1: { private_key: tokenResponse.privateKey } },
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets keys after a successful authentication when only userKey provided in login credentials", async () => {
|
||||
// Initialize credentials with only userKey
|
||||
credentials = new AuthRequestLoginCredentials(
|
||||
@@ -193,8 +153,6 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
accessCode,
|
||||
authRequestId,
|
||||
decUserKey, // Pass userKey
|
||||
null, // No masterKey
|
||||
null, // No masterKeyHash
|
||||
);
|
||||
|
||||
// Call logIn
|
||||
@@ -240,7 +198,6 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
};
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
masterPasswordService.masterKeySubject.next(decMasterKey);
|
||||
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(decUserKey);
|
||||
|
||||
await authRequestLoginStrategy.logIn(credentials);
|
||||
|
||||
@@ -72,20 +72,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
|
||||
}
|
||||
|
||||
protected override async setMasterKey(response: IdentityTokenResponse, userId: UserId) {
|
||||
const authRequestCredentials = this.cache.value.authRequestCredentials;
|
||||
if (
|
||||
authRequestCredentials.decryptedMasterKey &&
|
||||
authRequestCredentials.decryptedMasterKeyHash
|
||||
) {
|
||||
await this.masterPasswordService.setMasterKey(
|
||||
authRequestCredentials.decryptedMasterKey,
|
||||
userId,
|
||||
);
|
||||
await this.masterPasswordService.setMasterKeyHash(
|
||||
authRequestCredentials.decryptedMasterKeyHash,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
// This login strategy does not use a master key
|
||||
}
|
||||
|
||||
protected override async setUserKey(
|
||||
|
||||
@@ -416,24 +416,6 @@ describe("SsoLoginStrategy", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the user key using master key and hash from approved admin request if exists", async () => {
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
keyService.hasUserKey.mockResolvedValue(true);
|
||||
const adminAuthResponse = {
|
||||
id: "1",
|
||||
publicKey: "PRIVATE" as any,
|
||||
key: "KEY" as any,
|
||||
masterPasswordHash: "HASH" as any,
|
||||
requestApproved: true,
|
||||
};
|
||||
apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash).toHaveBeenCalled();
|
||||
expect(deviceTrustService.decryptUserKeyWithDeviceKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets the user key from approved admin request if exists", async () => {
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
keyService.hasUserKey.mockResolvedValue(true);
|
||||
@@ -475,9 +457,6 @@ describe("SsoLoginStrategy", () => {
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(authRequestService.clearAdminAuthRequest).toHaveBeenCalled();
|
||||
expect(
|
||||
authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).not.toHaveBeenCalled();
|
||||
expect(deviceTrustService.trustDeviceIfRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -239,23 +239,11 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
}
|
||||
|
||||
if (adminAuthReqResponse?.requestApproved) {
|
||||
// if masterPasswordHash has a value, we will always receive authReqResponse.key
|
||||
// as authRequestPublicKey(masterKey) + authRequestPublicKey(masterPasswordHash)
|
||||
if (adminAuthReqResponse.masterPasswordHash) {
|
||||
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||
adminAuthReqResponse,
|
||||
adminAuthReqStorable.privateKey,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
// if masterPasswordHash is null, we will always receive authReqResponse.key
|
||||
// as authRequestPublicKey(userKey)
|
||||
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
|
||||
adminAuthReqResponse,
|
||||
adminAuthReqStorable.privateKey,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
|
||||
adminAuthReqResponse,
|
||||
adminAuthReqStorable.privateKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (await this.keyService.hasUserKey(userId)) {
|
||||
// Now that we have a decrypted user key in memory, we can check if we
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
export class PasswordLoginCredentials {
|
||||
readonly type = AuthenticationType.Password;
|
||||
@@ -54,8 +54,6 @@ export class AuthRequestLoginCredentials {
|
||||
public accessCode: string,
|
||||
public authRequestId: string,
|
||||
public decryptedUserKey: UserKey | null,
|
||||
public decryptedMasterKey: MasterKey | null,
|
||||
public decryptedMasterKeyHash: string | null,
|
||||
public twoFactor?: TokenTwoFactorRequest,
|
||||
) {}
|
||||
|
||||
@@ -66,8 +64,6 @@ export class AuthRequestLoginCredentials {
|
||||
json.accessCode,
|
||||
json.authRequestId,
|
||||
null,
|
||||
null,
|
||||
json.decryptedMasterKeyHash,
|
||||
json.twoFactor
|
||||
? new TokenTwoFactorRequest(
|
||||
json.twoFactor.provider,
|
||||
@@ -78,7 +74,6 @@ export class AuthRequestLoginCredentials {
|
||||
),
|
||||
{
|
||||
decryptedUserKey: SymmetricCryptoKey.fromJSON(json.decryptedUserKey) as UserKey,
|
||||
decryptedMasterKey: SymmetricCryptoKey.fromJSON(json.decryptedMasterKey) as MasterKey,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.ser
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@@ -154,60 +154,6 @@ describe("AuthRequestService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setKeysAfterDecryptingSharedMasterKeyAndHash", () => {
|
||||
it("decrypts and sets master key and hash and user key when given valid auth request response and private key", async () => {
|
||||
// Arrange
|
||||
const mockAuthReqResponse = {
|
||||
key: "authReqPublicKeyEncryptedMasterKey",
|
||||
masterPasswordHash: "authReqPublicKeyEncryptedMasterKeyHash",
|
||||
} as AuthRequestResponse;
|
||||
|
||||
const mockDecryptedMasterKey = {} as MasterKey;
|
||||
const mockDecryptedMasterKeyHash = "mockDecryptedMasterKeyHash";
|
||||
const mockDecryptedUserKey = {} as UserKey;
|
||||
|
||||
jest.spyOn(sut, "decryptPubKeyEncryptedMasterKeyAndHash").mockResolvedValueOnce({
|
||||
masterKey: mockDecryptedMasterKey,
|
||||
masterKeyHash: mockDecryptedMasterKeyHash,
|
||||
});
|
||||
|
||||
masterPasswordService.masterKeySubject.next(undefined);
|
||||
masterPasswordService.masterKeyHashSubject.next(undefined);
|
||||
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(
|
||||
mockDecryptedUserKey,
|
||||
);
|
||||
keyService.setUserKey.mockResolvedValueOnce(undefined);
|
||||
|
||||
// Act
|
||||
await sut.setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||
mockAuthReqResponse,
|
||||
mockPrivateKey,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(sut.decryptPubKeyEncryptedMasterKeyAndHash).toBeCalledWith(
|
||||
mockAuthReqResponse.key,
|
||||
mockAuthReqResponse.masterPasswordHash,
|
||||
mockPrivateKey,
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
mockDecryptedMasterKey,
|
||||
mockUserId,
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKeyHash).toHaveBeenCalledWith(
|
||||
mockDecryptedMasterKeyHash,
|
||||
mockUserId,
|
||||
);
|
||||
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||
mockDecryptedMasterKey,
|
||||
mockUserId,
|
||||
undefined,
|
||||
);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(mockDecryptedUserKey, mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptAuthReqPubKeyEncryptedUserKey", () => {
|
||||
it("returns a decrypted user key when given valid public key encrypted user key and an auth req private key", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -16,14 +16,13 @@ import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
AUTH_REQUEST_DISK_LOCAL,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { AuthRequestApiServiceAbstraction } from "../../abstractions/auth-request-api.service";
|
||||
@@ -163,27 +162,6 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
|
||||
await this.keyService.setUserKey(userKey, userId);
|
||||
}
|
||||
|
||||
async setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||
authReqResponse: AuthRequestResponse,
|
||||
authReqPrivateKey: Uint8Array,
|
||||
userId: UserId,
|
||||
) {
|
||||
const { masterKey, masterKeyHash } = await this.decryptPubKeyEncryptedMasterKeyAndHash(
|
||||
authReqResponse.key,
|
||||
authReqResponse.masterPasswordHash,
|
||||
authReqPrivateKey,
|
||||
);
|
||||
|
||||
// Decrypt and set user key in state
|
||||
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId);
|
||||
|
||||
// Set masterKey + masterKeyHash in state after decryption (in case decryption fails)
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
await this.masterPasswordService.setMasterKeyHash(masterKeyHash, userId);
|
||||
|
||||
await this.keyService.setUserKey(userKey, userId);
|
||||
}
|
||||
|
||||
// Decryption helpers
|
||||
async decryptPubKeyEncryptedUserKey(
|
||||
pubKeyEncryptedUserKey: string,
|
||||
@@ -197,30 +175,6 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
|
||||
return decryptedUserKey as UserKey;
|
||||
}
|
||||
|
||||
async decryptPubKeyEncryptedMasterKeyAndHash(
|
||||
pubKeyEncryptedMasterKey: string,
|
||||
pubKeyEncryptedMasterKeyHash: string,
|
||||
privateKey: Uint8Array,
|
||||
): Promise<{ masterKey: MasterKey; masterKeyHash: string }> {
|
||||
const decryptedMasterKeyArrayBuffer = await this.encryptService.rsaDecrypt(
|
||||
new EncString(pubKeyEncryptedMasterKey),
|
||||
privateKey,
|
||||
);
|
||||
|
||||
const decryptedMasterKeyHashArrayBuffer = await this.encryptService.rsaDecrypt(
|
||||
new EncString(pubKeyEncryptedMasterKeyHash),
|
||||
privateKey,
|
||||
);
|
||||
|
||||
const masterKey = new SymmetricCryptoKey(decryptedMasterKeyArrayBuffer) as MasterKey;
|
||||
const masterKeyHash = Utils.fromBufferToUtf8(decryptedMasterKeyHashArrayBuffer);
|
||||
|
||||
return {
|
||||
masterKey,
|
||||
masterKeyHash,
|
||||
};
|
||||
}
|
||||
|
||||
sendAuthRequestPushNotification(notification: AuthRequestPushNotification): void {
|
||||
if (notification.id != null) {
|
||||
this.authRequestPushNotificationSubject.next(notification.id);
|
||||
|
||||
@@ -93,8 +93,6 @@ describe("LOGIN_STRATEGY_CACHE_KEY", () => {
|
||||
"ACCESS_CODE",
|
||||
"AUTH_REQUEST_ID",
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey,
|
||||
"MASTER_KEY_HASH",
|
||||
);
|
||||
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(actual)));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AutoConfirmState } from "../models/auto-confirm-state.model";
|
||||
@@ -27,12 +27,12 @@ export abstract class AutomaticUserConfirmationService {
|
||||
/**
|
||||
* Calls the API endpoint to initiate automatic user confirmation.
|
||||
* @param userId The userId of the logged in admin performing auto confirmation. This is neccesary to perform the key exchange and for permissions checks.
|
||||
* @param confirmingUserId The userId of the user being confirmed.
|
||||
* @param organization the organization the user is being auto confirmed to.
|
||||
* @param confirmedUserId The userId of the member being confirmed.
|
||||
* @param organization the organization the member is being auto confirmed to.
|
||||
**/
|
||||
abstract autoConfirmUser(
|
||||
userId: UserId,
|
||||
confirmingUserId: UserId,
|
||||
organization: Organization,
|
||||
confirmedUserId: UserId,
|
||||
organization: OrganizationId,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ import { Router, UrlTree } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { Account, 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 { ToastService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "../abstractions";
|
||||
|
||||
import { canAccessAutoConfirmSettings } from "./automatic-user-confirmation-settings.guard";
|
||||
|
||||
describe("canAccessAutoConfirmSettings", () => {
|
||||
@@ -2,13 +2,12 @@ import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { map, switchMap } from "rxjs";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "../abstractions";
|
||||
|
||||
export const canAccessAutoConfirmSettings: CanActivateFn = () => {
|
||||
const accountService = inject(AccountService);
|
||||
const autoConfirmService = inject(AutomaticUserConfirmationService);
|
||||
8
libs/auto-confirm/src/angular/index.ts
Normal file
8
libs/auto-confirm/src/angular/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Re-export core auto-confirm functionality for convenience
|
||||
export * from "../abstractions";
|
||||
export * from "../models";
|
||||
export * from "../services";
|
||||
|
||||
// Angular-specific exports
|
||||
export * from "./components";
|
||||
export * from "./guards";
|
||||
@@ -1,5 +1,3 @@
|
||||
export * from "./abstractions";
|
||||
export * from "./components";
|
||||
export * from "./guards";
|
||||
export * from "./models";
|
||||
export * from "./services";
|
||||
|
||||
@@ -377,48 +377,85 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
defaultUserCollectionName: "encrypted-collection",
|
||||
} as OrganizationUserConfirmRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([mockOrganization]);
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
policyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
// Enable auto-confirm configuration for the user
|
||||
const enabledConfig = new AutoConfirmState();
|
||||
enabledConfig.enabled = true;
|
||||
await stateProvider.setUserState(
|
||||
AUTO_CONFIRM_STATE,
|
||||
{ [mockUserId]: enabledConfig },
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
apiService.getUserPublicKey.mockResolvedValue({
|
||||
publicKey: mockPublicKey,
|
||||
} as UserKeyResponse);
|
||||
jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray);
|
||||
organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest));
|
||||
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);
|
||||
organizationUserApiService.postOrganizationUserAutoConfirm.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should successfully auto-confirm a user", async () => {
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization);
|
||||
it("should successfully auto-confirm a user with organizationId", async () => {
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(apiService.getUserPublicKey).toHaveBeenCalledWith(mockUserId);
|
||||
expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith(
|
||||
mockOrganization,
|
||||
mockPublicKeyArray,
|
||||
);
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
mockConfirmingUserId,
|
||||
mockConfirmRequest,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not confirm user when canManageAutoConfirm returns false", async () => {
|
||||
it("should return early when canManageAutoConfirm returns false", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
await expect(
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization),
|
||||
).rejects.toThrow("Cannot automatically confirm user (insufficient permissions)");
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(apiService.getUserPublicKey).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return early when auto-confirm is disabled in configuration", async () => {
|
||||
const disabledConfig = new AutoConfirmState();
|
||||
disabledConfig.enabled = false;
|
||||
await stateProvider.setUserState(
|
||||
AUTO_CONFIRM_STATE,
|
||||
{ [mockUserId]: disabledConfig },
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(apiService.getUserPublicKey).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return early when auto-confirm is disabled in configuration", async () => {
|
||||
const disabledConfig = new AutoConfirmState();
|
||||
disabledConfig.enabled = false;
|
||||
await stateProvider.setUserState(
|
||||
AUTO_CONFIRM_STATE,
|
||||
{ [mockUserId]: disabledConfig },
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(apiService.getUserPublicKey).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should build confirm request with organization and public key", async () => {
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization);
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith(
|
||||
mockOrganization,
|
||||
@@ -427,10 +464,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
});
|
||||
|
||||
it("should call API with correct parameters", async () => {
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization);
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
|
||||
mockOrganization.id,
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
mockConfirmingUserId,
|
||||
mockConfirmRequest,
|
||||
);
|
||||
@@ -441,10 +478,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
apiService.getUserPublicKey.mockRejectedValue(apiError);
|
||||
|
||||
await expect(
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization),
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId),
|
||||
).rejects.toThrow("API Error");
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle buildConfirmRequest errors gracefully", async () => {
|
||||
@@ -452,10 +489,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
organizationUserService.buildConfirmRequest.mockReturnValue(throwError(() => buildError));
|
||||
|
||||
await expect(
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization),
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId),
|
||||
).rejects.toThrow("Build Error");
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,11 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
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";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
@@ -66,26 +67,44 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon
|
||||
|
||||
async autoConfirmUser(
|
||||
userId: UserId,
|
||||
confirmingUserId: UserId,
|
||||
organization: Organization,
|
||||
confirmedUserId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<void> {
|
||||
const canManage = await firstValueFrom(this.canManageAutoConfirm$(userId));
|
||||
|
||||
if (!canManage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only initiate auto confirmation if the local client setting has been turned on
|
||||
const autoConfirmEnabled = await firstValueFrom(
|
||||
this.configuration$(userId).pipe(map((state) => state.enabled)),
|
||||
);
|
||||
|
||||
if (!autoConfirmEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organization$ = this.organizationService.organizations$(userId).pipe(
|
||||
getById(organizationId),
|
||||
map((organization) => {
|
||||
if (organization == null) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return organization;
|
||||
}),
|
||||
);
|
||||
|
||||
const publicKeyResponse = await this.apiService.getUserPublicKey(userId);
|
||||
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
|
||||
|
||||
await firstValueFrom(
|
||||
this.canManageAutoConfirm$(userId).pipe(
|
||||
map((canManage) => {
|
||||
if (!canManage) {
|
||||
throw new Error("Cannot automatically confirm user (insufficient permissions)");
|
||||
}
|
||||
return canManage;
|
||||
}),
|
||||
switchMap(() => this.apiService.getUserPublicKey(userId)),
|
||||
map((publicKeyResponse) => Utils.fromB64ToArray(publicKeyResponse.publicKey)),
|
||||
switchMap((publicKey) =>
|
||||
this.organizationUserService.buildConfirmRequest(organization, publicKey),
|
||||
),
|
||||
organization$.pipe(
|
||||
switchMap((org) => this.organizationUserService.buildConfirmRequest(org, publicKey)),
|
||||
switchMap((request) =>
|
||||
this.organizationUserApiService.postOrganizationUserConfirm(
|
||||
organization.id,
|
||||
confirmingUserId,
|
||||
this.organizationUserApiService.postOrganizationUserAutoConfirm(
|
||||
organizationId,
|
||||
confirmedUserId,
|
||||
request,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -36,8 +36,6 @@ import {
|
||||
ProviderUserUserDetailsResponse,
|
||||
} from "../admin-console/models/response/provider/provider-user.response";
|
||||
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
|
||||
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";
|
||||
import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request";
|
||||
import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request";
|
||||
@@ -92,6 +90,7 @@ import { CipherRequest } from "../vault/models/request/cipher.request";
|
||||
import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response";
|
||||
import { AttachmentResponse } from "../vault/models/response/attachment.response";
|
||||
import { CipherMiniResponse, CipherResponse } from "../vault/models/response/cipher.response";
|
||||
import { DeleteAttachmentResponse } from "../vault/models/response/delete-attachment.response";
|
||||
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
|
||||
|
||||
/**
|
||||
@@ -152,8 +151,6 @@ export abstract class ApiService {
|
||||
abstract putProfile(request: UpdateProfileRequest): Promise<ProfileResponse>;
|
||||
abstract putAvatar(request: UpdateAvatarRequest): Promise<ProfileResponse>;
|
||||
abstract postPrelogin(request: PreloginRequest): Promise<PreloginResponse>;
|
||||
abstract postEmailToken(request: EmailTokenRequest): Promise<any>;
|
||||
abstract postEmail(request: EmailRequest): Promise<any>;
|
||||
abstract postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise<any>;
|
||||
abstract postSecurityStamp(request: SecretVerificationRequest): Promise<any>;
|
||||
abstract getAccountRevisionDate(): Promise<number>;
|
||||
@@ -243,8 +240,14 @@ export abstract class ApiService {
|
||||
id: string,
|
||||
request: AttachmentRequest,
|
||||
): Promise<AttachmentUploadDataResponse>;
|
||||
abstract deleteCipherAttachment(id: string, attachmentId: string): Promise<any>;
|
||||
abstract deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise<any>;
|
||||
abstract deleteCipherAttachment(
|
||||
id: string,
|
||||
attachmentId: string,
|
||||
): Promise<DeleteAttachmentResponse>;
|
||||
abstract deleteCipherAttachmentAdmin(
|
||||
id: string,
|
||||
attachmentId: string,
|
||||
): Promise<DeleteAttachmentResponse>;
|
||||
abstract postShareCipherAttachment(
|
||||
id: string,
|
||||
attachmentId: string,
|
||||
|
||||
@@ -66,6 +66,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
ssoMemberDecryptionType: undefined,
|
||||
useDisableSMAdsForUsers: false,
|
||||
usePhishingBlocker: false,
|
||||
useMyItems: false,
|
||||
},
|
||||
};
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
|
||||
|
||||
@@ -69,6 +69,7 @@ export class OrganizationData {
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
usePhishingBlocker: boolean;
|
||||
useMyItems: boolean;
|
||||
|
||||
constructor(
|
||||
response?: ProfileOrganizationResponse,
|
||||
@@ -139,6 +140,7 @@ export class OrganizationData {
|
||||
this.ssoEnabled = response.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = response.ssoMemberDecryptionType;
|
||||
this.usePhishingBlocker = response.usePhishingBlocker;
|
||||
this.useMyItems = response.useMyItems;
|
||||
|
||||
this.isMember = options.isMember;
|
||||
this.isProviderUser = options.isProviderUser;
|
||||
|
||||
@@ -100,6 +100,7 @@ export class Organization {
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
usePhishingBlocker: boolean;
|
||||
useMyItems: boolean;
|
||||
|
||||
constructor(obj?: OrganizationData) {
|
||||
if (obj == null) {
|
||||
@@ -166,6 +167,7 @@ export class Organization {
|
||||
this.ssoEnabled = obj.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType;
|
||||
this.usePhishingBlocker = obj.usePhishingBlocker;
|
||||
this.useMyItems = obj.useMyItems;
|
||||
}
|
||||
|
||||
get canAccess() {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PolicyRequest } from "./policy.request";
|
||||
|
||||
export interface VNextSavePolicyRequest<TMetadata = Record<string, unknown>> {
|
||||
policy: PolicyRequest;
|
||||
metadata: TMetadata | null;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
usePhishingBlocker: boolean;
|
||||
useMyItems: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -86,5 +87,6 @@ export class OrganizationResponse extends BaseResponse {
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false;
|
||||
this.useMyItems = this.getResponseProperty("UseMyItems") ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
usePhishingBlocker: boolean;
|
||||
useMyItems: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -139,5 +140,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
|
||||
this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType");
|
||||
this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false;
|
||||
this.useMyItems = this.getResponseProperty("UseMyItems") ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { MasterPasswordAuthenticationData } from "../../../key-management/master-password/types/master-password.types";
|
||||
|
||||
import { SecretVerificationRequest } from "./secret-verification.request";
|
||||
|
||||
export class EmailTokenRequest extends SecretVerificationRequest {
|
||||
newEmail: string;
|
||||
masterPasswordHash: string;
|
||||
|
||||
/**
|
||||
* Creates an EmailTokenRequest using new KM data types.
|
||||
* This will eventually become the primary constructor once all callers are updated.
|
||||
* @see https://bitwarden.atlassian.net/browse/PM-30811
|
||||
*/
|
||||
static forNewEmail(
|
||||
authenticationData: MasterPasswordAuthenticationData,
|
||||
newEmail: string,
|
||||
): EmailTokenRequest {
|
||||
const request = new EmailTokenRequest();
|
||||
request.newEmail = newEmail;
|
||||
request.authenticateWith(authenticationData);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,26 @@
|
||||
// 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 { OrganizationUserResetPasswordRequest } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
|
||||
export class UpdateTdeOffboardingPasswordRequest extends OrganizationUserResetPasswordRequest {
|
||||
masterPasswordHint: string;
|
||||
|
||||
// This will eventually be changed to be an actual constructor, once all callers are updated.
|
||||
// The body of this request will be changed to carry the authentication data and unlock data.
|
||||
// https://bitwarden.atlassian.net/browse/PM-23234
|
||||
static newConstructorWithHint(
|
||||
authenticationData: MasterPasswordAuthenticationData,
|
||||
unlockData: MasterPasswordUnlockData,
|
||||
masterPasswordHint: string,
|
||||
): UpdateTdeOffboardingPasswordRequest {
|
||||
const request = new UpdateTdeOffboardingPasswordRequest();
|
||||
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
|
||||
request.key = unlockData.masterKeyWrappedUserKey;
|
||||
request.masterPasswordHint = masterPasswordHint;
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ export class AuthRequestResponse extends BaseResponse {
|
||||
requestDeviceIdentifier: string;
|
||||
requestIpAddress: string;
|
||||
requestCountryName: string;
|
||||
key: string; // could be either an encrypted MasterKey or an encrypted UserKey
|
||||
masterPasswordHash: string; // if hash is present, the `key` above is an encrypted MasterKey (else `key` is an encrypted UserKey)
|
||||
key: string; // Auth-request public-key encrypted user-key. Note: No sender authenticity provided!
|
||||
creationDate: string;
|
||||
requestApproved?: boolean;
|
||||
responseDate?: string;
|
||||
@@ -30,7 +29,6 @@ export class AuthRequestResponse extends BaseResponse {
|
||||
this.requestIpAddress = this.getResponseProperty("RequestIpAddress");
|
||||
this.requestCountryName = this.getResponseProperty("RequestCountryName");
|
||||
this.key = this.getResponseProperty("Key");
|
||||
this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.requestApproved = this.getResponseProperty("RequestApproved");
|
||||
this.responseDate = this.getResponseProperty("ResponseDate");
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export abstract class ChangeEmailService {
|
||||
/**
|
||||
* Requests an email change token from the server.
|
||||
*
|
||||
* @param masterPassword The user's current master password
|
||||
* @param newEmail The new email address
|
||||
* @param userId The user's ID
|
||||
* @throws if master password verification fails
|
||||
*/
|
||||
abstract requestEmailToken(
|
||||
masterPassword: string,
|
||||
newEmail: string,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Confirms the email change with the token received via email.
|
||||
*
|
||||
* @param masterPassword The user's current master password
|
||||
* @param newEmail The new email address
|
||||
* @param token The verification token received via email
|
||||
* @param userId The user's ID
|
||||
* @throws if master password verification fails
|
||||
*/
|
||||
abstract confirmEmailChange(
|
||||
masterPassword: string,
|
||||
newEmail: string,
|
||||
token: string,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordAuthenticationHash,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// Marked for removal when PM-30811 feature flag is unwound.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
DEFAULT_KDF_CONFIG,
|
||||
KdfConfig,
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { DefaultChangeEmailService } from "./default-change-email.service";
|
||||
|
||||
describe("DefaultChangeEmailService", () => {
|
||||
let sut: DefaultChangeEmailService;
|
||||
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockMasterPassword = "master-password";
|
||||
const mockNewEmail = "new@example.com";
|
||||
const mockToken = "verification-token";
|
||||
const kdfConfig: KdfConfig = DEFAULT_KDF_CONFIG;
|
||||
const existingSalt = "existing@example.com" as MasterPasswordSalt;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = mock<ConfigService>();
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
apiService = mock<ApiService>();
|
||||
keyService = mock<KeyService>();
|
||||
|
||||
sut = new DefaultChangeEmailService(
|
||||
configService,
|
||||
masterPasswordService,
|
||||
kdfConfigService,
|
||||
apiService,
|
||||
keyService,
|
||||
);
|
||||
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
expect(sut).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("requestEmailToken", () => {
|
||||
/**
|
||||
* The email token request verifies that the user knows their master password
|
||||
* by computing a hash from the password and their current (existing) salt.
|
||||
* This proves identity before allowing email change to proceed.
|
||||
*/
|
||||
describe("verifies user identity with existing email credentials", () => {
|
||||
it("should use MasterPasswordService APIs", async () => {
|
||||
// Arrange: Flag enabled - use new KM APIs
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
|
||||
const authenticationData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert: Verifies identity using existing salt
|
||||
expect(masterPasswordService.mock.saltForUser$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData,
|
||||
).toHaveBeenCalledWith(mockMasterPassword, kdfConfig, existingSalt);
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy path - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should use KeyService APIs for legacy support", async () => {
|
||||
// Arrange: Flag disabled - use legacy KeyService
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue("existing-master-key-hash");
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert: Legacy path derives and hashes master key
|
||||
expect(keyService.getOrDeriveMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.hashMasterKey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* After verifying identity, the service sends a request to the server
|
||||
* to generate a verification token for the new email address.
|
||||
*/
|
||||
describe("sends token request to server", () => {
|
||||
it("should send request with authentication hash", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
|
||||
const authenticationData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/email-token",
|
||||
expect.objectContaining({
|
||||
newEmail: mockNewEmail,
|
||||
masterPasswordHash: authenticationData.masterPasswordAuthenticationHash,
|
||||
}),
|
||||
mockUserId,
|
||||
false, // hasResponse: false - server returns no body
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy path - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should send request with hashed master key for legacy support", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue("existing-master-key-hash");
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/email-token",
|
||||
expect.objectContaining({
|
||||
newEmail: mockNewEmail,
|
||||
masterPasswordHash: "existing-master-key-hash",
|
||||
}),
|
||||
mockUserId,
|
||||
false, // hasResponse: false - server returns no body
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Critical preconditions must be met before attempting the operation.
|
||||
* These guard against invalid state that would cause cryptographic failures.
|
||||
*/
|
||||
describe("error handling", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should throw if KDF config is null", async () => {
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId),
|
||||
).rejects.toThrow("kdf is null or undefined.");
|
||||
});
|
||||
|
||||
it("should throw if salt is null", async () => {
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(
|
||||
of(null as unknown as MasterPasswordSalt),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId),
|
||||
).rejects.toThrow("salt is null or undefined.");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures clean separation between old and new code paths.
|
||||
* When one path is active, the other's APIs should not be invoked.
|
||||
*/
|
||||
describe("API isolation", () => {
|
||||
it("should NOT call legacy KeyService APIs", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
});
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(keyService.getOrDeriveMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated To be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should NOT call new MasterPasswordService APIs for legacy support", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue("existing-master-key-hash");
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmEmailChange", () => {
|
||||
/**
|
||||
* The confirm request requires TWO authentication hashes:
|
||||
* 1. Existing salt hash - proves user knows their password (verification)
|
||||
* 2. New salt hash - will become the new authentication hash after email change
|
||||
*
|
||||
* This is because the master key derivation includes the email (as salt),
|
||||
* so changing email changes the derived master key.
|
||||
*/
|
||||
describe("verifies user identity with existing email credentials", () => {
|
||||
it("should create auth data with EXISTING salt for verification", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
const existingAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"existing-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "new-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newUnlockData: MasterPasswordUnlockData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-user-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData
|
||||
.mockResolvedValueOnce(existingAuthData)
|
||||
.mockResolvedValueOnce(newAuthData);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(newUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert: First call uses EXISTING salt for verification
|
||||
expect(
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData,
|
||||
).toHaveBeenNthCalledWith(1, mockMasterPassword, kdfConfig, existingSalt);
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy path - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should derive and hash master key with existing credentials for legacy support", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
const mockNewMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(2)) as MasterKey;
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey
|
||||
.mockResolvedValueOnce("existing-hash")
|
||||
.mockResolvedValueOnce("new-hash");
|
||||
keyService.makeMasterKey.mockResolvedValue(mockNewMasterKey);
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValue([
|
||||
mockUserKey,
|
||||
{ encryptedString: "encrypted-user-key" } as any,
|
||||
]);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert: Legacy path derives master key from existing user
|
||||
expect(keyService.getOrDeriveMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* When email changes, the salt changes (email IS the salt in Bitwarden).
|
||||
* This means the master key changes, so we must:
|
||||
* 1. Compute new authentication hash with new salt
|
||||
* 2. Re-wrap the user key with the new master key
|
||||
*/
|
||||
describe("creates new credentials with new email salt", () => {
|
||||
let mockUserKey: UserKey;
|
||||
let existingAuthData: MasterPasswordAuthenticationData;
|
||||
let newAuthData: MasterPasswordAuthenticationData;
|
||||
let newUnlockData: MasterPasswordUnlockData;
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
mockUserKey = new SymmetricCryptoKey(new Uint8Array(64).fill(3) as CsprngArray) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
existingAuthData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"existing-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
newAuthData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "new-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
newUnlockData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-user-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData
|
||||
.mockResolvedValueOnce(existingAuthData)
|
||||
.mockResolvedValueOnce(newAuthData);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(newUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should derive new salt from new email", async () => {
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
expect(masterPasswordService.mock.emailToSalt).toHaveBeenCalledWith(mockNewEmail);
|
||||
});
|
||||
|
||||
it("should create auth data with NEW salt for new password hash", async () => {
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Second call uses NEW salt for the new authentication hash
|
||||
expect(
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData,
|
||||
).toHaveBeenNthCalledWith(2, mockMasterPassword, kdfConfig, newSalt);
|
||||
});
|
||||
|
||||
it("should create unlock data with NEW salt to re-wrap user key", async () => {
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
expect(masterPasswordService.mock.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
kdfConfig,
|
||||
newSalt,
|
||||
mockUserKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The confirmation request carries all the data the server needs
|
||||
* to update the user's email and re-encrypt their keys.
|
||||
*/
|
||||
describe("sends confirmation request to server", () => {
|
||||
it("should send request with all required fields", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
const existingAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"existing-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "new-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newUnlockData: MasterPasswordUnlockData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-user-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData
|
||||
.mockResolvedValueOnce(existingAuthData)
|
||||
.mockResolvedValueOnce(newAuthData);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(newUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/email",
|
||||
expect.objectContaining({
|
||||
newEmail: mockNewEmail,
|
||||
token: mockToken,
|
||||
masterPasswordHash: existingAuthData.masterPasswordAuthenticationHash,
|
||||
newMasterPasswordHash: newAuthData.masterPasswordAuthenticationHash,
|
||||
key: newUnlockData.masterKeyWrappedUserKey,
|
||||
}),
|
||||
mockUserId,
|
||||
false, // hasResponse: false - server returns no body
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy path - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should send request with hashed keys for legacy support", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
const mockNewMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(2)) as MasterKey;
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey
|
||||
.mockResolvedValueOnce("existing-hash")
|
||||
.mockResolvedValueOnce("new-hash");
|
||||
keyService.makeMasterKey.mockResolvedValue(mockNewMasterKey);
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValue([
|
||||
mockUserKey,
|
||||
{ encryptedString: "encrypted-user-key" } as any,
|
||||
]);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/email",
|
||||
expect.objectContaining({
|
||||
newEmail: mockNewEmail,
|
||||
token: mockToken,
|
||||
masterPasswordHash: "existing-hash",
|
||||
newMasterPasswordHash: "new-hash",
|
||||
key: "encrypted-user-key",
|
||||
}),
|
||||
mockUserId,
|
||||
false, // hasResponse: false - server returns no body
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* After the server confirms the email change, we must update local state
|
||||
* so the application can continue operating with the new credentials.
|
||||
* This is a transitional requirement that will be removed in PM-30676.
|
||||
*/
|
||||
describe("maintains backwards compatibility", () => {
|
||||
it("should call setLegacyMasterKeyFromUnlockData after successful change", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
const existingAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"existing-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "new-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newUnlockData: MasterPasswordUnlockData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-user-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData
|
||||
.mockResolvedValueOnce(existingAuthData)
|
||||
.mockResolvedValueOnce(newAuthData);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(newUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert: Sets legacy master key for backwards compat (remove in PM-30676)
|
||||
expect(masterPasswordService.mock.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
newUnlockData,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The legacy master key MUST be set AFTER the API call succeeds.
|
||||
* If set before and the API fails, local state would be inconsistent with the server,
|
||||
* making the operation non-retry-able without logging out.
|
||||
*/
|
||||
it("should set legacy master key AFTER the API call succeeds", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
});
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue({
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Track call order
|
||||
const callOrder: string[] = [];
|
||||
apiService.send.mockImplementation(async () => {
|
||||
callOrder.push("apiService.send");
|
||||
});
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockImplementation(async () => {
|
||||
callOrder.push("setLegacyMasterKeyFromUnlockData");
|
||||
});
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert: API call must happen BEFORE legacy key update
|
||||
expect(callOrder).toEqual(["apiService.send", "setLegacyMasterKeyFromUnlockData"]);
|
||||
});
|
||||
|
||||
it("should NOT set legacy master key if API call fails", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
});
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue({
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
|
||||
// API call fails
|
||||
apiService.send.mockRejectedValue(new Error("Server error"));
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow("Server error");
|
||||
|
||||
// Legacy key should NOT have been set (preserves retry-ability)
|
||||
expect(masterPasswordService.mock.setLegacyMasterKeyFromUnlockData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Critical preconditions must be met before attempting the operation.
|
||||
* These guard against invalid state that would cause cryptographic failures.
|
||||
*/
|
||||
describe("error handling", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should throw if KDF config is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow("kdf is null or undefined.");
|
||||
});
|
||||
|
||||
it("should throw if user key is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow("userKey is null or undefined.");
|
||||
});
|
||||
|
||||
it("should throw if existing salt is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(
|
||||
of(null as unknown as MasterPasswordSalt),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow("salt is null or undefined.");
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy error cases - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
describe("legacy path errors", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("should throw if KDF config is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should throw if user key is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue("existing-hash");
|
||||
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures clean separation between old and new code paths.
|
||||
* When one path is active, the other's APIs should not be invoked.
|
||||
*/
|
||||
describe("API isolation", () => {
|
||||
it("should NOT call legacy KeyService APIs", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
});
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue({
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(keyService.getOrDeriveMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { MasterPasswordUnlockData } from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// Marked for removal when PM-30811 feature flag is unwound.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { EmailTokenRequest } from "../../models/request/email-token.request";
|
||||
import { EmailRequest } from "../../models/request/email.request";
|
||||
import { assertNonNullish } from "../../utils";
|
||||
|
||||
import { ChangeEmailService } from "./change-email.service";
|
||||
|
||||
export class DefaultChangeEmailService implements ChangeEmailService {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private apiService: ApiService,
|
||||
private keyService: KeyService,
|
||||
) {}
|
||||
|
||||
async requestEmailToken(masterPassword: string, newEmail: string, userId: UserId): Promise<void> {
|
||||
let request: EmailTokenRequest;
|
||||
|
||||
if (
|
||||
await this.configService.getFeatureFlag(FeatureFlag.PM30811_ChangeEmailNewAuthenticationApis)
|
||||
) {
|
||||
const saltForUser = await firstValueFrom(this.masterPasswordService.saltForUser$(userId));
|
||||
assertNonNullish(saltForUser, "salt");
|
||||
|
||||
const kdf = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
|
||||
assertNonNullish(kdf, "kdf");
|
||||
|
||||
const authenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
masterPassword,
|
||||
kdf,
|
||||
saltForUser,
|
||||
);
|
||||
|
||||
request = EmailTokenRequest.forNewEmail(authenticationData, newEmail);
|
||||
} else {
|
||||
// Legacy path: marked for removal when PM-30811 flag is unwound.
|
||||
// See: https://bitwarden.atlassian.net/browse/PM-30811
|
||||
|
||||
request = new EmailTokenRequest();
|
||||
request.newEmail = newEmail;
|
||||
request.masterPasswordHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
await this.keyService.getOrDeriveMasterKey(masterPassword, userId),
|
||||
);
|
||||
}
|
||||
|
||||
await this.apiService.send("POST", "/accounts/email-token", request, userId, false);
|
||||
}
|
||||
|
||||
async confirmEmailChange(
|
||||
masterPassword: string,
|
||||
newEmail: string,
|
||||
token: string,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
let request: EmailRequest;
|
||||
let unlockDataForLegacyUpdate: MasterPasswordUnlockData | null = null;
|
||||
|
||||
if (
|
||||
await this.configService.getFeatureFlag(FeatureFlag.PM30811_ChangeEmailNewAuthenticationApis)
|
||||
) {
|
||||
const kdf = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
|
||||
assertNonNullish(kdf, "kdf");
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
assertNonNullish(userKey, "userKey");
|
||||
|
||||
// Existing salt required for verification
|
||||
const existingSalt = await firstValueFrom(this.masterPasswordService.saltForUser$(userId));
|
||||
assertNonNullish(existingSalt, "salt");
|
||||
|
||||
// Create auth data with existing salt (proves user knows password)
|
||||
const existingAuthData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
masterPassword,
|
||||
kdf,
|
||||
existingSalt,
|
||||
);
|
||||
|
||||
const newSalt = this.masterPasswordService.emailToSalt(newEmail);
|
||||
const newAuthData = await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
masterPassword,
|
||||
kdf,
|
||||
newSalt,
|
||||
);
|
||||
const newUnlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
|
||||
masterPassword,
|
||||
kdf,
|
||||
newSalt,
|
||||
userKey,
|
||||
);
|
||||
|
||||
request = EmailRequest.newConstructor(newAuthData, newUnlockData);
|
||||
request.newEmail = newEmail;
|
||||
request.token = token;
|
||||
request.authenticateWith(existingAuthData);
|
||||
|
||||
// Track unlock data for legacy update after successful API call
|
||||
unlockDataForLegacyUpdate = newUnlockData;
|
||||
} else {
|
||||
// Legacy path: marked for removal when PM-30811 flag is unwound.
|
||||
// See: https://bitwarden.atlassian.net/browse/PM-30811
|
||||
|
||||
request = new EmailRequest();
|
||||
request.token = token;
|
||||
request.newEmail = newEmail;
|
||||
request.masterPasswordHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
await this.keyService.getOrDeriveMasterKey(masterPassword, userId),
|
||||
);
|
||||
|
||||
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
|
||||
if (kdfConfig == null) {
|
||||
throw new Error("Missing kdf config");
|
||||
}
|
||||
const newMasterKey = await this.keyService.makeMasterKey(masterPassword, newEmail, kdfConfig);
|
||||
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
newMasterKey,
|
||||
);
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (userKey == null) {
|
||||
throw new Error("Can't find UserKey");
|
||||
}
|
||||
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey, userKey);
|
||||
const encryptedUserKey = newUserKey[1]?.encryptedString;
|
||||
if (encryptedUserKey == null) {
|
||||
throw new Error("Missing Encrypted User Key");
|
||||
}
|
||||
request.key = encryptedUserKey;
|
||||
}
|
||||
|
||||
await this.apiService.send("POST", "/accounts/email", request, userId, false);
|
||||
|
||||
// Set legacy master key only AFTER successful API call to prevent inconsistent state on failure.
|
||||
// This ensures the operation is retry-able if the server request fails.
|
||||
// Remove in PM-30676.
|
||||
if (unlockDataForLegacyUpdate != null) {
|
||||
await this.masterPasswordService.setLegacyMasterKeyFromUnlockData(
|
||||
masterPassword,
|
||||
unlockDataForLegacyUpdate,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
|
||||
import { MasterPasswordUnlockService } from "../../../key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../../key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinLockType } from "../../../key-management/pin/pin-lock-type";
|
||||
import { PinServiceAbstraction } from "../../../key-management/pin/pin.service.abstraction";
|
||||
@@ -43,6 +44,7 @@ describe("UserVerificationService", () => {
|
||||
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
const kdfConfigService = mock<KdfConfigService>();
|
||||
const biometricsService = mock<BiometricsService>();
|
||||
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
@@ -61,6 +63,7 @@ describe("UserVerificationService", () => {
|
||||
pinService,
|
||||
kdfConfigService,
|
||||
biometricsService,
|
||||
masterPasswordUnlockService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -328,11 +331,10 @@ describe("UserVerificationService", () => {
|
||||
describe("client-side verification", () => {
|
||||
beforeEach(() => {
|
||||
setMasterPasswordAvailability(true);
|
||||
masterPasswordUnlockService.proofOfDecryption.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("returns if verification is successful", async () => {
|
||||
keyService.compareKeyHash.mockResolvedValueOnce(true);
|
||||
|
||||
const result = await sut.verifyUserByMasterPassword(
|
||||
{
|
||||
type: VerificationType.MasterPassword,
|
||||
@@ -342,7 +344,10 @@ describe("UserVerificationService", () => {
|
||||
"email",
|
||||
);
|
||||
|
||||
expect(keyService.compareKeyHash).toHaveBeenCalled();
|
||||
expect(masterPasswordUnlockService.proofOfDecryption).toHaveBeenCalledWith(
|
||||
"password",
|
||||
mockUserId,
|
||||
);
|
||||
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
|
||||
"localHash",
|
||||
mockUserId,
|
||||
@@ -356,7 +361,7 @@ describe("UserVerificationService", () => {
|
||||
});
|
||||
|
||||
it("throws if verification fails", async () => {
|
||||
keyService.compareKeyHash.mockResolvedValueOnce(false);
|
||||
masterPasswordUnlockService.proofOfDecryption.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
sut.verifyUserByMasterPassword(
|
||||
@@ -369,7 +374,10 @@ describe("UserVerificationService", () => {
|
||||
),
|
||||
).rejects.toThrow("Invalid master password");
|
||||
|
||||
expect(keyService.compareKeyHash).toHaveBeenCalled();
|
||||
expect(masterPasswordUnlockService.proofOfDecryption).toHaveBeenCalledWith(
|
||||
"password",
|
||||
mockUserId,
|
||||
);
|
||||
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalledWith();
|
||||
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalledWith();
|
||||
});
|
||||
@@ -401,7 +409,7 @@ describe("UserVerificationService", () => {
|
||||
"email",
|
||||
);
|
||||
|
||||
expect(keyService.compareKeyHash).not.toHaveBeenCalled();
|
||||
expect(masterPasswordUnlockService.proofOfDecryption).not.toHaveBeenCalled();
|
||||
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
|
||||
"localHash",
|
||||
mockUserId,
|
||||
@@ -435,7 +443,7 @@ describe("UserVerificationService", () => {
|
||||
),
|
||||
).rejects.toThrow("Invalid master password");
|
||||
|
||||
expect(keyService.compareKeyHash).not.toHaveBeenCalled();
|
||||
expect(masterPasswordUnlockService.proofOfDecryption).not.toHaveBeenCalled();
|
||||
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalledWith();
|
||||
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
KeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { MasterPasswordUnlockService } from "../../../key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../../key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinServiceAbstraction } from "../../../key-management/pin/pin.service.abstraction";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
@@ -54,6 +55,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
private pinService: PinServiceAbstraction,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private biometricsService: BiometricsService,
|
||||
private masterPasswordUnlockService: MasterPasswordUnlockService,
|
||||
) {}
|
||||
|
||||
async getAvailableVerificationOptions(
|
||||
@@ -202,9 +204,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
let policyOptions: MasterPasswordPolicyResponse | null;
|
||||
// Client-side verification
|
||||
if (await this.hasMasterPasswordAndMasterKeyHash(userId)) {
|
||||
const passwordValid = await this.keyService.compareKeyHash(
|
||||
const passwordValid = await this.masterPasswordUnlockService.proofOfDecryption(
|
||||
verification.secret,
|
||||
masterKey,
|
||||
userId,
|
||||
);
|
||||
if (!passwordValid) {
|
||||
@@ -214,12 +215,13 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
} else {
|
||||
// Server-side verification
|
||||
const request = new SecretVerificationRequest();
|
||||
const serverKeyHash = await this.keyService.hashMasterKey(
|
||||
verification.secret,
|
||||
masterKey,
|
||||
HashPurpose.ServerAuthorization,
|
||||
);
|
||||
request.masterPasswordHash = serverKeyHash;
|
||||
const authenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
verification.secret,
|
||||
kdfConfig,
|
||||
await firstValueFrom(this.masterPasswordService.saltForUser$(userId)),
|
||||
);
|
||||
request.authenticateWith(authenticationData);
|
||||
try {
|
||||
policyOptions = await this.userVerificationApiService.postAccountVerifyPassword(request);
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
|
||||
@@ -25,13 +25,6 @@ export abstract class BillingAccountProfileStateService {
|
||||
*/
|
||||
abstract hasPremiumFromAnySource$(userId: UserId): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Emits `true` when the subscription menu item should be shown in navigation.
|
||||
* This is hidden for organizations that provide premium, except if the user has premium personally
|
||||
* or has a billing history.
|
||||
*/
|
||||
abstract canViewSubscription$(userId: UserId): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Sets the user's premium status fields upon every full sync, either from their personal
|
||||
* subscription to premium, or an organization they're a part of that grants them premium.
|
||||
|
||||
@@ -21,11 +21,7 @@ export abstract class BillingApiServiceAbstraction {
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getOrganizationBillingMetadataVNext(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getOrganizationBillingMetadataVNextSelfHost(
|
||||
abstract getOrganizationBillingMetadataSelfHost(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export class PlanResponse extends BaseResponse {
|
||||
trialPeriodDays: number;
|
||||
hasSelfHost: boolean;
|
||||
hasPolicies: boolean;
|
||||
hasMyItems: boolean;
|
||||
hasGroups: boolean;
|
||||
hasDirectory: boolean;
|
||||
hasEvents: boolean;
|
||||
@@ -42,6 +43,7 @@ export class PlanResponse extends BaseResponse {
|
||||
this.trialPeriodDays = this.getResponseProperty("TrialPeriodDays");
|
||||
this.hasSelfHost = this.getResponseProperty("HasSelfHost");
|
||||
this.hasPolicies = this.getResponseProperty("HasPolicies");
|
||||
this.hasMyItems = this.getResponseProperty("HasMyItems");
|
||||
this.hasGroups = this.getResponseProperty("HasGroups");
|
||||
this.hasDirectory = this.getResponseProperty("HasDirectory");
|
||||
this.hasEvents = this.getResponseProperty("HasEvents");
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { BillingAccountProfile } from "@bitwarden/common/billing/abstractions";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
mockAccountServiceWith,
|
||||
FakeStateProvider,
|
||||
FakeSingleUserState,
|
||||
} from "../../../../spec";
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { BillingAccountProfile } from "../../abstractions/account/billing-account-profile-state.service";
|
||||
import { BillingHistoryResponse } from "../../models/response/billing-history.response";
|
||||
|
||||
import {
|
||||
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
|
||||
@@ -22,26 +20,14 @@ describe("BillingAccountProfileStateService", () => {
|
||||
let sut: DefaultBillingAccountProfileStateService;
|
||||
let userBillingAccountProfileState: FakeSingleUserState<BillingAccountProfile>;
|
||||
let accountService: FakeAccountService;
|
||||
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
let apiService: jest.Mocked<ApiService>;
|
||||
|
||||
const userId = "fakeUserId" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
platformUtilsService = {
|
||||
isSelfHost: jest.fn(),
|
||||
} as any;
|
||||
apiService = {
|
||||
getUserBillingHistory: jest.fn(),
|
||||
} as any;
|
||||
|
||||
sut = new DefaultBillingAccountProfileStateService(
|
||||
stateProvider,
|
||||
platformUtilsService,
|
||||
apiService,
|
||||
);
|
||||
sut = new DefaultBillingAccountProfileStateService(stateProvider);
|
||||
|
||||
userBillingAccountProfileState = stateProvider.singleUser.getFake(
|
||||
userId,
|
||||
@@ -131,74 +117,4 @@ describe("BillingAccountProfileStateService", () => {
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnySource$(userId))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canViewSubscription$", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsService.isSelfHost.mockReturnValue(false);
|
||||
apiService.getUserBillingHistory.mockResolvedValue(
|
||||
new BillingHistoryResponse({ invoices: [], transactions: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true when user has premium personally", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: true,
|
||||
hasPremiumFromAnyOrganization: true,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when user has no premium from any source", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: false,
|
||||
hasPremiumFromAnyOrganization: false,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when user has billing history in cloud environment", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: false,
|
||||
hasPremiumFromAnyOrganization: true,
|
||||
});
|
||||
platformUtilsService.isSelfHost.mockReturnValue(false);
|
||||
apiService.getUserBillingHistory.mockResolvedValue(
|
||||
new BillingHistoryResponse({
|
||||
invoices: [{ id: "1" }],
|
||||
transactions: [{ id: "2" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when user has no premium personally, has org premium, and no billing history", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: false,
|
||||
hasPremiumFromAnyOrganization: true,
|
||||
});
|
||||
platformUtilsService.isSelfHost.mockReturnValue(false);
|
||||
apiService.getUserBillingHistory.mockResolvedValue(
|
||||
new BillingHistoryResponse({
|
||||
invoices: [],
|
||||
transactions: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user has no premium personally, has org premium, in self-hosted environment", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: false,
|
||||
hasPremiumFromAnyOrganization: true,
|
||||
});
|
||||
platformUtilsService.isSelfHost.mockReturnValue(true);
|
||||
|
||||
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(false);
|
||||
expect(apiService.getUserBillingHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { map, Observable, combineLatest, concatMap } from "rxjs";
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { BILLING_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import {
|
||||
BillingAccountProfile,
|
||||
BillingAccountProfileStateService,
|
||||
} from "../../abstractions/account/billing-account-profile-state.service";
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
|
||||
export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION = new UserKeyDefinition<BillingAccountProfile>(
|
||||
BILLING_DISK,
|
||||
@@ -19,11 +18,7 @@ export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION = new UserKeyDefinition<Bill
|
||||
);
|
||||
|
||||
export class DefaultBillingAccountProfileStateService implements BillingAccountProfileStateService {
|
||||
constructor(
|
||||
private readonly stateProvider: StateProvider,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
private readonly apiService: ApiService,
|
||||
) {}
|
||||
constructor(private readonly stateProvider: StateProvider) {}
|
||||
|
||||
hasPremiumFromAnyOrganization$(userId: UserId): Observable<boolean> {
|
||||
return this.stateProvider
|
||||
@@ -69,26 +64,4 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
canViewSubscription$(userId: UserId): Observable<boolean> {
|
||||
return combineLatest([
|
||||
this.hasPremiumPersonally$(userId),
|
||||
this.hasPremiumFromAnyOrganization$(userId),
|
||||
]).pipe(
|
||||
concatMap(async ([hasPremiumPersonally, hasPremiumFromOrg]) => {
|
||||
if (hasPremiumPersonally === true || !hasPremiumFromOrg === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isCloud = !this.platformUtilsService.isSelfHost();
|
||||
|
||||
if (isCloud) {
|
||||
const billing = await this.apiService.getUserBillingHistory();
|
||||
return !billing?.hasNoHistory;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,20 +36,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
|
||||
async getOrganizationBillingMetadata(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/billing/metadata",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new OrganizationBillingMetadataResponse(r);
|
||||
}
|
||||
|
||||
async getOrganizationBillingMetadataVNext(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
@@ -62,7 +48,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
return new OrganizationBillingMetadataResponse(r);
|
||||
}
|
||||
|
||||
async getOrganizationBillingMetadataVNextSelfHost(
|
||||
async getOrganizationBillingMetadataSelfHost(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
const r = await this.apiService.send(
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
import { 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { OrganizationId } from "../../../types/guid";
|
||||
|
||||
import { DefaultOrganizationMetadataService } from "./organization-metadata.service";
|
||||
@@ -15,9 +13,7 @@ import { DefaultOrganizationMetadataService } from "./organization-metadata.serv
|
||||
describe("DefaultOrganizationMetadataService", () => {
|
||||
let service: DefaultOrganizationMetadataService;
|
||||
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
let featureFlagSubject: BehaviorSubject<boolean>;
|
||||
|
||||
const mockOrganizationId = newGuid() as OrganizationId;
|
||||
const mockOrganizationId2 = newGuid() as OrganizationId;
|
||||
@@ -34,182 +30,114 @@ describe("DefaultOrganizationMetadataService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
featureFlagSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
|
||||
platformUtilsService.isSelfHost.mockReturnValue(false);
|
||||
|
||||
service = new DefaultOrganizationMetadataService(
|
||||
billingApiService,
|
||||
configService,
|
||||
platformUtilsService,
|
||||
);
|
||||
service = new DefaultOrganizationMetadataService(billingApiService, platformUtilsService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
featureFlagSubject.complete();
|
||||
});
|
||||
|
||||
describe("getOrganizationMetadata$", () => {
|
||||
describe("feature flag OFF", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(false);
|
||||
});
|
||||
it("calls getOrganizationBillingMetadata for cloud-hosted", async () => {
|
||||
const mockResponse = createMockMetadataResponse(false, 10);
|
||||
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
|
||||
|
||||
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));
|
||||
|
||||
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);
|
||||
});
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
describe("feature flag ON", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(true);
|
||||
});
|
||||
it("calls getOrganizationBillingMetadataSelfHost when isSelfHost is true", async () => {
|
||||
platformUtilsService.isSelfHost.mockReturnValue(true);
|
||||
const mockResponse = createMockMetadataResponse(true, 25);
|
||||
billingApiService.getOrganizationBillingMetadataSelfHost.mockResolvedValue(mockResponse);
|
||||
|
||||
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));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("calls getOrganizationBillingMetadataVNextSelfHost when feature flag is on and isSelfHost is true", async () => {
|
||||
platformUtilsService.isSelfHost.mockReturnValue(true);
|
||||
const mockResponse = createMockMetadataResponse(true, 25);
|
||||
billingApiService.getOrganizationBillingMetadataVNextSelfHost.mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNextSelfHost).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
|
||||
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
|
||||
expect(billingApiService.getOrganizationBillingMetadataSelfHost).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
describe("shareReplay behavior", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(true);
|
||||
});
|
||||
it("caches metadata by organization ID", async () => {
|
||||
const mockResponse = createMockMetadataResponse(true, 10);
|
||||
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
|
||||
|
||||
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 result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
const metadata$ = service.getOrganizationMetadata$(mockOrganizationId);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
|
||||
expect(result1).toEqual(mockResponse);
|
||||
expect(result2).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
const subscription1Promise = firstValueFrom(metadata$);
|
||||
const subscription2Promise = firstValueFrom(metadata$);
|
||||
const subscription3Promise = firstValueFrom(metadata$);
|
||||
it("maintains separate cache entries for different organization IDs", async () => {
|
||||
const mockResponse1 = createMockMetadataResponse(true, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(false, 20);
|
||||
billingApiService.getOrganizationBillingMetadata
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const [result1, result2, result3] = await Promise.all([
|
||||
subscription1Promise,
|
||||
subscription2Promise,
|
||||
subscription3Promise,
|
||||
]);
|
||||
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(1);
|
||||
expect(result1).toEqual(mockResponse);
|
||||
expect(result2).toEqual(mockResponse);
|
||||
expect(result3).toEqual(mockResponse);
|
||||
});
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockOrganizationId2,
|
||||
);
|
||||
expect(result1).toEqual(mockResponse1);
|
||||
expect(result2).toEqual(mockResponse2);
|
||||
expect(result3).toEqual(mockResponse1);
|
||||
expect(result4).toEqual(mockResponse2);
|
||||
});
|
||||
|
||||
it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => {
|
||||
const mockResponse = createMockMetadataResponse(true, 10);
|
||||
billingApiService.getOrganizationBillingMetadata.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.getOrganizationBillingMetadata).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) => {
|
||||
it("refreshes cached metadata when called", (done) => {
|
||||
const mockResponse1 = createMockMetadataResponse(true, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(true, 20);
|
||||
let invocationCount = 0;
|
||||
|
||||
billingApiService.getOrganizationBillingMetadataVNext
|
||||
billingApiService.getOrganizationBillingMetadata
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
@@ -221,7 +149,7 @@ describe("DefaultOrganizationMetadataService", () => {
|
||||
expect(result).toEqual(mockResponse1);
|
||||
} else if (invocationCount === 2) {
|
||||
expect(result).toEqual(mockResponse2);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
@@ -234,45 +162,13 @@ describe("DefaultOrganizationMetadataService", () => {
|
||||
}, 10);
|
||||
});
|
||||
|
||||
it("does 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();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(invocationCount).toBe(2);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
|
||||
|
||||
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
|
||||
billingApiService.getOrganizationBillingMetadata
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2)
|
||||
.mockResolvedValueOnce(mockResponse3);
|
||||
@@ -289,7 +185,7 @@ describe("DefaultOrganizationMetadataService", () => {
|
||||
service.refreshMetadataCache();
|
||||
} else if (invocationCount === 3) {
|
||||
expect(result).toEqual(mockResponse3);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(3);
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
|
||||
import { BehaviorSubject, from, Observable, shareReplay, switchMap } from "rxjs";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
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";
|
||||
@@ -17,7 +15,6 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
|
||||
@@ -28,50 +25,26 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
|
||||
};
|
||||
|
||||
getOrganizationMetadata$(orgId: OrganizationId): Observable<OrganizationBillingMetadataResponse> {
|
||||
return combineLatest([
|
||||
this.refreshMetadataTrigger,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure),
|
||||
]).pipe(
|
||||
switchMap(([_, featureFlagEnabled]) =>
|
||||
featureFlagEnabled
|
||||
? this.vNextGetOrganizationMetadataInternal$(orgId)
|
||||
: this.getOrganizationMetadataInternal$(orgId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private vNextGetOrganizationMetadataInternal$(
|
||||
orgId: OrganizationId,
|
||||
): Observable<OrganizationBillingMetadataResponse> {
|
||||
const cacheHit = this.metadataCache.get(orgId);
|
||||
if (cacheHit) {
|
||||
return cacheHit;
|
||||
}
|
||||
|
||||
const result = from(this.fetchMetadata(orgId, true)).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
this.metadataCache.set(orgId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private getOrganizationMetadataInternal$(
|
||||
organizationId: OrganizationId,
|
||||
): Observable<OrganizationBillingMetadataResponse> {
|
||||
return from(this.fetchMetadata(organizationId, false)).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
return this.refreshMetadataTrigger.pipe(
|
||||
switchMap(() => {
|
||||
const cacheHit = this.metadataCache.get(orgId);
|
||||
if (cacheHit) {
|
||||
return cacheHit;
|
||||
}
|
||||
const result = from(this.fetchMetadata(orgId)).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
this.metadataCache.set(orgId, result);
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchMetadata(
|
||||
organizationId: OrganizationId,
|
||||
featureFlagEnabled: boolean,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
return featureFlagEnabled
|
||||
? this.platformUtilsService.isSelfHost()
|
||||
? await this.billingApiService.getOrganizationBillingMetadataVNextSelfHost(organizationId)
|
||||
: await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
|
||||
return this.platformUtilsService.isSelfHost()
|
||||
? await this.billingApiService.getOrganizationBillingMetadataSelfHost(organizationId)
|
||||
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
trialPeriodDays: 7,
|
||||
hasSelfHost: false,
|
||||
hasPolicies: false,
|
||||
hasMyItems: false,
|
||||
hasGroups: false,
|
||||
hasDirectory: false,
|
||||
hasEvents: false,
|
||||
@@ -80,6 +81,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
trialPeriodDays: 7,
|
||||
hasSelfHost: true,
|
||||
hasPolicies: true,
|
||||
hasMyItems: false,
|
||||
hasGroups: true,
|
||||
hasDirectory: true,
|
||||
hasEvents: true,
|
||||
@@ -131,6 +133,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
trialPeriodDays: 7,
|
||||
hasSelfHost: true,
|
||||
hasPolicies: true,
|
||||
hasMyItems: true,
|
||||
hasGroups: true,
|
||||
hasDirectory: true,
|
||||
hasEvents: true,
|
||||
@@ -181,6 +184,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
trialPeriodDays: null,
|
||||
hasSelfHost: false,
|
||||
hasPolicies: false,
|
||||
hasMyItems: false,
|
||||
hasGroups: false,
|
||||
hasDirectory: false,
|
||||
hasEvents: false,
|
||||
@@ -231,6 +235,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
},
|
||||
storage: {
|
||||
price: 4,
|
||||
provided: 1,
|
||||
},
|
||||
} as PremiumPlanResponse;
|
||||
|
||||
@@ -350,7 +355,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
|
||||
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(environmentService);
|
||||
|
||||
service = new DefaultSubscriptionPricingService(
|
||||
@@ -915,7 +920,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const testError = new Error("Premium plan API error");
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
@@ -959,71 +964,16 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
expect(getPlansResponse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should share premium plan API response between multiple subscriptions when feature flag is enabled", () => {
|
||||
// Create a new mock to avoid conflicts with beforeEach setup
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
|
||||
|
||||
// Create a new service instance with the feature flag enabled
|
||||
const newService = new DefaultSubscriptionPricingService(
|
||||
newBillingApiService,
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
it("should share premium plan API response between multiple subscriptions", () => {
|
||||
const getPremiumPlanSpy = jest.spyOn(billingApiService, "getPremiumPlan");
|
||||
|
||||
// Subscribe to the premium pricing tier multiple times
|
||||
newService.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||
newService.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||
service.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||
service.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||
|
||||
// API should only be called once due to shareReplay on premiumPlanResponse$
|
||||
expect(getPremiumPlanSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should use hardcoded premium price when feature flag is disabled", (done) => {
|
||||
// Create a new mock to test from scratch
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue({
|
||||
seat: { price: 999 }, // Different price to verify hardcoded value is used
|
||||
storage: { price: 999 },
|
||||
} as PremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
// Create a new service instance with the feature flag disabled
|
||||
const newService = new DefaultSubscriptionPricingService(
|
||||
newBillingApiService,
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe with feature flag disabled
|
||||
newService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
|
||||
const premiumTier = tiers.find(
|
||||
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
|
||||
);
|
||||
|
||||
// Should use hardcoded value of 10, not the API response value of 999
|
||||
expect(premiumTier!.passwordManager.annualPrice).toBe(10);
|
||||
expect(premiumTier!.passwordManager.annualPricePerAdditionalStorageGB).toBe(4);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Self-hosted environment behavior", () => {
|
||||
@@ -1035,7 +985,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
|
||||
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
@@ -1061,7 +1011,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const selfHostedConfigService = mock<ConfigService>();
|
||||
const selfHostedEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
|
||||
@@ -33,16 +33,6 @@ import {
|
||||
} from "../types/subscription-pricing-tier";
|
||||
|
||||
export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction {
|
||||
/**
|
||||
* Fallback premium pricing used when the feature flag is disabled.
|
||||
* These values represent the legacy pricing model and will not reflect
|
||||
* server-side price changes. They are retained for backward compatibility
|
||||
* during the feature flag rollout period.
|
||||
*/
|
||||
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
|
||||
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
|
||||
private static readonly FALLBACK_PREMIUM_PROVIDED_STORAGE_GB = 1;
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
@@ -123,45 +113,27 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
||||
.pipe(
|
||||
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
|
||||
switchMap((fetchPremiumFromPricingService) =>
|
||||
fetchPremiumFromPricingService
|
||||
? this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat?.price,
|
||||
storage: premiumPlan.storage?.price,
|
||||
provided: premiumPlan.storage?.provided,
|
||||
})),
|
||||
)
|
||||
: of({
|
||||
seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
|
||||
storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
|
||||
provided: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_PROVIDED_STORAGE_GB,
|
||||
}),
|
||||
),
|
||||
map((premiumPrices) => ({
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: this.i18nService.t("premium"),
|
||||
description: this.i18nService.t("advancedOnlineSecurity"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: premiumPrices.seat,
|
||||
annualPricePerAdditionalStorageGB: premiumPrices.storage,
|
||||
providedStorageGB: premiumPrices.provided,
|
||||
features: [
|
||||
this.featureTranslations.builtInAuthenticator(),
|
||||
this.featureTranslations.secureFileStorage(),
|
||||
this.featureTranslations.emergencyAccess(),
|
||||
this.featureTranslations.breachMonitoring(),
|
||||
this.featureTranslations.andMoreFeatures(),
|
||||
],
|
||||
},
|
||||
})),
|
||||
);
|
||||
private premium$: Observable<PersonalSubscriptionPricingTier> = this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: this.i18nService.t("premium"),
|
||||
description: this.i18nService.t("advancedOnlineSecurity"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: premiumPlan.seat?.price,
|
||||
annualPricePerAdditionalStorageGB: premiumPlan.storage?.price,
|
||||
providedStorageGB: premiumPlan.storage?.provided,
|
||||
features: [
|
||||
this.featureTranslations.builtInAuthenticator(),
|
||||
this.featureTranslations.secureFileStorage(),
|
||||
this.featureTranslations.emergencyAccess(),
|
||||
this.featureTranslations.breachMonitoring(),
|
||||
this.featureTranslations.andMoreFeatures(),
|
||||
],
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
|
||||
@@ -5,4 +5,5 @@ export enum EventSystemUser {
|
||||
SCIM = 1,
|
||||
DomainVerification = 2,
|
||||
PublicApi = 3,
|
||||
BitwardenPortal = 5,
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export enum EventType {
|
||||
OrganizationUser_RejectedAuthRequest = 1514,
|
||||
OrganizationUser_Deleted = 1515,
|
||||
OrganizationUser_Left = 1516,
|
||||
OrganizationUser_AutomaticallyConfirmed = 1517,
|
||||
|
||||
Organization_Updated = 1600,
|
||||
Organization_PurgedVault = 1601,
|
||||
@@ -81,6 +82,10 @@ export enum EventType {
|
||||
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617,
|
||||
Organization_ItemOrganization_Accepted = 1618,
|
||||
Organization_ItemOrganization_Declined = 1619,
|
||||
Organization_AutoConfirmEnabled_Admin = 1620,
|
||||
Organization_AutoConfirmDisabled_Admin = 1621,
|
||||
Organization_AutoConfirmEnabled_Portal = 1622,
|
||||
Organization_AutoConfirmDisabled_Portal = 1623,
|
||||
|
||||
Policy_Updated = 1700,
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ export enum FeatureFlag {
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password",
|
||||
SafariAccountSwitching = "pm-5594-safari-account-switching",
|
||||
PM30811_ChangeEmailNewAuthenticationApis = "pm-30811-change-email-new-authentication-apis",
|
||||
PM31088_MasterPasswordServiceEmitSalt = "pm-31088-master-password-service-emit-salt",
|
||||
|
||||
/* Autofill */
|
||||
UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic",
|
||||
@@ -30,8 +32,6 @@ export enum FeatureFlag {
|
||||
/* Billing */
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
|
||||
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
||||
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
|
||||
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
|
||||
PM26462_Milestone_3 = "pm-26462-milestone-3",
|
||||
PM23341_Milestone_2 = "pm-23341-milestone-2",
|
||||
@@ -42,6 +42,7 @@ export enum FeatureFlag {
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
|
||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||
SdkKeyRotation = "pm-30144-sdk-key-rotation",
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
PasskeyUnlock = "pm-2035-passkey-unlock",
|
||||
@@ -53,7 +54,6 @@ export enum FeatureFlag {
|
||||
|
||||
/* Tools */
|
||||
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
|
||||
ChromiumImporterWithABE = "pm-25855-chromium-importer-abe",
|
||||
SendUIRefresh = "pm-28175-send-ui-refresh",
|
||||
SendEmailOTP = "pm-19051-send-email-verification",
|
||||
|
||||
@@ -71,6 +71,10 @@ export enum FeatureFlag {
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
|
||||
PM30521_AutofillButtonViewLoginScreen = "pm-30521-autofill-button-view-login-screen",
|
||||
PM29438_WelcomeDialogWithExtensionPrompt = "pm-29438-welcome-dialog-with-extension-prompt",
|
||||
PM29438_DialogWithExtensionPromptAccountAge = "pm-29438-dialog-with-extension-prompt-account-age",
|
||||
PM29437_WelcomeDialog = "pm-29437-welcome-dialog-no-ext-prompt",
|
||||
PM31039ItemActionInExtension = "pm-31039-item-action-in-extension",
|
||||
|
||||
/* Platform */
|
||||
@@ -83,12 +87,10 @@ export enum FeatureFlag {
|
||||
/* Desktop */
|
||||
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
|
||||
DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2",
|
||||
DesktopUiMigrationMilestone3 = "desktop-ui-migration-milestone-3",
|
||||
|
||||
/* UIF */
|
||||
RouterFocusManagement = "router-focus-management",
|
||||
|
||||
/* Secrets Manager */
|
||||
SM1719_RemoveSecretsManagerAds = "sm-1719-remove-secrets-manager-ads",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -120,7 +122,6 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Tools */
|
||||
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
|
||||
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
|
||||
[FeatureFlag.SendUIRefresh]: FALSE,
|
||||
[FeatureFlag.SendEmailOTP]: FALSE,
|
||||
|
||||
@@ -138,17 +139,21 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
[FeatureFlag.PM30521_AutofillButtonViewLoginScreen]: FALSE,
|
||||
[FeatureFlag.PM29438_WelcomeDialogWithExtensionPrompt]: FALSE,
|
||||
[FeatureFlag.PM29438_DialogWithExtensionPromptAccountAge]: 5,
|
||||
[FeatureFlag.PM29437_WelcomeDialog]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
[FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE,
|
||||
[FeatureFlag.SafariAccountSwitching]: FALSE,
|
||||
[FeatureFlag.PM30811_ChangeEmailNewAuthenticationApis]: FALSE,
|
||||
[FeatureFlag.PM31088_MasterPasswordServiceEmitSalt]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
|
||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
[FeatureFlag.PM23341_Milestone_2]: FALSE,
|
||||
@@ -159,6 +164,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.SdkKeyRotation]: FALSE,
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.PasskeyUnlock]: FALSE,
|
||||
@@ -178,12 +184,10 @@ export const DefaultFeatureFlagValue = {
|
||||
/* Desktop */
|
||||
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
|
||||
[FeatureFlag.DesktopUiMigrationMilestone2]: FALSE,
|
||||
[FeatureFlag.DesktopUiMigrationMilestone3]: FALSE,
|
||||
|
||||
/* UIF */
|
||||
[FeatureFlag.RouterFocusManagement]: FALSE,
|
||||
|
||||
/* Secrets Manager */
|
||||
[FeatureFlag.SM1719_RemoveSecretsManagerAds]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum IntegrationType {
|
||||
Integration = "integration",
|
||||
SDK = "sdk",
|
||||
SSO = "sso",
|
||||
SCIM = "scim",
|
||||
BWDC = "bwdc",
|
||||
EVENT = "event",
|
||||
DEVICE = "device",
|
||||
}
|
||||
export const IntegrationType = Object.freeze({
|
||||
Integration: "integration",
|
||||
SDK: "sdk",
|
||||
SSO: "sso",
|
||||
SCIM: "scim",
|
||||
BWDC: "bwdc",
|
||||
EVENT: "event",
|
||||
DEVICE: "device",
|
||||
} as const);
|
||||
|
||||
export type IntegrationType = (typeof IntegrationType)[keyof typeof IntegrationType];
|
||||
|
||||
@@ -35,4 +35,5 @@ export enum NotificationType {
|
||||
ProviderBankAccountVerified = 24,
|
||||
|
||||
SyncPolicy = 25,
|
||||
AutoConfirmMember = 26,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user