mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 05:30:01 +00:00
Merge remote-tracking branch 'origin/main' into playwright
This commit is contained in:
@@ -6,19 +6,26 @@ import { ReplaySubject, combineLatest, map, Observable } from "rxjs";
|
||||
import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
|
||||
import { UserId } from "../src/types/guid";
|
||||
|
||||
/**
|
||||
* Creates a mock AccountInfo object with sensible defaults that can be overridden.
|
||||
* Use this when you need just an AccountInfo object in tests.
|
||||
*/
|
||||
export function mockAccountInfoWith(info: Partial<AccountInfo> = {}): AccountInfo {
|
||||
return {
|
||||
name: "name",
|
||||
email: "email",
|
||||
emailVerified: true,
|
||||
creationDate: new Date("2024-01-01T00:00:00.000Z"),
|
||||
...info,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockAccountServiceWith(
|
||||
userId: UserId,
|
||||
info: Partial<AccountInfo> = {},
|
||||
activity: Record<UserId, Date> = {},
|
||||
): FakeAccountService {
|
||||
const fullInfo: AccountInfo = {
|
||||
...info,
|
||||
...{
|
||||
name: "name",
|
||||
email: "email",
|
||||
emailVerified: true,
|
||||
},
|
||||
};
|
||||
const fullInfo = mockAccountInfoWith(info);
|
||||
|
||||
const fullActivity = { [userId]: new Date(), ...activity };
|
||||
|
||||
@@ -37,6 +44,8 @@ export class FakeAccountService implements AccountService {
|
||||
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
accountVerifyDevicesSubject = new ReplaySubject<boolean>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
showHeaderSubject = new ReplaySubject<boolean>(1);
|
||||
private _activeUserId: UserId;
|
||||
get activeUserId() {
|
||||
return this._activeUserId;
|
||||
@@ -55,6 +64,7 @@ export class FakeAccountService implements AccountService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
showHeader$ = this.showHeaderSubject.asObservable();
|
||||
get nextUpAccount$(): Observable<Account> {
|
||||
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
|
||||
map(([accounts, activeAccount, sortedUserIds]) => {
|
||||
@@ -101,6 +111,10 @@ export class FakeAccountService implements AccountService {
|
||||
await this.mock.setAccountEmailVerified(userId, emailVerified);
|
||||
}
|
||||
|
||||
async setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void> {
|
||||
await this.mock.setAccountCreationDate(userId, creationDate);
|
||||
}
|
||||
|
||||
async switchAccount(userId: UserId): Promise<void> {
|
||||
const next =
|
||||
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
|
||||
@@ -114,10 +128,15 @@ export class FakeAccountService implements AccountService {
|
||||
this.accountsSubject.next(updated);
|
||||
await this.mock.clean(userId);
|
||||
}
|
||||
|
||||
async setShowHeader(value: boolean): Promise<void> {
|
||||
this.showHeaderSubject.next(value);
|
||||
}
|
||||
}
|
||||
|
||||
const loggedOutInfo: AccountInfo = {
|
||||
name: undefined,
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
creationDate: undefined,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// @ts-strict-ignore
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { EncryptionType } from "../src/platform/enums";
|
||||
import { Utils } from "../src/platform/misc/utils";
|
||||
@@ -29,6 +32,7 @@ export function BuildTestObject<T, K extends keyof T = keyof T>(
|
||||
|
||||
export function mockEnc(s: string): MockProxy<EncString> {
|
||||
const mocked = mock<EncString>();
|
||||
mocked.decryptedValue = s;
|
||||
mocked.decrypt.mockResolvedValue(s);
|
||||
|
||||
return mocked;
|
||||
@@ -77,4 +81,14 @@ export const mockFromSdk = (stub: any) => {
|
||||
return `${stub}_fromSdk`;
|
||||
};
|
||||
|
||||
export const mockContainerService = () => {
|
||||
const keyService = mock<KeyService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
encryptService.decryptString.mockImplementation(async (encStr, _key) => {
|
||||
return encStr.decryptedValue;
|
||||
});
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
return (window as any).bitwardenContainerService;
|
||||
};
|
||||
|
||||
export { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils";
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// 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 { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionAccessDetailsResponse,
|
||||
CollectionDetailsResponse,
|
||||
CollectionResponse,
|
||||
CreateCollectionRequest,
|
||||
UpdateCollectionRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
import { OrganizationConnectionType } from "../admin-console/enums";
|
||||
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request";
|
||||
@@ -50,6 +49,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ
|
||||
import { ApiKeyResponse } from "../auth/models/response/api-key.response";
|
||||
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
|
||||
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
|
||||
import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response";
|
||||
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
|
||||
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
|
||||
@@ -140,7 +140,10 @@ export abstract class ApiService {
|
||||
| UserApiTokenRequest
|
||||
| WebAuthnLoginTokenRequest,
|
||||
): Promise<
|
||||
IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityDeviceVerificationResponse
|
||||
| IdentitySsoRequiredResponse
|
||||
>;
|
||||
abstract refreshIdentityToken(userId?: UserId): Promise<any>;
|
||||
|
||||
@@ -442,6 +445,13 @@ export abstract class ApiService {
|
||||
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
|
||||
abstract postSetupPayment(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Retrieves the bearer access token for the user.
|
||||
* If the access token is expired or within 5 minutes of expiration, attempts to refresh the token
|
||||
* and persists the refresh token to state before returning it.
|
||||
* @param userId The user for whom we're retrieving the access token
|
||||
* @returns The access token, or an Error if no access token exists.
|
||||
*/
|
||||
abstract getActiveBearerToken(userId: UserId): Promise<string>;
|
||||
abstract fetch(request: Request): Promise<Response>;
|
||||
abstract nativeFetch(request: Request): Promise<Response>;
|
||||
|
||||
@@ -41,6 +41,18 @@ export function canAccessBillingTab(org: Organization): boolean {
|
||||
return org.isOwner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Intelligence is only available to:
|
||||
* - Enterprise organizations
|
||||
* - Users in those organizations with report access
|
||||
*
|
||||
* @param org The organization to verify access
|
||||
* @returns If true can access the Access Intelligence feature
|
||||
*/
|
||||
export function canAccessAccessIntelligence(org: Organization): boolean {
|
||||
return org.canUseAccessIntelligence && org.canAccessReports;
|
||||
}
|
||||
|
||||
export function canAccessOrgAdmin(org: Organization): boolean {
|
||||
// Admin console can only be accessed by Owners for disabled organizations
|
||||
if (!org.enabled && !org.isOwner) {
|
||||
@@ -63,8 +75,8 @@ export function canAccessEmergencyAccess(
|
||||
) {
|
||||
return combineLatest([
|
||||
configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
|
||||
policyService.policiesByType$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled)));
|
||||
policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(map(([enabled, policyAppliesToUser]) => !(enabled && policyAppliesToUser)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,4 +101,9 @@ export abstract class InternalPolicyService extends PolicyService {
|
||||
* Replace a policy in the local sync data. This does not update any policies on the server.
|
||||
*/
|
||||
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
|
||||
/**
|
||||
* Wrapper around upsert that uses account service to sync policies for the logged in user. This comes from
|
||||
* the server push notification to update local policies.
|
||||
*/
|
||||
abstract syncPolicy: (payload: PolicyData) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
interface SelectionResponseLike {
|
||||
id: string;
|
||||
readOnly: boolean;
|
||||
hidePasswords: boolean;
|
||||
manage: boolean;
|
||||
}
|
||||
|
||||
export class CollectionAccessSelectionView extends View {
|
||||
readonly id: string;
|
||||
readonly readOnly: boolean;
|
||||
readonly hidePasswords: boolean;
|
||||
readonly manage: boolean;
|
||||
|
||||
constructor(response?: SelectionResponseLike) {
|
||||
super();
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.id = response.id;
|
||||
this.readOnly = response.readOnly;
|
||||
this.hidePasswords = response.hidePasswords;
|
||||
this.manage = response.manage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { CollectionAccessDetailsResponse, CollectionResponse } from "./collection.response";
|
||||
import { CollectionView } from "./collection.view";
|
||||
|
||||
// TODO: this is used to represent the pseudo "Unassigned" collection as well as
|
||||
// the user's personal vault (as a pseudo organization). This should be separated out into different values.
|
||||
export const Unassigned = "unassigned";
|
||||
export type Unassigned = typeof Unassigned;
|
||||
|
||||
export class CollectionAdminView extends CollectionView {
|
||||
groups: CollectionAccessSelectionView[] = [];
|
||||
users: CollectionAccessSelectionView[] = [];
|
||||
|
||||
/**
|
||||
* Flag indicating the collection has no active user or group assigned to it with CanManage permissions
|
||||
* In this case, the collection can be managed by admins/owners or custom users with appropriate permissions
|
||||
*/
|
||||
unmanaged: boolean = false;
|
||||
|
||||
/**
|
||||
* Flag indicating the user has been explicitly assigned to this Collection
|
||||
*/
|
||||
assigned: boolean = false;
|
||||
|
||||
/**
|
||||
* Returns true if the user can edit a collection (including user and group access) from the Admin Console.
|
||||
*/
|
||||
override canEdit(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
org?.canEditAnyCollection ||
|
||||
(this.unmanaged && org?.canEditUnmanagedCollections) ||
|
||||
super.canEdit(org)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user can delete a collection from the Admin Console.
|
||||
*/
|
||||
override canDelete(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return org?.canDeleteAnyCollection || super.canDelete(org);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user can modify user access to this collection
|
||||
*/
|
||||
canEditUserAccess(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user can modify group access to this collection
|
||||
*/
|
||||
canEditGroupAccess(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) ||
|
||||
this.canEdit(org)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user can view collection info and access in a read-only state from the Admin Console
|
||||
*/
|
||||
override canViewCollectionInfo(org: Organization | undefined): boolean {
|
||||
if (this.isUnassignedCollection || this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
const isAdmin = org?.isAdmin ?? false;
|
||||
const permissions = org?.permissions.editAnyCollection ?? false;
|
||||
|
||||
return this.manage || isAdmin || permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this collection represents the pseudo "Unassigned" collection
|
||||
* This is different from the "unmanaged" flag, which indicates that no users or groups have access to the collection
|
||||
*/
|
||||
get isUnassignedCollection() {
|
||||
return this.id === Unassigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
|
||||
* that were DefaultUserCollections but where the relevant user has been offboarded.
|
||||
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
|
||||
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
|
||||
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
|
||||
* Do not edit or remove this unless you understand why.
|
||||
*/
|
||||
override canEditName(org: Organization): boolean {
|
||||
return (this.canEdit(org) && !this.defaultUserCollectionEmail) || super.canEditName(org);
|
||||
}
|
||||
static async fromCollectionAccessDetails(
|
||||
collection: CollectionAccessDetailsResponse,
|
||||
encryptService: EncryptService,
|
||||
orgKey: OrgKey,
|
||||
): Promise<CollectionAdminView> {
|
||||
const view = new CollectionAdminView({ ...collection });
|
||||
try {
|
||||
view.name = await encryptService.decryptString(new EncString(view.name), orgKey);
|
||||
} catch (e) {
|
||||
view.name = "[error: cannot decrypt]";
|
||||
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"[CollectionAdminView/fromCollectionAccessDetails] Error decrypting collection name",
|
||||
e,
|
||||
);
|
||||
}
|
||||
view.assigned = collection.assigned;
|
||||
view.readOnly = collection.readOnly;
|
||||
view.hidePasswords = collection.hidePasswords;
|
||||
view.manage = collection.manage;
|
||||
view.unmanaged = collection.unmanaged;
|
||||
view.type = collection.type;
|
||||
view.externalId = collection.externalId;
|
||||
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
|
||||
|
||||
view.groups = collection.groups
|
||||
? collection.groups.map((g) => new CollectionAccessSelectionView(g))
|
||||
: [];
|
||||
|
||||
view.users = collection.users
|
||||
? collection.users.map((g) => new CollectionAccessSelectionView(g))
|
||||
: [];
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
static async fromCollectionResponse(
|
||||
collection: CollectionResponse,
|
||||
encryptService: EncryptService,
|
||||
orgKey: OrgKey,
|
||||
): Promise<CollectionAdminView> {
|
||||
let collectionName: string;
|
||||
try {
|
||||
collectionName = await encryptService.decryptString(new EncString(collection.name), orgKey);
|
||||
} catch (e) {
|
||||
// Note: This should be updated by the owning team with appropriate, domain specific behavior
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"[CollectionAdminView/fromCollectionResponse] Failed to decrypt the collection name",
|
||||
e,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const collectionAdminView = new CollectionAdminView({
|
||||
id: collection.id,
|
||||
name: collectionName,
|
||||
organizationId: collection.organizationId,
|
||||
});
|
||||
|
||||
collectionAdminView.externalId = collection.externalId;
|
||||
|
||||
return collectionAdminView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
CollectionDetailsResponse,
|
||||
CollectionType,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class CollectionData {
|
||||
id: CollectionId;
|
||||
organizationId: OrganizationId;
|
||||
name: string;
|
||||
defaultUserCollectionEmail: string | undefined;
|
||||
externalId: string | undefined;
|
||||
readOnly: boolean = false;
|
||||
manage: boolean = false;
|
||||
hidePasswords: boolean = false;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
|
||||
constructor(response: CollectionDetailsResponse) {
|
||||
this.id = response.id;
|
||||
this.organizationId = response.organizationId;
|
||||
this.name = response.name;
|
||||
this.externalId = response.externalId;
|
||||
this.readOnly = response.readOnly;
|
||||
this.manage = response.manage;
|
||||
this.hidePasswords = response.hidePasswords;
|
||||
this.type = response.type;
|
||||
this.defaultUserCollectionEmail = response.defaultUserCollectionEmail;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
return Object.assign(new CollectionData(new CollectionDetailsResponse({})), obj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
CollectionType,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/models/response/selection-read-only.response";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class CollectionResponse extends BaseResponse {
|
||||
id: CollectionId;
|
||||
organizationId: OrganizationId;
|
||||
name: string;
|
||||
defaultUserCollectionEmail: string | undefined;
|
||||
externalId: string | undefined;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.organizationId = this.getResponseProperty("OrganizationId");
|
||||
this.name = this.getResponseProperty("Name");
|
||||
this.externalId = this.getResponseProperty("ExternalId");
|
||||
this.defaultUserCollectionEmail = this.getResponseProperty("DefaultUserCollectionEmail");
|
||||
this.type = this.getResponseProperty("Type") ?? CollectionTypes.SharedCollection;
|
||||
}
|
||||
}
|
||||
|
||||
export class CollectionDetailsResponse extends CollectionResponse {
|
||||
readOnly: boolean;
|
||||
manage: boolean;
|
||||
hidePasswords: boolean;
|
||||
|
||||
/**
|
||||
* Flag indicating the user has been explicitly assigned to this Collection
|
||||
*/
|
||||
assigned: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.readOnly = this.getResponseProperty("ReadOnly") || false;
|
||||
this.manage = this.getResponseProperty("Manage") || false;
|
||||
this.hidePasswords = this.getResponseProperty("HidePasswords") || false;
|
||||
|
||||
// Temporary until the API is updated to return this property in AC-2084
|
||||
// For now, we can assume that if the object is 'collectionDetails' then the user is assigned
|
||||
this.assigned = this.getResponseProperty("object") == "collectionDetails";
|
||||
}
|
||||
}
|
||||
|
||||
export class CollectionAccessDetailsResponse extends CollectionDetailsResponse {
|
||||
groups: SelectionReadOnlyResponse[] = [];
|
||||
users: SelectionReadOnlyResponse[] = [];
|
||||
unmanaged: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.assigned = this.getResponseProperty("Assigned") || false;
|
||||
this.unmanaged = this.getResponseProperty("Unmanaged") || false;
|
||||
|
||||
const groups = this.getResponseProperty("Groups");
|
||||
if (groups != null) {
|
||||
this.groups = groups.map((g: any) => new SelectionReadOnlyResponse(g));
|
||||
}
|
||||
|
||||
const users = this.getResponseProperty("Users");
|
||||
if (users != null) {
|
||||
this.users = users.map((g: any) => new SelectionReadOnlyResponse(g));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { CollectionData } from "./collection.data";
|
||||
|
||||
export const CollectionTypes = {
|
||||
SharedCollection: 0,
|
||||
DefaultUserCollection: 1,
|
||||
} as const;
|
||||
|
||||
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
|
||||
|
||||
export class Collection extends Domain {
|
||||
id: CollectionId;
|
||||
organizationId: OrganizationId;
|
||||
name: EncString;
|
||||
externalId: string | undefined;
|
||||
readOnly: boolean = false;
|
||||
hidePasswords: boolean = false;
|
||||
manage: boolean = false;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
defaultUserCollectionEmail: string | undefined;
|
||||
|
||||
constructor(c: { id: CollectionId; name: EncString; organizationId: OrganizationId }) {
|
||||
super();
|
||||
this.id = c.id;
|
||||
this.name = c.name;
|
||||
this.organizationId = c.organizationId;
|
||||
}
|
||||
|
||||
static fromCollectionData(obj: CollectionData): Collection {
|
||||
if (obj == null || obj.name == null || obj.organizationId == null) {
|
||||
throw new Error("CollectionData must contain name and organizationId.");
|
||||
}
|
||||
|
||||
const collection = new Collection({
|
||||
...obj,
|
||||
name: new EncString(obj.name),
|
||||
});
|
||||
|
||||
collection.externalId = obj.externalId;
|
||||
collection.readOnly = obj.readOnly;
|
||||
collection.hidePasswords = obj.hidePasswords;
|
||||
collection.manage = obj.manage;
|
||||
collection.type = obj.type;
|
||||
collection.defaultUserCollectionEmail = obj.defaultUserCollectionEmail;
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
static async fromCollectionView(
|
||||
view: CollectionView,
|
||||
encryptService: EncryptService,
|
||||
orgKey: OrgKey,
|
||||
): Promise<Collection> {
|
||||
const collection = new Collection({
|
||||
name: await encryptService.encryptString(view.name, orgKey),
|
||||
id: view.id,
|
||||
organizationId: view.organizationId,
|
||||
});
|
||||
|
||||
collection.externalId = view.externalId;
|
||||
collection.readOnly = view.readOnly;
|
||||
collection.hidePasswords = view.hidePasswords;
|
||||
collection.manage = view.manage;
|
||||
collection.type = view.type;
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
decrypt(orgKey: OrgKey, encryptService: EncryptService): Promise<CollectionView> {
|
||||
return CollectionView.fromCollection(this, encryptService, orgKey);
|
||||
}
|
||||
|
||||
// @TODO: This would be better off in Collection.Utils. Move this there when
|
||||
// refactoring to a shared lib.
|
||||
static isCollectionId(id: any): id is CollectionId {
|
||||
return typeof id === "string" && id != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
|
||||
import { Collection, CollectionType, CollectionTypes } from "./collection";
|
||||
import { CollectionAccessDetailsResponse } from "./collection.response";
|
||||
|
||||
export const NestingDelimiter = "/";
|
||||
|
||||
export class CollectionView implements View, ITreeNodeObject {
|
||||
id: CollectionId;
|
||||
organizationId: OrganizationId;
|
||||
externalId: string | undefined;
|
||||
// readOnly applies to the items within a collection
|
||||
readOnly: boolean = false;
|
||||
hidePasswords: boolean = false;
|
||||
manage: boolean = false;
|
||||
assigned: boolean = false;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
defaultUserCollectionEmail: string | undefined;
|
||||
|
||||
private _name: string;
|
||||
|
||||
constructor(c: { id: CollectionId; organizationId: OrganizationId; name: string }) {
|
||||
this.id = c.id;
|
||||
this.organizationId = c.organizationId;
|
||||
this._name = c.name;
|
||||
}
|
||||
|
||||
set name(name: string) {
|
||||
this._name = name;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.defaultUserCollectionEmail ?? this._name;
|
||||
}
|
||||
|
||||
canEditItems(org: Organization): boolean {
|
||||
if (org != null && org.id !== this.organizationId) {
|
||||
throw new Error(
|
||||
"Id of the organization provided does not match the org id of the collection.",
|
||||
);
|
||||
}
|
||||
|
||||
return org?.canEditAllCiphers || this.manage || (this.assigned && !this.readOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user can edit a collection (including user and group access) from the individual vault.
|
||||
* Does not include admin permissions - see {@link CollectionAdminView.canEdit}.
|
||||
*/
|
||||
canEdit(org: Organization | undefined): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (org != null && org.id !== this.organizationId) {
|
||||
throw new Error(
|
||||
"Id of the organization provided does not match the org id of the collection.",
|
||||
);
|
||||
}
|
||||
|
||||
return this.manage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user can delete a collection from the individual vault.
|
||||
* Does not include admin permissions - see {@link CollectionAdminView.canDelete}.
|
||||
*/
|
||||
canDelete(org: Organization | undefined): boolean {
|
||||
if (org != null && org.id !== this.organizationId) {
|
||||
throw new Error(
|
||||
"Id of the organization provided does not match the org id of the collection.",
|
||||
);
|
||||
}
|
||||
|
||||
const canDeleteManagedCollections = !org?.limitCollectionDeletion || org.isAdmin;
|
||||
|
||||
// Only use individual permissions, not admin permissions
|
||||
return canDeleteManagedCollections && this.manage && !this.isDefaultCollection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user can view collection info and access in a read-only state from the individual vault
|
||||
*/
|
||||
canViewCollectionInfo(org: Organization | undefined): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
|
||||
* that were DefaultUserCollections but where the relevant user has been offboarded.
|
||||
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
|
||||
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
|
||||
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
|
||||
* Do not edit or remove this unless you understand why.
|
||||
*/
|
||||
canEditName(org: Organization): boolean {
|
||||
return this.canEdit(org) && !this.defaultUserCollectionEmail;
|
||||
}
|
||||
|
||||
get isDefaultCollection() {
|
||||
return this.type == CollectionTypes.DefaultUserCollection;
|
||||
}
|
||||
|
||||
// FIXME: we should not use a CollectionView object for the vault filter header because it is not a real
|
||||
// CollectionView and this violates ts-strict rules.
|
||||
static vaultFilterHead(): CollectionView {
|
||||
return new CollectionView({
|
||||
id: "" as CollectionId,
|
||||
organizationId: "" as OrganizationId,
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
|
||||
static async fromCollection(
|
||||
collection: Collection,
|
||||
encryptService: EncryptService,
|
||||
key: OrgKey,
|
||||
): Promise<CollectionView> {
|
||||
const view = new CollectionView({ ...collection, name: "" });
|
||||
|
||||
try {
|
||||
view.name = await encryptService.decryptString(collection.name, key);
|
||||
} catch (e) {
|
||||
view.name = "[error: cannot decrypt]";
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[CollectionView] Error decrypting collection name", e);
|
||||
}
|
||||
|
||||
view.assigned = true;
|
||||
view.externalId = collection.externalId;
|
||||
view.readOnly = collection.readOnly;
|
||||
view.hidePasswords = collection.hidePasswords;
|
||||
view.manage = collection.manage;
|
||||
view.type = collection.type;
|
||||
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
|
||||
return view;
|
||||
}
|
||||
|
||||
static async fromCollectionAccessDetails(
|
||||
collection: CollectionAccessDetailsResponse,
|
||||
encryptService: EncryptService,
|
||||
orgKey: OrgKey,
|
||||
): Promise<CollectionView> {
|
||||
const view = new CollectionView({ ...collection });
|
||||
|
||||
try {
|
||||
view.name = await encryptService.decryptString(new EncString(collection.name), orgKey);
|
||||
} catch (e) {
|
||||
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[CollectionView] Error decrypting collection name", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
view.externalId = collection.externalId;
|
||||
view.type = collection.type;
|
||||
view.assigned = collection.assigned;
|
||||
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
|
||||
return view;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<CollectionView>) {
|
||||
return Object.assign(new CollectionView({ ...obj }), obj);
|
||||
}
|
||||
|
||||
encrypt(orgKey: OrgKey, encryptService: EncryptService): Promise<Collection> {
|
||||
return Collection.fromCollectionView(this, encryptService, orgKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./collection-access-selection.view";
|
||||
export * from "./collection-admin.view";
|
||||
export * from "./collection.view";
|
||||
export * from "./collection.response";
|
||||
export * from "./collection";
|
||||
export * from "./collection.data";
|
||||
@@ -64,6 +64,8 @@ describe("ORGANIZATIONS state", () => {
|
||||
isAdminInitiated: false,
|
||||
ssoEnabled: false,
|
||||
ssoMemberDecryptionType: undefined,
|
||||
useDisableSMAdsForUsers: false,
|
||||
usePhishingBlocker: false,
|
||||
},
|
||||
};
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
|
||||
|
||||
@@ -64,9 +64,11 @@ export class OrganizationData {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
constructor(
|
||||
response?: ProfileOrganizationResponse,
|
||||
@@ -132,9 +134,11 @@ export class OrganizationData {
|
||||
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
|
||||
this.useAccessIntelligence = response.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
|
||||
this.useDisableSMAdsForUsers = response.useDisableSMAdsForUsers ?? false;
|
||||
this.isAdminInitiated = response.isAdminInitiated;
|
||||
this.ssoEnabled = response.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = response.ssoMemberDecryptionType;
|
||||
this.usePhishingBlocker = response.usePhishingBlocker;
|
||||
|
||||
this.isMember = options.isMember;
|
||||
this.isProviderUser = options.isProviderUser;
|
||||
|
||||
@@ -11,6 +11,7 @@ export class PolicyData {
|
||||
type: PolicyType;
|
||||
data: Record<string, string | number | boolean>;
|
||||
enabled: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response?: PolicyResponse) {
|
||||
if (response == null) {
|
||||
@@ -22,6 +23,7 @@ export class PolicyData {
|
||||
this.type = response.type;
|
||||
this.data = response.data;
|
||||
this.enabled = response.enabled;
|
||||
this.revisionDate = response.revisionDate;
|
||||
}
|
||||
|
||||
static fromPolicy(policy: Policy): PolicyData {
|
||||
|
||||
@@ -95,9 +95,11 @@ export class Organization {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
constructor(obj?: OrganizationData) {
|
||||
if (obj == null) {
|
||||
@@ -159,9 +161,11 @@ export class Organization {
|
||||
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
|
||||
this.useAccessIntelligence = obj.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
|
||||
this.useDisableSMAdsForUsers = obj.useDisableSMAdsForUsers ?? false;
|
||||
this.isAdminInitiated = obj.isAdminInitiated;
|
||||
this.ssoEnabled = obj.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType;
|
||||
this.usePhishingBlocker = obj.usePhishingBlocker;
|
||||
}
|
||||
|
||||
get canAccess() {
|
||||
@@ -379,6 +383,13 @@ export class Organization {
|
||||
return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not call this function to perform business logic, use the function in @link AutomaticUserConfirmationService instead.
|
||||
**/
|
||||
get canManageAutoConfirm() {
|
||||
return this.isMember && this.canManageUsers && this.useAutomaticUserConfirmation;
|
||||
}
|
||||
|
||||
static fromJSON(json: Jsonify<Organization>) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
@@ -400,4 +411,8 @@ export class Organization {
|
||||
this.permissions.accessEventLogs)
|
||||
);
|
||||
}
|
||||
|
||||
get canUseAccessIntelligence() {
|
||||
return this.productTierType === ProductTierType.Enterprise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export class Policy extends Domain {
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
revisionDate: Date;
|
||||
|
||||
constructor(obj?: PolicyData) {
|
||||
super();
|
||||
if (obj == null) {
|
||||
@@ -30,6 +32,7 @@ export class Policy extends Domain {
|
||||
this.type = obj.type;
|
||||
this.data = obj.data;
|
||||
this.enabled = obj.enabled;
|
||||
this.revisionDate = new Date(obj.revisionDate);
|
||||
}
|
||||
|
||||
static fromResponse(response: PolicyResponse): Policy {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
export class ProviderUserConfirmRequest {
|
||||
key: string;
|
||||
protected key: string;
|
||||
|
||||
constructor(key: string) {
|
||||
this.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
// 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 { CollectionResponse } from "@bitwarden/admin-console/common";
|
||||
import { CollectionResponse } from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { CipherResponse } from "../../../vault/models/response/cipher.response";
|
||||
|
||||
@@ -38,7 +38,9 @@ export class OrganizationResponse extends BaseResponse {
|
||||
limitCollectionDeletion: boolean;
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -80,7 +82,9 @@ export class OrganizationResponse extends BaseResponse {
|
||||
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
|
||||
"AllowAdminAccessToAllCollectionItems",
|
||||
);
|
||||
this.useDisableSMAdsForUsers = this.getResponseProperty("UseDisableSMAdsForUsers") ?? false;
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
|
||||
data: any;
|
||||
enabled: boolean;
|
||||
canToggleState: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
|
||||
this.data = this.getResponseProperty("Data");
|
||||
this.enabled = this.getResponseProperty("Enabled");
|
||||
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +59,11 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -132,8 +134,10 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
|
||||
this.useDisableSMAdsForUsers = this.getResponseProperty("UseDisableSMAdsForUsers") ?? false;
|
||||
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
|
||||
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
|
||||
this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType");
|
||||
this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ function buildKeyDefinition<T>(key: string): UserKeyDefinition<T> {
|
||||
|
||||
export const AUTO_CONFIRM_FINGERPRINTS = buildKeyDefinition<boolean>("autoConfirmFingerPrints");
|
||||
|
||||
export class DefaultOrganizationManagementPreferencesService
|
||||
implements OrganizationManagementPreferencesService
|
||||
{
|
||||
export class DefaultOrganizationManagementPreferencesService implements OrganizationManagementPreferencesService {
|
||||
constructor(private stateProvider: StateProvider) {}
|
||||
|
||||
autoConfirmFingerPrints = this.buildOrganizationManagementPreference(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { FakeSingleUserState } from "../../../../spec/fake-state";
|
||||
import {
|
||||
@@ -22,15 +24,15 @@ import { DefaultPolicyService, getFirstPolicy } from "./default-policy.service";
|
||||
import { POLICIES } from "./policy-state";
|
||||
|
||||
describe("PolicyService", () => {
|
||||
const userId = "userId" as UserId;
|
||||
const userId = newGuid() as UserId;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
|
||||
let policyService: DefaultPolicyService;
|
||||
|
||||
beforeEach(() => {
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
organizationService = mock<OrganizationService>();
|
||||
singleUserState = stateProvider.singleUser.getFake(userId, POLICIES);
|
||||
@@ -59,7 +61,7 @@ describe("PolicyService", () => {
|
||||
|
||||
organizationService.organizations$.calledWith(userId).mockReturnValue(organizations$);
|
||||
|
||||
policyService = new DefaultPolicyService(stateProvider, organizationService);
|
||||
policyService = new DefaultPolicyService(stateProvider, organizationService, accountService);
|
||||
});
|
||||
|
||||
it("upsert", async () => {
|
||||
@@ -81,12 +83,15 @@ describe("PolicyService", () => {
|
||||
type: PolicyType.MaximumVaultTimeout,
|
||||
enabled: true,
|
||||
data: { minutes: 14 },
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "99",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -111,6 +116,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -240,6 +247,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,24 +338,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -369,24 +386,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: false,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -409,24 +434,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org2",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -449,24 +482,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org3",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -635,7 +676,7 @@ describe("PolicyService", () => {
|
||||
beforeEach(() => {
|
||||
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
|
||||
organizationService = mock<OrganizationService>();
|
||||
policyService = new DefaultPolicyService(stateProvider, organizationService);
|
||||
policyService = new DefaultPolicyService(stateProvider, organizationService, accountService);
|
||||
});
|
||||
|
||||
it("returns undefined when there are no policies", () => {
|
||||
@@ -786,6 +827,7 @@ describe("PolicyService", () => {
|
||||
policyData.type = type;
|
||||
policyData.enabled = enabled;
|
||||
policyData.data = data;
|
||||
policyData.revisionDate = new Date().toISOString();
|
||||
|
||||
return policyData;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { combineLatest, map, Observable, of } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -25,6 +28,7 @@ export class DefaultPolicyService implements PolicyService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
private policyState(userId: UserId) {
|
||||
@@ -326,4 +330,13 @@ export class DefaultPolicyService implements PolicyService {
|
||||
target.enforceOnLogin = Boolean(target.enforceOnLogin || source.enforceOnLogin);
|
||||
}
|
||||
}
|
||||
|
||||
async syncPolicy(policyData: PolicyData) {
|
||||
await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.upsert(policyData, userId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
120
libs/common/src/admin-console/utils/collection-utils.spec.ts
Normal file
120
libs/common/src/admin-console/utils/collection-utils.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { getNestedCollectionTree, getFlatCollectionTree } from "./collection-utils";
|
||||
|
||||
describe("CollectionUtils Service", () => {
|
||||
describe("getNestedCollectionTree", () => {
|
||||
it("should return collections properly sorted if provided out of order", () => {
|
||||
// Arrange
|
||||
const collections: CollectionView[] = [];
|
||||
|
||||
const parentCollection = new CollectionView({
|
||||
name: "Parent",
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
id: newGuid() as CollectionId,
|
||||
});
|
||||
|
||||
const childCollection = new CollectionView({
|
||||
name: "Parent/Child",
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
id: newGuid() as CollectionId,
|
||||
});
|
||||
|
||||
collections.push(childCollection);
|
||||
collections.push(parentCollection);
|
||||
|
||||
// Act
|
||||
const result = getNestedCollectionTree(collections);
|
||||
|
||||
// Assert
|
||||
expect(result[0].node.name).toBe("Parent");
|
||||
expect(result[0].children[0].node.name).toBe("Child");
|
||||
});
|
||||
|
||||
it("should return an empty array if no collections are provided", () => {
|
||||
// Arrange
|
||||
const collections: CollectionView[] = [];
|
||||
|
||||
// Act
|
||||
const result = getNestedCollectionTree(collections);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFlatCollectionTree", () => {
|
||||
it("should flatten a tree node with no children", () => {
|
||||
// Arrange
|
||||
const collection = new CollectionView({
|
||||
name: "Test Collection",
|
||||
id: "test-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const treeNodes: TreeNode<CollectionView>[] = [
|
||||
new TreeNode<CollectionView>(collection, {} as TreeNode<CollectionView>),
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getFlatCollectionTree(treeNodes);
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toBe(collection);
|
||||
});
|
||||
|
||||
it("should flatten a tree node with children", () => {
|
||||
// Arrange
|
||||
const parentCollection = new CollectionView({
|
||||
name: "Parent",
|
||||
id: "parent-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const child1Collection = new CollectionView({
|
||||
name: "Child 1",
|
||||
id: "child1-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const child2Collection = new CollectionView({
|
||||
name: "Child 2",
|
||||
id: "child2-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const grandchildCollection = new CollectionView({
|
||||
name: "Grandchild",
|
||||
id: "grandchild-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const parentNode = new TreeNode<CollectionView>(
|
||||
parentCollection,
|
||||
{} as TreeNode<CollectionView>,
|
||||
);
|
||||
const child1Node = new TreeNode<CollectionView>(child1Collection, parentNode);
|
||||
const child2Node = new TreeNode<CollectionView>(child2Collection, parentNode);
|
||||
const grandchildNode = new TreeNode<CollectionView>(grandchildCollection, child1Node);
|
||||
|
||||
parentNode.children = [child1Node, child2Node];
|
||||
child1Node.children = [grandchildNode];
|
||||
|
||||
const treeNodes: TreeNode<CollectionView>[] = [parentNode];
|
||||
|
||||
// Act
|
||||
const result = getFlatCollectionTree(treeNodes);
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(4);
|
||||
expect(result[0]).toBe(parentCollection);
|
||||
expect(result).toContain(child1Collection);
|
||||
expect(result).toContain(child2Collection);
|
||||
expect(result).toContain(grandchildCollection);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
libs/common/src/admin-console/utils/collection-utils.ts
Normal file
87
libs/common/src/admin-console/utils/collection-utils.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
CollectionView,
|
||||
NestingDelimiter,
|
||||
CollectionAdminView,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
|
||||
export function getNestedCollectionTree(
|
||||
collections: CollectionAdminView[],
|
||||
): TreeNode<CollectionAdminView>[];
|
||||
export function getNestedCollectionTree(collections: CollectionView[]): TreeNode<CollectionView>[];
|
||||
export function getNestedCollectionTree(
|
||||
collections: (CollectionView | CollectionAdminView)[],
|
||||
): TreeNode<CollectionView | CollectionAdminView>[] {
|
||||
if (!collections) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
|
||||
// modifies the names of collections.
|
||||
// These changes risk affecting collections store in StateService.
|
||||
const clonedCollections: CollectionView[] | CollectionAdminView[] = collections
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(cloneCollection);
|
||||
|
||||
const all: TreeNode<CollectionView | CollectionAdminView>[] = [];
|
||||
const groupedByOrg = new Map<OrganizationId, (CollectionView | CollectionAdminView)[]>();
|
||||
clonedCollections.map((c) => {
|
||||
const key = c.organizationId;
|
||||
(groupedByOrg.get(key) ?? groupedByOrg.set(key, []).get(key)!).push(c);
|
||||
});
|
||||
for (const group of groupedByOrg.values()) {
|
||||
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
|
||||
for (const c of group) {
|
||||
const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, c, undefined, NestingDelimiter);
|
||||
}
|
||||
all.push(...nodes);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
export function cloneCollection(collection: CollectionView): CollectionView;
|
||||
export function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
|
||||
export function cloneCollection(
|
||||
collection: CollectionView | CollectionAdminView,
|
||||
): CollectionView | CollectionAdminView {
|
||||
let cloned;
|
||||
|
||||
if (collection instanceof CollectionAdminView) {
|
||||
cloned = Object.assign(
|
||||
new CollectionAdminView({ ...collection, name: collection.name }),
|
||||
collection,
|
||||
);
|
||||
} else {
|
||||
cloned = Object.assign(
|
||||
new CollectionView({ ...collection, name: collection.name }),
|
||||
collection,
|
||||
);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function getFlatCollectionTree(
|
||||
nodes: TreeNode<CollectionAdminView>[],
|
||||
): CollectionAdminView[];
|
||||
export function getFlatCollectionTree(nodes: TreeNode<CollectionView>[]): CollectionView[];
|
||||
export function getFlatCollectionTree(
|
||||
nodes: TreeNode<CollectionView | CollectionAdminView>[],
|
||||
): (CollectionView | CollectionAdminView)[] {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return nodes.flatMap((node) => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return [node.node];
|
||||
}
|
||||
|
||||
const children = getFlatCollectionTree(node.children);
|
||||
return [node.node, ...children];
|
||||
});
|
||||
}
|
||||
1
libs/common/src/admin-console/utils/index.ts
Normal file
1
libs/common/src/admin-console/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./collection-utils";
|
||||
@@ -3,35 +3,24 @@ import { Observable } from "rxjs";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
/**
|
||||
* Holds information about an account for use in the AccountService
|
||||
* if more information is added, be sure to update the equality method.
|
||||
* Holds state that represents a user's account with Bitwarden.
|
||||
* Any additions here should be added to the equality check in the AccountService
|
||||
* to ensure that emissions are done on every change.
|
||||
*
|
||||
* @property email - User's email address.
|
||||
* @property emailVerified - Whether the email has been verified.
|
||||
* @property name - User's display name (optional).
|
||||
* @property creationDate - Date when the account was created.
|
||||
* Will be undefined immediately after login until the first sync completes.
|
||||
*/
|
||||
export type AccountInfo = {
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
name: string | undefined;
|
||||
creationDate: Date | undefined;
|
||||
};
|
||||
|
||||
export type Account = { id: UserId } & AccountInfo;
|
||||
|
||||
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
|
||||
if (a == null && b == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof AccountInfo>;
|
||||
for (const key of keys) {
|
||||
if (a[key] !== b[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export abstract class AccountService {
|
||||
abstract accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||
|
||||
@@ -47,6 +36,8 @@ export abstract class AccountService {
|
||||
abstract sortedUserIds$: Observable<UserId[]>;
|
||||
/** Next account that is not the current active account */
|
||||
abstract nextUpAccount$: Observable<Account>;
|
||||
/** Observable to display the header */
|
||||
abstract showHeader$: Observable<boolean>;
|
||||
/**
|
||||
* Updates the `accounts$` observable with the new account data.
|
||||
*
|
||||
@@ -73,6 +64,12 @@ export abstract class AccountService {
|
||||
* @param emailVerified
|
||||
*/
|
||||
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
|
||||
/**
|
||||
* updates the `accounts$` observable with the creation date for the account.
|
||||
* @param userId
|
||||
* @param creationDate
|
||||
*/
|
||||
abstract setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void>;
|
||||
/**
|
||||
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
|
||||
* @param userId
|
||||
@@ -100,6 +97,11 @@ export abstract class AccountService {
|
||||
* @param lastActivity
|
||||
*/
|
||||
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
|
||||
/**
|
||||
* Show the account switcher.
|
||||
* @param value
|
||||
*/
|
||||
abstract setShowHeader(visible: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class InternalAccountService extends AccountService {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Auth Request Answering Service
|
||||
|
||||
This feature is to allow for the taking of auth requests that are received via websockets by the background service to
|
||||
be acted on when the user loads up a client. Currently only implemented with the browser client.
|
||||
This feature is to allow for the taking of auth requests that are received via websockets to be acted on when the user loads up a client.
|
||||
|
||||
See diagram for the high level picture of how this is wired up.
|
||||
|
||||
|
||||
@@ -1,30 +1,50 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export abstract class AuthRequestAnsweringServiceAbstraction {
|
||||
export abstract class AuthRequestAnsweringService {
|
||||
/**
|
||||
* Tries to either display the dialog for the user or will preserve its data and show it at a
|
||||
* later time. Even in the event the dialog is shown immediately, this will write to global state
|
||||
* so that even if someone closes a window or a popup and comes back, it could be processed later.
|
||||
* Only way to clear out the global state is to respond to the auth request.
|
||||
* - Implemented on Extension and Desktop.
|
||||
*
|
||||
* Currently, this is only implemented for browser extension.
|
||||
*
|
||||
* @param userId The UserId that the auth request is for.
|
||||
* @param authRequestUserId The UserId that the auth request is for.
|
||||
* @param authRequestId The id of the auth request that is to be processed.
|
||||
*/
|
||||
abstract receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void>;
|
||||
abstract receivedPendingAuthRequest?(
|
||||
authRequestUserId: UserId,
|
||||
authRequestId: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* When a system notification is clicked, this function is used to process that event.
|
||||
* Confirms whether or not the user meets the conditions required to show them an
|
||||
* approval dialog immediately.
|
||||
*
|
||||
* @param authRequestUserId the UserId that the auth request is for.
|
||||
* @returns boolean stating whether or not the user meets conditions
|
||||
*/
|
||||
abstract activeUserMeetsConditionsToShowApprovalDialog(
|
||||
authRequestUserId: UserId,
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Sets up listeners for scenarios where the user unlocks and we want to process
|
||||
* any pending auth requests in state.
|
||||
*
|
||||
* @param destroy$ The destroy$ observable from the caller
|
||||
*/
|
||||
abstract setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void;
|
||||
|
||||
/**
|
||||
* When a system notification is clicked, this method is used to process that event.
|
||||
* - Implemented on Extension only.
|
||||
* - Desktop does not implement this method because click handling is already setup in
|
||||
* electron-main-messaging.service.ts.
|
||||
*
|
||||
* @param event The event passed in. Check initNotificationSubscriptions in main.background.ts.
|
||||
*/
|
||||
abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Process notifications that have been received but didn't meet the conditions to display the
|
||||
* approval dialog.
|
||||
*/
|
||||
abstract processPendingAuthRequests(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
|
||||
|
||||
export class AuthResult {
|
||||
userId: UserId;
|
||||
// TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal
|
||||
/**
|
||||
* @deprecated
|
||||
* Replace with using UserDecryptionOptions to determine if the user does
|
||||
* not have a master password and is not using Key Connector.
|
||||
* */
|
||||
resetMasterPassword = false;
|
||||
|
||||
twoFactorProviders: Partial<Record<TwoFactorProviderType, Record<string, string>>> = null;
|
||||
ssoEmail2FaSessionToken?: string;
|
||||
email: string;
|
||||
requiresEncryptionKeyMigration: boolean;
|
||||
requiresDeviceVerification: boolean;
|
||||
ssoOrganizationIdentifier?: string | null;
|
||||
// The master-password used in the authentication process
|
||||
masterPassword: string | null;
|
||||
|
||||
get requiresTwoFactor() {
|
||||
return this.twoFactorProviders != null;
|
||||
}
|
||||
|
||||
// This is not as extensible as an object-based approach. In the future we may need to adjust to an object based approach.
|
||||
get requiresSso() {
|
||||
return !Utils.isNullOrWhitespace(this.ssoOrganizationIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class IdentitySsoRequiredResponse extends BaseResponse {
|
||||
ssoOrganizationIdentifier: string | null;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.ssoOrganizationIdentifier = this.getResponseProperty("SsoOrganizationIdentifier");
|
||||
}
|
||||
}
|
||||
@@ -116,4 +116,36 @@ describe("IdentityTokenResponse", () => {
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.userDecryptionOptions).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create response with accountKeys not present", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
AccountKeys: null as unknown,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.accountKeysResponseModel).toBeNull();
|
||||
});
|
||||
|
||||
it("should create response with accountKeys present", () => {
|
||||
const accountKeysData = {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
publicKey: "testPublicKey",
|
||||
wrappedPrivateKey: "testPrivateKey",
|
||||
},
|
||||
};
|
||||
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
AccountKeys: accountKeysData,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.accountKeysResponseModel).toBeDefined();
|
||||
expect(
|
||||
identityTokenResponse.accountKeysResponseModel?.publicKeyEncryptionKeyPair,
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { PrivateKeysResponseModel } from "../../../key-management/keys/response/private-keys.response";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
|
||||
@@ -18,15 +19,26 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
tokenType: string;
|
||||
|
||||
// Decryption Information
|
||||
resetMasterPassword: boolean;
|
||||
privateKey: string; // userKeyEncryptedPrivateKey
|
||||
key?: EncString; // masterKeyEncryptedUserKey
|
||||
|
||||
/**
|
||||
* privateKey is actually userKeyEncryptedPrivateKey
|
||||
* @deprecated Use {@link accountKeysResponseModel} instead
|
||||
*/
|
||||
privateKey: string;
|
||||
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-30124 - Rename to just accountKeys
|
||||
accountKeysResponseModel: PrivateKeysResponseModel | null = null;
|
||||
|
||||
/**
|
||||
* key is actually masterKeyEncryptedUserKey
|
||||
* @deprecated Use {@link userDecryptionOptions.masterPasswordUnlock.masterKeyWrappedUserKey} instead
|
||||
*/
|
||||
key?: EncString;
|
||||
twoFactorToken: string;
|
||||
kdfConfig: KdfConfig;
|
||||
forcePasswordReset: boolean;
|
||||
masterPasswordPolicy: MasterPasswordPolicyResponse;
|
||||
apiUseKeyConnector: boolean;
|
||||
keyConnectorUrl: string;
|
||||
|
||||
userDecryptionOptions?: UserDecryptionOptionsResponse;
|
||||
|
||||
@@ -53,8 +65,12 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
this.resetMasterPassword = this.getResponseProperty("ResetMasterPassword");
|
||||
this.privateKey = this.getResponseProperty("PrivateKey");
|
||||
if (this.getResponseProperty("AccountKeys") != null) {
|
||||
this.accountKeysResponseModel = new PrivateKeysResponseModel(
|
||||
this.getResponseProperty("AccountKeys"),
|
||||
);
|
||||
}
|
||||
const key = this.getResponseProperty("Key");
|
||||
if (key) {
|
||||
this.key = new EncString(key);
|
||||
@@ -70,7 +86,7 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
|
||||
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
|
||||
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
|
||||
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
|
||||
|
||||
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
|
||||
this.getResponseProperty("MasterPasswordPolicy"),
|
||||
);
|
||||
|
||||
@@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
|
||||
masterPasswordUnlock?: MasterPasswordUnlockResponse;
|
||||
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
|
||||
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
|
||||
/**
|
||||
* The IdTokenresponse only returns a single WebAuthn PRF option.
|
||||
* To support immediate unlock after logging in with the same PRF passkey.
|
||||
*/
|
||||
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
|
||||
|
||||
constructor(response: IUserDecryptionOptionsServerResponse) {
|
||||
|
||||
@@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response";
|
||||
export interface IWebAuthnPrfDecryptionOptionServerResponse {
|
||||
EncryptedPrivateKey: string;
|
||||
EncryptedUserKey: string;
|
||||
CredentialId: string;
|
||||
Transports: string[];
|
||||
}
|
||||
|
||||
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
|
||||
encryptedPrivateKey: EncString;
|
||||
encryptedUserKey: EncString;
|
||||
credentialId: string;
|
||||
transports: string[];
|
||||
|
||||
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
|
||||
super(response);
|
||||
if (response.EncryptedPrivateKey) {
|
||||
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
|
||||
|
||||
const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey");
|
||||
if (encPrivateKey) {
|
||||
this.encryptedPrivateKey = new EncString(encPrivateKey);
|
||||
}
|
||||
if (response.EncryptedUserKey) {
|
||||
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
|
||||
|
||||
const encUserKey = this.getResponseProperty("EncryptedUserKey");
|
||||
if (encUserKey) {
|
||||
this.encryptedUserKey = new EncString(encUserKey);
|
||||
}
|
||||
|
||||
this.credentialId = this.getResponseProperty("CredentialId");
|
||||
this.transports = this.getResponseProperty("Transports") || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ export class DefaultSendTokenService implements SendTokenServiceAbstraction {
|
||||
}
|
||||
|
||||
// Destructure to omit the specific sendId and get new reference for the rest of the dict for an immutable update
|
||||
|
||||
|
||||
const { [sendId]: _, ...rest } = sendAccessTokenDict;
|
||||
|
||||
return rest;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
|
||||
import { FakeGlobalState } from "../../../spec/fake-state";
|
||||
import {
|
||||
FakeGlobalStateProvider,
|
||||
@@ -16,7 +17,7 @@ import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
|
||||
import { AccountInfo } from "../abstractions/account.service";
|
||||
|
||||
import {
|
||||
ACCOUNT_ACCOUNTS,
|
||||
@@ -26,46 +27,6 @@ import {
|
||||
AccountServiceImplementation,
|
||||
} from "./account.service";
|
||||
|
||||
describe("accountInfoEqual", () => {
|
||||
const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true };
|
||||
|
||||
it("compares nulls", () => {
|
||||
expect(accountInfoEqual(null, null)).toBe(true);
|
||||
expect(accountInfoEqual(null, accountInfo)).toBe(false);
|
||||
expect(accountInfoEqual(accountInfo, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares all keys, not just those defined in AccountInfo", () => {
|
||||
const different = { ...accountInfo, extra: "extra" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares name", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, name: "name2" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares email", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, email: "email2" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares emailVerified", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, emailVerified: false };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountService", () => {
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
@@ -76,7 +37,10 @@ describe("accountService", () => {
|
||||
let activeAccountIdState: FakeGlobalState<UserId>;
|
||||
let accountActivityState: FakeGlobalState<Record<UserId, Date>>;
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const userInfo = { email: "email", name: "name", emailVerified: true };
|
||||
const userInfo = mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "name",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
messagingService = mock();
|
||||
@@ -100,6 +64,60 @@ describe("accountService", () => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("accountInfoEqual", () => {
|
||||
const accountInfo = mockAccountInfoWith();
|
||||
|
||||
it("compares nulls", () => {
|
||||
expect((sut as any).accountInfoEqual(null, null)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(null, accountInfo)).toBe(false);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares name", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, name: "name2" };
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares email", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, email: "email2" };
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares emailVerified", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, emailVerified: false };
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares creationDate", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, creationDate: new Date("2024-12-31T00:00:00.000Z") };
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares undefined creationDate", () => {
|
||||
const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined });
|
||||
const same = { ...accountWithoutCreationDate };
|
||||
const different = {
|
||||
...accountWithoutCreationDate,
|
||||
creationDate: new Date("2024-01-01T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, different)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeAccount$", () => {
|
||||
it("should emit null if no account is active", () => {
|
||||
const emissions = trackEmissions(sut.activeAccount$);
|
||||
@@ -253,6 +271,79 @@ describe("accountService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCreationDate", () => {
|
||||
const initialState = { [userId]: userInfo };
|
||||
beforeEach(() => {
|
||||
accountsState.stateSubject.next(initialState);
|
||||
});
|
||||
|
||||
it("should update the account with a new creation date", async () => {
|
||||
const newCreationDate = new Date("2024-12-31T00:00:00.000Z");
|
||||
await sut.setAccountCreationDate(userId, newCreationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: { ...userInfo, creationDate: newCreationDate },
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update if the creation date is the same", async () => {
|
||||
await sut.setAccountCreationDate(userId, userInfo.creationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual(initialState);
|
||||
});
|
||||
|
||||
it("should not update if the creation date has the same timestamp but different Date object", async () => {
|
||||
const sameTimestamp = new Date(userInfo.creationDate.getTime());
|
||||
await sut.setAccountCreationDate(userId, sameTimestamp);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual(initialState);
|
||||
});
|
||||
|
||||
it("should update if the creation date has a different timestamp", async () => {
|
||||
const differentDate = new Date(userInfo.creationDate.getTime() + 1000);
|
||||
await sut.setAccountCreationDate(userId, differentDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: { ...userInfo, creationDate: differentDate },
|
||||
});
|
||||
});
|
||||
|
||||
it("should update from undefined to a defined creation date", async () => {
|
||||
const accountWithoutCreationDate = mockAccountInfoWith({
|
||||
...userInfo,
|
||||
creationDate: undefined,
|
||||
});
|
||||
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
|
||||
|
||||
const newCreationDate = new Date("2024-06-15T12:30:00.000Z");
|
||||
await sut.setAccountCreationDate(userId, newCreationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: { ...accountWithoutCreationDate, creationDate: newCreationDate },
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update when both creation dates are undefined", async () => {
|
||||
const accountWithoutCreationDate = mockAccountInfoWith({
|
||||
...userInfo,
|
||||
creationDate: undefined,
|
||||
});
|
||||
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
|
||||
|
||||
// Attempt to set to undefined (shouldn't trigger update)
|
||||
const currentStateBefore = await firstValueFrom(accountsState.state$);
|
||||
|
||||
// We can't directly call setAccountCreationDate with undefined, but we can verify
|
||||
// the behavior through setAccountInfo which accountInfoEqual uses internally
|
||||
expect(currentStateBefore[userId].creationDate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAccountVerifyNewDeviceLogin", () => {
|
||||
const initialState = true;
|
||||
beforeEach(() => {
|
||||
@@ -294,6 +385,7 @@ describe("accountService", () => {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
name: undefined,
|
||||
creationDate: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -429,6 +521,16 @@ describe("accountService", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("setShowHeader", () => {
|
||||
it("should update _showHeader$ when setShowHeader is called", async () => {
|
||||
expect(sut["_showHeader$"].value).toBe(true);
|
||||
|
||||
await sut.setShowHeader(false);
|
||||
|
||||
expect(sut["_showHeader$"].value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
distinctUntilChanged,
|
||||
shareReplay,
|
||||
combineLatest,
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
switchMap,
|
||||
filter,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
Account,
|
||||
AccountInfo,
|
||||
InternalAccountService,
|
||||
accountInfoEqual,
|
||||
} from "../../auth/abstractions/account.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
@@ -36,7 +36,10 @@ export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
|
||||
ACCOUNT_DISK,
|
||||
"accounts",
|
||||
{
|
||||
deserializer: (accountInfo) => accountInfo,
|
||||
deserializer: (accountInfo) => ({
|
||||
...accountInfo,
|
||||
creationDate: accountInfo.creationDate ? new Date(accountInfo.creationDate) : undefined,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -61,6 +64,7 @@ const LOGGED_OUT_INFO: AccountInfo = {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
name: undefined,
|
||||
creationDate: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,6 +88,7 @@ export const getOptionalUserId = map<Account | null, UserId | null>(
|
||||
export class AccountServiceImplementation implements InternalAccountService {
|
||||
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
|
||||
private activeAccountIdState: GlobalState<UserId | undefined>;
|
||||
private _showHeader$ = new BehaviorSubject<boolean>(true);
|
||||
|
||||
accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||
activeAccount$: Observable<Account | null>;
|
||||
@@ -91,6 +96,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
accountVerifyNewDeviceLogin$: Observable<boolean>;
|
||||
sortedUserIds$: Observable<UserId[]>;
|
||||
nextUpAccount$: Observable<Account>;
|
||||
showHeader$ = this._showHeader$.asObservable();
|
||||
|
||||
constructor(
|
||||
private messagingService: MessagingService,
|
||||
@@ -107,7 +113,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
|
||||
combineLatestWith(this.accounts$),
|
||||
map(([id, accounts]) => (id ? ({ id, ...(accounts[id] as AccountInfo) } as Account) : null)),
|
||||
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
|
||||
distinctUntilChanged((a, b) => a?.id === b?.id && this.accountInfoEqual(a, b)),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
this.accountActivity$ = this.globalStateProvider
|
||||
@@ -164,6 +170,10 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
await this.setAccountInfo(userId, { emailVerified });
|
||||
}
|
||||
|
||||
async setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void> {
|
||||
await this.setAccountInfo(userId, { creationDate });
|
||||
}
|
||||
|
||||
async clean(userId: UserId) {
|
||||
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
|
||||
await this.removeAccountActivity(userId);
|
||||
@@ -262,6 +272,27 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
async setShowHeader(visible: boolean): Promise<void> {
|
||||
this._showHeader$.next(visible);
|
||||
}
|
||||
|
||||
private accountInfoEqual(a: AccountInfo, b: AccountInfo) {
|
||||
if (a == null && b == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
a.email === b.email &&
|
||||
a.emailVerified === b.emailVerified &&
|
||||
a.name === b.name &&
|
||||
a.creationDate?.getTime() === b.creationDate?.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
|
||||
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
|
||||
return { ...oldAccountInfo, ...update };
|
||||
@@ -279,7 +310,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
throw new Error("Account does not exist");
|
||||
}
|
||||
|
||||
return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
|
||||
return !this.accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ActionsService } from "@bitwarden/common/platform/actions";
|
||||
import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
SystemNotificationsService,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "./auth-request-answering.service";
|
||||
import { PendingAuthRequestsStateService } from "./pending-auth-requests.state";
|
||||
|
||||
describe("AuthRequestAnsweringService", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let actionService: MockProxy<ActionsService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let systemNotificationsService: MockProxy<SystemNotificationsService>;
|
||||
|
||||
let sut: AuthRequestAnsweringService;
|
||||
|
||||
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
actionService = mock<ActionsService>();
|
||||
authService = mock<AuthService>();
|
||||
i18nService = mock<I18nService>();
|
||||
masterPasswordService = { forceSetPasswordReason$: jest.fn() };
|
||||
messagingService = mock<MessagingService>();
|
||||
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
systemNotificationsService = mock<SystemNotificationsService>();
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
accountService.activeAccount$ = of({
|
||||
id: userId,
|
||||
email: "user@example.com",
|
||||
emailVerified: true,
|
||||
name: "User",
|
||||
});
|
||||
accountService.accounts$ = of({
|
||||
[userId]: { email: "user@example.com", emailVerified: true, name: "User" },
|
||||
});
|
||||
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
|
||||
of(ForceSetPasswordReason.None),
|
||||
);
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
i18nService.t.mockImplementation(
|
||||
(key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`,
|
||||
);
|
||||
systemNotificationsService.create.mockResolvedValue("notif-id");
|
||||
|
||||
sut = new AuthRequestAnsweringService(
|
||||
accountService,
|
||||
actionService,
|
||||
authService,
|
||||
i18nService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
platformUtilsService,
|
||||
systemNotificationsService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("handleAuthRequestNotificationClicked", () => {
|
||||
it("clears notification and opens popup when notification body is clicked", async () => {
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.NotificationButton,
|
||||
};
|
||||
|
||||
await sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" });
|
||||
expect(actionService.openPopup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does nothing when an optional button is clicked", async () => {
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.FirstOptionalButton,
|
||||
};
|
||||
|
||||
await sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
expect(systemNotificationsService.clear).not.toHaveBeenCalled();
|
||||
expect(actionService.openPopup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("receivedPendingAuthRequest", () => {
|
||||
const authRequestId = "req-abc";
|
||||
|
||||
it("creates a system notification when popup is not open", async () => {
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com");
|
||||
expect(systemNotificationsService.create).toHaveBeenCalledWith({
|
||||
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`,
|
||||
title: "accountAccessRequested",
|
||||
body: "confirmAccessAttempt:user@example.com",
|
||||
buttons: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create a notification when popup is open, user is active, unlocked, and no force set password", async () => {
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
|
||||
of(ForceSetPasswordReason.None),
|
||||
);
|
||||
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
expect(systemNotificationsService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ActionsService } from "@bitwarden/common/platform/actions";
|
||||
import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
SystemNotificationsService,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
|
||||
constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly actionService: ActionsService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private readonly messagingService: MessagingService,
|
||||
private readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
private readonly systemNotificationsService: SystemNotificationsService,
|
||||
) {}
|
||||
|
||||
async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void> {
|
||||
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
const activeUserId: UserId | null = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(userId),
|
||||
);
|
||||
const popupOpen = await this.platformUtilsService.isPopupOpen();
|
||||
|
||||
// Always persist the pending marker for this user to global state.
|
||||
await this.pendingAuthRequestsState.add(userId);
|
||||
|
||||
// These are the conditions we are looking for to know if the extension is in a state to show
|
||||
// the approval dialog.
|
||||
const userIsAvailableToReceiveAuthRequest =
|
||||
popupOpen &&
|
||||
authStatus === AuthenticationStatus.Unlocked &&
|
||||
activeUserId === userId &&
|
||||
forceSetPasswordReason === ForceSetPasswordReason.None;
|
||||
|
||||
if (!userIsAvailableToReceiveAuthRequest) {
|
||||
// Get the user's email to include in the system notification
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
const emailForUser = accounts[userId].email;
|
||||
|
||||
await this.systemNotificationsService.create({
|
||||
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter.
|
||||
title: this.i18nService.t("accountAccessRequested"),
|
||||
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
|
||||
buttons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Popup is open and conditions are met; open dialog immediately for this request
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
|
||||
await this.systemNotificationsService.clear({
|
||||
id: `${event.id}`,
|
||||
});
|
||||
await this.actionService.openPopup();
|
||||
}
|
||||
}
|
||||
|
||||
async processPendingAuthRequests(): Promise<void> {
|
||||
// Prune any stale pending requests (older than 15 minutes)
|
||||
// This comes from GlobalSettings.cs
|
||||
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
const fifteenMinutesMs = 15 * 60 * 1000;
|
||||
|
||||
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
|
||||
|
||||
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
|
||||
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
|
||||
|
||||
if (pendingAuthRequestsInState.length > 0) {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
|
||||
(e) => e.userId === activeUserId,
|
||||
);
|
||||
|
||||
if (pendingAuthRequestsForActiveUser) {
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of, Subject } from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import { DefaultAuthRequestAnsweringService } from "./default-auth-request-answering.service";
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
describe("DefaultAuthRequestAnsweringService", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
|
||||
|
||||
let sut: AuthRequestAnsweringService;
|
||||
|
||||
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
|
||||
const userAccountInfo = mockAccountInfoWith({
|
||||
name: "User",
|
||||
email: "user@example.com",
|
||||
});
|
||||
const userAccount: Account = {
|
||||
id: userId,
|
||||
...userAccountInfo,
|
||||
};
|
||||
|
||||
const otherUserId = "554c3112-9a75-23af-ab80-8dk3e9bl5i8e" as UserId;
|
||||
const otherUserAccountInfo = mockAccountInfoWith({
|
||||
name: "Other",
|
||||
email: "other@example.com",
|
||||
});
|
||||
const otherUserAccount: Account = {
|
||||
id: otherUserId,
|
||||
...otherUserAccountInfo,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
authService = mock<AuthService>();
|
||||
masterPasswordService = {
|
||||
forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)),
|
||||
};
|
||||
messagingService = mock<MessagingService>();
|
||||
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
accountService.activeAccount$ = of(userAccount);
|
||||
accountService.accounts$ = of({
|
||||
[userId]: userAccountInfo,
|
||||
[otherUserId]: otherUserAccountInfo,
|
||||
});
|
||||
|
||||
sut = new DefaultAuthRequestAnsweringService(
|
||||
accountService,
|
||||
authService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
);
|
||||
});
|
||||
|
||||
describe("activeUserMeetsConditionsToShowApprovalDialog()", () => {
|
||||
it("should return false if there is no active user", async () => {
|
||||
// Arrange
|
||||
accountService.activeAccount$ = of(null);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the active user is not the intended recipient of the auth request", async () => {
|
||||
// Arrange
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(otherUserId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the active user is not unlocked", async () => {
|
||||
// Arrange
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the active user is required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
|
||||
of(ForceSetPasswordReason.WeakMasterPassword),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupUnlockListenersForProcessingAuthRequests()", () => {
|
||||
let destroy$: Subject<void>;
|
||||
let activeAccount$: BehaviorSubject<Account>;
|
||||
let activeAccountStatus$: BehaviorSubject<AuthenticationStatus>;
|
||||
let authStatusForSubjects: Map<UserId, BehaviorSubject<AuthenticationStatus>>;
|
||||
let pendingRequestMarkers: PendingAuthUserMarker[];
|
||||
|
||||
beforeEach(() => {
|
||||
destroy$ = new Subject<void>();
|
||||
activeAccount$ = new BehaviorSubject(userAccount);
|
||||
activeAccountStatus$ = new BehaviorSubject(AuthenticationStatus.Locked);
|
||||
authStatusForSubjects = new Map();
|
||||
pendingRequestMarkers = [];
|
||||
|
||||
accountService.activeAccount$ = activeAccount$;
|
||||
authService.activeAccountStatus$ = activeAccountStatus$;
|
||||
authService.authStatusFor$.mockImplementation((id: UserId) => {
|
||||
if (!authStatusForSubjects.has(id)) {
|
||||
authStatusForSubjects.set(id, new BehaviorSubject(AuthenticationStatus.Locked));
|
||||
}
|
||||
return authStatusForSubjects.get(id)!;
|
||||
});
|
||||
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of([]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroy$.next();
|
||||
destroy$.complete();
|
||||
});
|
||||
|
||||
describe("active account switching", () => {
|
||||
it("should process pending auth requests when switching to an unlocked user", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Simulate account switching to an Unlocked account
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // Allows observable chain to complete before assertion
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when switching to a locked user", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Locked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when switching to a logged out user", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.LoggedOut));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when active account becomes null", async () => {
|
||||
// Arrange
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(null);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle multiple user switches correctly", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(userId, new BehaviorSubject(AuthenticationStatus.Locked));
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Switch to unlocked user (should trigger)
|
||||
activeAccount$.next(otherUserAccount);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Switch to locked user (should NOT trigger)
|
||||
activeAccount$.next(userAccount);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when switching to an Unlocked user who is required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
|
||||
of(ForceSetPasswordReason.WeakMasterPassword),
|
||||
);
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication status transitions", () => {
|
||||
it("should process pending auth requests when active account transitions to Unlocked", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should process pending auth requests when transitioning from LoggedOut to Unlocked", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when transitioning from Unlocked to Locked", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Clear any calls from the initial trigger (from null -> Unlocked)
|
||||
messagingService.send.mockClear();
|
||||
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when transitioning from Locked to LoggedOut", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when staying in Unlocked status", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Clear any calls from the initial trigger (from null -> Unlocked)
|
||||
messagingService.send.mockClear();
|
||||
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle multiple status transitions correctly", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Transition to Unlocked (should trigger)
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Transition to Locked (should NOT trigger)
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Transition back to Unlocked (should trigger again)
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).toHaveBeenCalledTimes(2);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when active account transitions to Unlocked but is required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
|
||||
of(ForceSetPasswordReason.WeakMasterPassword),
|
||||
);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscription cleanup", () => {
|
||||
it("should stop processing when destroy$ emits", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Emit destroy signal
|
||||
destroy$.next();
|
||||
|
||||
// Try to trigger processing after cleanup
|
||||
activeAccount$.next(otherUserAccount);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAuthRequestNotificationClicked()", () => {
|
||||
it("should throw an error", async () => {
|
||||
// Arrange
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.NotificationButton,
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"handleAuthRequestNotificationClicked() not implemented for this client",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
export class DefaultAuthRequestAnsweringService implements AuthRequestAnsweringService {
|
||||
constructor(
|
||||
protected readonly accountService: AccountService,
|
||||
protected readonly authService: AuthService,
|
||||
protected readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
protected readonly messagingService: MessagingService,
|
||||
protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
) {}
|
||||
|
||||
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
|
||||
// If the active user is not the intended recipient of the auth request, return false
|
||||
const activeUserId: UserId | null = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
if (activeUserId !== authRequestUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the active user is not unlocked, return false
|
||||
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
if (authStatus !== AuthenticationStatus.Unlocked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the active user is required to set/change their master password, return false
|
||||
// Note that by this point we know that the authRequestUserId is the active UserId (see check above)
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(authRequestUserId),
|
||||
);
|
||||
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User meets conditions: they are the intended recipient, unlocked, and not required to set/change their master password
|
||||
return true;
|
||||
}
|
||||
|
||||
setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void {
|
||||
// When account switching to a user who is Unlocked, process any pending auth requests.
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
map((a) => a?.id), // Extract active userId
|
||||
distinctUntilChanged(), // Only when userId actually changes
|
||||
filter((userId) => userId != null), // Require a valid userId
|
||||
switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user
|
||||
filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked
|
||||
tap(() => {
|
||||
void this.processPendingAuthRequests();
|
||||
}),
|
||||
takeUntil(destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// When the active account transitions TO Unlocked, process any pending auth requests.
|
||||
this.authService.activeAccountStatus$
|
||||
.pipe(
|
||||
startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission
|
||||
pairwise(), // Compare previous and current statuses
|
||||
filter(
|
||||
([prev, curr]) =>
|
||||
prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial)
|
||||
),
|
||||
takeUntil(destroy$),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void this.processPendingAuthRequests();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process notifications that have been received but didn't meet the conditions to display the
|
||||
* approval dialog.
|
||||
*/
|
||||
private async processPendingAuthRequests(): Promise<void> {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
// Only continue if the active user is not required to set/change their master password
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(activeUserId),
|
||||
);
|
||||
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prune any stale pending requests (older than 15 minutes)
|
||||
// This comes from GlobalSettings.cs
|
||||
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
const fifteenMinutesMs = 15 * 60 * 1000;
|
||||
|
||||
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
|
||||
|
||||
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
|
||||
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
|
||||
|
||||
if (pendingAuthRequestsInState.length > 0) {
|
||||
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
|
||||
(e) => e.userId === activeUserId,
|
||||
);
|
||||
|
||||
if (pendingAuthRequestsForActiveUser) {
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
|
||||
constructor() {}
|
||||
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringService {
|
||||
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
|
||||
throw new Error(
|
||||
"activeUserMeetsConditionsToShowApprovalDialog() not implemented for this client",
|
||||
);
|
||||
}
|
||||
|
||||
async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise<void> {}
|
||||
setupUnlockListenersForProcessingAuthRequests(): void {
|
||||
throw new Error(
|
||||
"setupUnlockListenersForProcessingAuthRequests() not implemented for this client",
|
||||
);
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {}
|
||||
|
||||
async processPendingAuthRequests(): Promise<void> {}
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
makeStaticByteArray,
|
||||
mockAccountServiceWith,
|
||||
trackEmissions,
|
||||
mockAccountInfoWith,
|
||||
} from "../../../spec";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
@@ -58,9 +59,10 @@ describe("AuthService", () => {
|
||||
const accountInfo = {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: userId,
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
name: "name",
|
||||
...mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "name",
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -112,9 +114,10 @@ describe("AuthService", () => {
|
||||
const accountInfo2 = {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: Utils.newGuid() as UserId,
|
||||
email: "email2",
|
||||
emailVerified: false,
|
||||
name: "name2",
|
||||
...mockAccountInfoWith({
|
||||
email: "email2",
|
||||
name: "name2",
|
||||
}),
|
||||
};
|
||||
|
||||
const emissions = trackEmissions(sut.activeAccountStatus$);
|
||||
@@ -131,11 +134,13 @@ describe("AuthService", () => {
|
||||
it("requests auth status for all known users", async () => {
|
||||
const userId2 = Utils.newGuid() as UserId;
|
||||
|
||||
await accountService.addAccount(userId2, {
|
||||
email: "email2",
|
||||
emailVerified: false,
|
||||
name: "name2",
|
||||
});
|
||||
await accountService.addAccount(
|
||||
userId2,
|
||||
mockAccountInfoWith({
|
||||
email: "email2",
|
||||
name: "name2",
|
||||
}),
|
||||
);
|
||||
|
||||
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
|
||||
sut.authStatusFor$ = mockFn;
|
||||
|
||||
@@ -8,12 +8,13 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
|
||||
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { Account, AccountInfo, AccountService } from "../abstractions/account.service";
|
||||
import { Account, AccountService } from "../abstractions/account.service";
|
||||
|
||||
import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation";
|
||||
|
||||
@@ -96,11 +97,10 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
|
||||
const encryptedKey = { encryptedString: "encryptedString" };
|
||||
organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any);
|
||||
|
||||
const user1AccountInfo: AccountInfo = {
|
||||
const user1AccountInfo = mockAccountInfoWith({
|
||||
name: "Test User 1",
|
||||
email: "test1@email.com",
|
||||
emailVerified: true,
|
||||
};
|
||||
});
|
||||
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
|
||||
|
||||
keyService.userKey$.mockReturnValue(of({ key: "key" } as any));
|
||||
|
||||
@@ -22,9 +22,7 @@ import { UserKey } from "../../types/key";
|
||||
import { AccountService } from "../abstractions/account.service";
|
||||
import { PasswordResetEnrollmentServiceAbstraction } from "../abstractions/password-reset-enrollment.service.abstraction";
|
||||
|
||||
export class PasswordResetEnrollmentServiceImplementation
|
||||
implements PasswordResetEnrollmentServiceAbstraction
|
||||
{
|
||||
export class PasswordResetEnrollmentServiceImplementation implements PasswordResetEnrollmentServiceAbstraction {
|
||||
constructor(
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
protected accountService: AccountService,
|
||||
|
||||
@@ -445,13 +445,15 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
|
||||
// but we can simply clear all locations to avoid the need to require those parameters.
|
||||
|
||||
// When secure storage is supported, clear the encryption key from secure storage.
|
||||
// When not supported (e.g., portable builds), tokens are stored on disk and this step is skipped.
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
// Always clear the access token key when clearing the access token
|
||||
// The next set of the access token will create a new access token key
|
||||
// Always clear the access token key when clearing the access token.
|
||||
// The next set of the access token will create a new access token key.
|
||||
await this.clearAccessTokenKey(userId);
|
||||
}
|
||||
|
||||
// Platform doesn't support secure storage, so use state provider implementation
|
||||
// Clear tokens from disk storage (all platforms)
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null, {
|
||||
shouldUpdate: (previousValue) => previousValue !== null,
|
||||
});
|
||||
@@ -478,6 +480,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
return null;
|
||||
}
|
||||
|
||||
// When platformSupportsSecureStorage=true, tokens on disk are encrypted and require
|
||||
// decryption keys from secure storage. When false (e.g., portable builds), tokens are
|
||||
// stored on disk.
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
let accessTokenKey: AccessTokenKey;
|
||||
try {
|
||||
@@ -1118,6 +1123,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
) {
|
||||
return TokenStorageLocation.Memory;
|
||||
} else {
|
||||
// Secure storage (e.g., OS credential manager) is preferred when available.
|
||||
// Desktop portable builds set platformSupportsSecureStorage=false to store tokens
|
||||
// on disk for portability across machines.
|
||||
if (useSecureStorage && this.platformSupportsSecureStorage) {
|
||||
return TokenStorageLocation.SecureStorage;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response";
|
||||
import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart } from "@bitwarden/pricing";
|
||||
import {
|
||||
BitwardenSubscription,
|
||||
Storage,
|
||||
SubscriptionStatus,
|
||||
SubscriptionStatuses,
|
||||
} from "@bitwarden/subscription";
|
||||
|
||||
export class BitwardenSubscriptionResponse extends BaseResponse {
|
||||
status: SubscriptionStatus;
|
||||
cart: Cart;
|
||||
storage: Storage;
|
||||
cancelAt?: Date;
|
||||
canceled?: Date;
|
||||
nextCharge?: Date;
|
||||
suspension?: Date;
|
||||
gracePeriod?: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const status = this.getResponseProperty("Status");
|
||||
if (
|
||||
status !== SubscriptionStatuses.Incomplete &&
|
||||
status !== SubscriptionStatuses.IncompleteExpired &&
|
||||
status !== SubscriptionStatuses.Trialing &&
|
||||
status !== SubscriptionStatuses.Active &&
|
||||
status !== SubscriptionStatuses.PastDue &&
|
||||
status !== SubscriptionStatuses.Canceled &&
|
||||
status !== SubscriptionStatuses.Unpaid
|
||||
) {
|
||||
throw new Error(`Failed to parse invalid subscription status: ${status}`);
|
||||
}
|
||||
this.status = status;
|
||||
|
||||
this.cart = new CartResponse(this.getResponseProperty("Cart"));
|
||||
this.storage = new StorageResponse(this.getResponseProperty("Storage"));
|
||||
|
||||
const suspension = this.getResponseProperty("Suspension");
|
||||
if (suspension) {
|
||||
this.suspension = new Date(suspension);
|
||||
}
|
||||
|
||||
const gracePeriod = this.getResponseProperty("GracePeriod");
|
||||
if (gracePeriod) {
|
||||
this.gracePeriod = gracePeriod;
|
||||
}
|
||||
|
||||
const nextCharge = this.getResponseProperty("NextCharge");
|
||||
if (nextCharge) {
|
||||
this.nextCharge = new Date(nextCharge);
|
||||
}
|
||||
|
||||
const cancelAt = this.getResponseProperty("CancelAt");
|
||||
if (cancelAt) {
|
||||
this.cancelAt = new Date(cancelAt);
|
||||
}
|
||||
|
||||
const canceled = this.getResponseProperty("Canceled");
|
||||
if (canceled) {
|
||||
this.canceled = new Date(canceled);
|
||||
}
|
||||
}
|
||||
|
||||
toDomain = (): BitwardenSubscription => {
|
||||
switch (this.status) {
|
||||
case SubscriptionStatuses.Incomplete:
|
||||
case SubscriptionStatuses.IncompleteExpired:
|
||||
case SubscriptionStatuses.PastDue:
|
||||
case SubscriptionStatuses.Unpaid: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
suspension: this.suspension!,
|
||||
gracePeriod: this.gracePeriod!,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Trialing:
|
||||
case SubscriptionStatuses.Active: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
nextCharge: this.nextCharge!,
|
||||
cancelAt: this.cancelAt,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Canceled: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
canceled: this.canceled!,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart, CartItem, Discount } from "@bitwarden/pricing";
|
||||
|
||||
import { DiscountResponse } from "./discount.response";
|
||||
|
||||
export class CartItemResponse extends BaseResponse implements CartItem {
|
||||
translationKey: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
discount?: Discount;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.translationKey = this.getResponseProperty("TranslationKey");
|
||||
this.quantity = this.getResponseProperty("Quantity");
|
||||
this.cost = this.getResponseProperty("Cost");
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = discount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalStorage = this.getResponseProperty("AdditionalStorage");
|
||||
if (additionalStorage) {
|
||||
this.additionalStorage = new CartItemResponse(additionalStorage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SecretsManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts");
|
||||
if (additionalServiceAccounts) {
|
||||
this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CartResponse extends BaseResponse implements Cart {
|
||||
passwordManager: {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
};
|
||||
secretsManager?: {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
};
|
||||
cadence: SubscriptionCadence;
|
||||
discount?: Discount;
|
||||
estimatedTax: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.passwordManager = new PasswordManagerCartItemResponse(
|
||||
this.getResponseProperty("PasswordManager"),
|
||||
);
|
||||
|
||||
const secretsManager = this.getResponseProperty("SecretsManager");
|
||||
if (secretsManager) {
|
||||
this.secretsManager = new SecretsManagerCartItemResponse(secretsManager);
|
||||
}
|
||||
|
||||
const cadence = this.getResponseProperty("Cadence");
|
||||
if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) {
|
||||
throw new Error(`Failed to parse invalid cadence: ${cadence}`);
|
||||
}
|
||||
this.cadence = cadence;
|
||||
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = new DiscountResponse(discount);
|
||||
}
|
||||
|
||||
this.estimatedTax = this.getResponseProperty("EstimatedTax");
|
||||
}
|
||||
}
|
||||
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing";
|
||||
|
||||
export class DiscountResponse extends BaseResponse implements Discount {
|
||||
type: DiscountType;
|
||||
value: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const type = this.getResponseProperty("Type");
|
||||
if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) {
|
||||
throw new Error(`Failed to parse invalid discount type: ${type}`);
|
||||
}
|
||||
this.type = type;
|
||||
this.value = this.getResponseProperty("Value");
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ export class PremiumPlanResponse extends BaseResponse {
|
||||
seat: {
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
provided: number;
|
||||
};
|
||||
storage: {
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
provided: number;
|
||||
};
|
||||
|
||||
constructor(response: any) {
|
||||
@@ -30,6 +32,7 @@ export class PremiumPlanResponse extends BaseResponse {
|
||||
class PurchasableResponse extends BaseResponse {
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
provided: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -43,5 +46,9 @@ class PurchasableResponse extends BaseResponse {
|
||||
if (typeof this.price !== "number" || isNaN(this.price)) {
|
||||
throw new Error("PurchasableResponse: Missing or invalid 'Price' property");
|
||||
}
|
||||
this.provided = this.getResponseProperty("Provided");
|
||||
if (typeof this.provided !== "number" || isNaN(this.provided)) {
|
||||
throw new Error("PurchasableResponse: Missing or invalid 'Provided' property");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Storage } from "@bitwarden/subscription";
|
||||
|
||||
export class StorageResponse extends BaseResponse implements Storage {
|
||||
available: number;
|
||||
used: number;
|
||||
readableUsed: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.available = this.getResponseProperty("Available");
|
||||
this.used = this.getResponseProperty("Used");
|
||||
this.readableUsed = this.getResponseProperty("ReadableUsed");
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import { PlatformUtilsService } from "../../../platform/abstractions/platform-ut
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response";
|
||||
|
||||
export class OrganizationSponsorshipApiService
|
||||
implements OrganizationSponsorshipApiServiceAbstraction
|
||||
{
|
||||
export class OrganizationSponsorshipApiService implements OrganizationSponsorshipApiServiceAbstraction {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
|
||||
@@ -6,6 +6,10 @@ import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
const mockFamiliesPlan = {
|
||||
type: PlanType.FamiliesAnnually2025,
|
||||
@@ -55,6 +60,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
basePrice: 36,
|
||||
seatPrice: 0,
|
||||
additionalStoragePricePerGb: 4,
|
||||
providedStorageGB: 1,
|
||||
allowSeatAutoscale: false,
|
||||
maxSeats: 6,
|
||||
maxCollections: null,
|
||||
@@ -94,6 +100,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
basePrice: 0,
|
||||
seatPrice: 36,
|
||||
additionalStoragePricePerGb: 4,
|
||||
providedStorageGB: 1,
|
||||
allowSeatAutoscale: true,
|
||||
maxSeats: null,
|
||||
maxCollections: null,
|
||||
@@ -248,7 +255,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
return "Custom";
|
||||
|
||||
// Plan descriptions
|
||||
case "planDescPremium":
|
||||
case "advancedOnlineSecurity":
|
||||
return "Premium plan description";
|
||||
case "planDescFamiliesV2":
|
||||
return "Families plan description";
|
||||
@@ -326,19 +333,32 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const setupEnvironmentService = (
|
||||
envService: MockProxy<EnvironmentService>,
|
||||
region: Region = Region.US,
|
||||
) => {
|
||||
envService.environment$ = of({
|
||||
getRegion: () => region,
|
||||
isCloud: () => region !== Region.SelfHosted,
|
||||
} as any);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
|
||||
setupEnvironmentService(environmentService);
|
||||
|
||||
service = new DefaultSubscriptionPricingService(
|
||||
billingApiService,
|
||||
configService,
|
||||
i18nService,
|
||||
logService,
|
||||
environmentService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -359,6 +379,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
providedStorageGB: 1,
|
||||
features: [
|
||||
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
|
||||
{ key: "secureFileStorage", value: "Secure file storage" },
|
||||
@@ -383,6 +404,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPrice: mockFamiliesPlan.PasswordManager.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockFamiliesPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "premiumAccounts", value: "6 premium accounts" },
|
||||
{ key: "familiesUnlimitedSharing", value: "Unlimited sharing for families" },
|
||||
@@ -393,7 +415,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
|
||||
expect(i18nService.t).toHaveBeenCalledWith("premium");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("planDescPremium");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("advancedOnlineSecurity");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("planNameFamilies");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("planDescFamiliesV2");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("builtInAuthenticator");
|
||||
@@ -415,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -428,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
@@ -456,6 +481,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
|
||||
expect(premiumTier.passwordManager.annualPrice).toEqual(10);
|
||||
expect(premiumTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual(4);
|
||||
expect(premiumTier.passwordManager.providedStorageGB).toEqual(1);
|
||||
|
||||
expect(familiesTier.passwordManager.annualPrice).toEqual(
|
||||
mockFamiliesPlan.PasswordManager.basePrice,
|
||||
@@ -463,6 +489,9 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
expect(familiesTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual(
|
||||
mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
);
|
||||
expect(familiesTier.passwordManager.providedStorageGB).toEqual(
|
||||
mockFamiliesPlan.PasswordManager.baseStorageGb,
|
||||
);
|
||||
|
||||
done();
|
||||
});
|
||||
@@ -487,6 +516,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "secureItemSharing", value: "Secure item sharing" },
|
||||
{ key: "eventLogMonitoring", value: "Event log monitoring" },
|
||||
@@ -522,6 +552,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "enterpriseSecurityPolicies", value: "Enterprise security policies" },
|
||||
{ key: "passwordLessSso", value: "Passwordless SSO" },
|
||||
@@ -595,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -608,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getBusinessSubscriptionPricingTiers$().subscribe({
|
||||
@@ -648,6 +682,9 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
expect(teamsSecretsManager.annualPricePerAdditionalServiceAccount).toEqual(
|
||||
mockTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
);
|
||||
expect(teamsPasswordManager.providedStorageGB).toEqual(
|
||||
mockTeamsPlan.PasswordManager.baseStorageGb,
|
||||
);
|
||||
|
||||
const enterprisePasswordManager = enterpriseTier.passwordManager as any;
|
||||
const enterpriseSecretsManager = enterpriseTier.secretsManager as any;
|
||||
@@ -657,6 +694,9 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
expect(enterprisePasswordManager.annualPricePerAdditionalStorageGB).toEqual(
|
||||
mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
);
|
||||
expect(enterprisePasswordManager.providedStorageGB).toEqual(
|
||||
mockEnterprisePlan.PasswordManager.baseStorageGb,
|
||||
);
|
||||
expect(enterpriseSecretsManager.annualPricePerUser).toEqual(
|
||||
mockEnterprisePlan.SecretsManager.seatPrice,
|
||||
);
|
||||
@@ -729,6 +769,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "secureItemSharing", value: "Secure item sharing" },
|
||||
{ key: "eventLogMonitoring", value: "Event log monitoring" },
|
||||
@@ -764,6 +805,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "enterpriseSecurityPolicies", value: "Enterprise security policies" },
|
||||
{ key: "passwordLessSso", value: "Passwordless SSO" },
|
||||
@@ -830,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -843,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
|
||||
@@ -865,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
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
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
@@ -896,88 +944,6 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed premium plan API response", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
|
||||
|
||||
// Malformed response missing the Seat property
|
||||
const malformedResponse = {
|
||||
Storage: {
|
||||
StripePriceId: "price_storage",
|
||||
Price: 4,
|
||||
},
|
||||
};
|
||||
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
next: () => {
|
||||
fail("Observable should error, not return a value");
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"Failed to load personal subscription pricing tiers",
|
||||
testError,
|
||||
);
|
||||
expect(error).toEqual(testError);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed premium plan with invalid price types", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
|
||||
|
||||
// Malformed response with price as string instead of number
|
||||
const malformedResponse = {
|
||||
Seat: {
|
||||
StripePriceId: "price_seat",
|
||||
Price: "10", // Should be a number
|
||||
},
|
||||
Storage: {
|
||||
StripePriceId: "price_storage",
|
||||
Price: 4,
|
||||
},
|
||||
};
|
||||
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
next: () => {
|
||||
fail("Observable should error, not return a value");
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"Failed to load personal subscription pricing tiers",
|
||||
testError,
|
||||
);
|
||||
expect(error).toEqual(testError);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Observable behavior and caching", () => {
|
||||
@@ -997,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// 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");
|
||||
|
||||
@@ -1010,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe to the premium pricing tier multiple times
|
||||
@@ -1024,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// 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({
|
||||
@@ -1031,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
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(
|
||||
@@ -1038,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe with feature flag disabled
|
||||
@@ -1053,4 +1025,66 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Self-hosted environment behavior", () => {
|
||||
it("should not call API for self-hosted environment", () => {
|
||||
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const selfHostedConfigService = mock<ConfigService>();
|
||||
const selfHostedEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
|
||||
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
selfHostedBillingApiService,
|
||||
selfHostedConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
selfHostedEnvironmentService,
|
||||
);
|
||||
|
||||
// Trigger subscriptions by calling the methods
|
||||
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||
selfHostedService.getBusinessSubscriptionPricingTiers$().subscribe();
|
||||
selfHostedService.getDeveloperSubscriptionPricingTiers$().subscribe();
|
||||
|
||||
// API should not be called for self-hosted environments
|
||||
expect(getPlansSpy).not.toHaveBeenCalled();
|
||||
expect(getPremiumPlanSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return valid tier structure with undefined prices for self-hosted", (done) => {
|
||||
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const selfHostedConfigService = mock<ConfigService>();
|
||||
const selfHostedEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
selfHostedBillingApiService,
|
||||
selfHostedConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
selfHostedEnvironmentService,
|
||||
);
|
||||
|
||||
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
|
||||
expect(tiers).toHaveLength(2); // Premium and Families
|
||||
|
||||
const premiumTier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
|
||||
expect(premiumTier).toBeDefined();
|
||||
expect(premiumTier?.passwordManager.annualPrice).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.annualPricePerAdditionalStorageGB).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.providedStorageGB).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.features).toBeDefined();
|
||||
expect(premiumTier?.passwordManager.features.length).toBeGreaterThan(0);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -40,17 +41,20 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
*/
|
||||
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,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* Pricing information will be undefined if current environment is self-hosted.
|
||||
* @returns An observable of an array of personal subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@@ -65,6 +69,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
/**
|
||||
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* Pricing information will be undefined if current environment is self-hosted.
|
||||
* @returns An observable of an array of business subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@@ -79,6 +84,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
/**
|
||||
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* Pricing information will be undefined if current environment is self-hosted.
|
||||
* @returns An observable of an array of business subscription pricing tiers for developers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@@ -90,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
}),
|
||||
);
|
||||
|
||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||
this.billingApiService.getPlans(),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
|
||||
this.environmentService.environment$.pipe(
|
||||
take(1),
|
||||
switchMap((environment) =>
|
||||
!environment.isCloud()
|
||||
? of({ data: [] } as unknown as ListResponse<PlanResponse>)
|
||||
: from(this.billingApiService.getPlans()),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
|
||||
this.billingApiService.getPremiumPlan(),
|
||||
).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to fetch premium plan from API", error);
|
||||
return throwError(() => error); // Re-throw to propagate to higher-level error handler
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
private premiumPlanResponse$: Observable<PremiumPlanResponse> =
|
||||
this.environmentService.environment$.pipe(
|
||||
take(1),
|
||||
switchMap((environment) =>
|
||||
!environment.isCloud()
|
||||
? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
|
||||
: from(this.billingApiService.getPremiumPlan()).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to fetch premium plan from API", error);
|
||||
return throwError(() => error); // Re-throw to propagate to higher-level error handler
|
||||
}),
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
||||
@@ -112,24 +131,27 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
fetchPremiumFromPricingService
|
||||
? this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat.price,
|
||||
storage: premiumPlan.storage.price,
|
||||
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("planDescPremium"),
|
||||
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(),
|
||||
@@ -141,40 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
})),
|
||||
);
|
||||
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
||||
map(([plans, milestone3FeatureEnabled]) => {
|
||||
const familiesPlan = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
||||
)!;
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
||||
map(([plans, milestone3FeatureEnabled]) => {
|
||||
const familiesPlan = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
||||
);
|
||||
|
||||
return {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: this.i18nService.t("planNameFamilies"),
|
||||
description: this.i18nService.t("planDescFamiliesV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: familiesPlan.PasswordManager.baseSeats,
|
||||
annualPrice: familiesPlan.PasswordManager.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: this.i18nService.t("planNameFamilies"),
|
||||
description: this.i18nService.t("planDescFamiliesV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: familiesPlan?.PasswordManager?.baseSeats,
|
||||
annualPrice: familiesPlan?.PasswordManager?.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
|
||||
map((plans): BusinessSubscriptionPricingTier => {
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Free,
|
||||
@@ -184,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
passwordManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
|
||||
this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(
|
||||
freePlan?.PasswordManager?.maxCollections,
|
||||
),
|
||||
this.featureTranslations.alwaysFree(),
|
||||
],
|
||||
},
|
||||
@@ -193,108 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.twoSecretsIncluded(),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan?.SecretsManager?.maxProjects),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
|
||||
private teams$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
description: this.i18nService.t("teamsPlanUpgradeMessage"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
this.featureTranslations.directoryIntegration(),
|
||||
this.featureTranslations.scimSupport(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedSecretsAndProjects(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualTeamsPlan.SecretsManager.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualEnterprisePlan = plans.data.find(
|
||||
(plan) => plan.type === PlanType.EnterpriseAnnually,
|
||||
)!;
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Enterprise,
|
||||
name: this.i18nService.t("planNameEnterprise"),
|
||||
description: this.i18nService.t("planDescEnterpriseV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
features: [
|
||||
this.featureTranslations.enterpriseSecurityPolicies(),
|
||||
this.featureTranslations.passwordLessSso(),
|
||||
this.featureTranslations.accountRecovery(),
|
||||
this.featureTranslations.selfHostOption(),
|
||||
this.featureTranslations.complimentaryFamiliesPlan(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedUsers(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualEnterprisePlan.SecretsManager.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map(
|
||||
(): BusinessSubscriptionPricingTier => ({
|
||||
id: BusinessSubscriptionPricingTierIds.Custom,
|
||||
name: this.i18nService.t("planNameCustom"),
|
||||
description: this.i18nService.t("planDescCustom"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "custom",
|
||||
features: [
|
||||
this.featureTranslations.strengthenCybersecurity(),
|
||||
this.featureTranslations.boostProductivity(),
|
||||
this.featureTranslations.seamlessIntegration(),
|
||||
],
|
||||
},
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
description: this.i18nService.t("teamsPlanUpgradeMessage"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan?.PasswordManager?.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
this.featureTranslations.directoryIntegration(),
|
||||
this.featureTranslations.scimSupport(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan?.SecretsManager?.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualTeamsPlan?.SecretsManager?.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedSecretsAndProjects(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualTeamsPlan?.SecretsManager?.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
private enterprise$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualEnterprisePlan = plans.data.find(
|
||||
(plan) => plan.type === PlanType.EnterpriseAnnually,
|
||||
);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Enterprise,
|
||||
name: this.i18nService.t("planNameEnterprise"),
|
||||
description: this.i18nService.t("planDescEnterpriseV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan?.PasswordManager?.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualEnterprisePlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualEnterprisePlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.enterpriseSecurityPolicies(),
|
||||
this.featureTranslations.passwordLessSso(),
|
||||
this.featureTranslations.accountRecovery(),
|
||||
this.featureTranslations.selfHostOption(),
|
||||
this.featureTranslations.complimentaryFamiliesPlan(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan?.SecretsManager?.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualEnterprisePlan?.SecretsManager?.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedUsers(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualEnterprisePlan?.SecretsManager?.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private custom$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map(
|
||||
(): BusinessSubscriptionPricingTier => ({
|
||||
id: BusinessSubscriptionPricingTierIds.Custom,
|
||||
name: this.i18nService.t("planNameCustom"),
|
||||
description: this.i18nService.t("planDescCustom"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "custom",
|
||||
features: [
|
||||
this.featureTranslations.strengthenCybersecurity(),
|
||||
this.featureTranslations.boostProductivity(),
|
||||
this.featureTranslations.seamlessIntegration(),
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
private featureTranslations = {
|
||||
builtInAuthenticator: () => ({
|
||||
@@ -333,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "familiesSharedStorage",
|
||||
value: this.i18nService.t("familiesSharedStorage"),
|
||||
}),
|
||||
limitedUsersV2: (users: number) => ({
|
||||
limitedUsersV2: (users?: number) => ({
|
||||
key: "limitedUsersV2",
|
||||
value: this.i18nService.t("limitedUsersV2", users),
|
||||
}),
|
||||
limitedCollectionsV2: (collections: number) => ({
|
||||
limitedCollectionsV2: (collections?: number) => ({
|
||||
key: "limitedCollectionsV2",
|
||||
value: this.i18nService.t("limitedCollectionsV2", collections),
|
||||
}),
|
||||
@@ -349,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "twoSecretsIncluded",
|
||||
value: this.i18nService.t("twoSecretsIncluded"),
|
||||
}),
|
||||
projectsIncludedV2: (projects: number) => ({
|
||||
projectsIncludedV2: (projects?: number) => ({
|
||||
key: "projectsIncludedV2",
|
||||
value: this.i18nService.t("projectsIncludedV2", projects),
|
||||
}),
|
||||
@@ -373,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "unlimitedSecretsAndProjects",
|
||||
value: this.i18nService.t("unlimitedSecretsAndProjects"),
|
||||
}),
|
||||
includedMachineAccountsV2: (included: number) => ({
|
||||
includedMachineAccountsV2: (included?: number) => ({
|
||||
key: "includedMachineAccountsV2",
|
||||
value: this.i18nService.t("includedMachineAccountsV2", included),
|
||||
}),
|
||||
|
||||
@@ -27,20 +27,26 @@ type HasFeatures = {
|
||||
};
|
||||
|
||||
type HasAdditionalStorage = {
|
||||
annualPricePerAdditionalStorageGB: number;
|
||||
annualPricePerAdditionalStorageGB?: number;
|
||||
};
|
||||
|
||||
type HasProvidedStorage = {
|
||||
providedStorageGB?: number;
|
||||
};
|
||||
|
||||
type StandalonePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
HasAdditionalStorage &
|
||||
HasProvidedStorage & {
|
||||
type: "standalone";
|
||||
annualPrice: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type PackagedPasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "packaged";
|
||||
users: number;
|
||||
annualPrice: number;
|
||||
users?: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type FreePasswordManager = HasFeatures & {
|
||||
@@ -52,9 +58,10 @@ type CustomPasswordManager = HasFeatures & {
|
||||
};
|
||||
|
||||
type ScalablePasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerUser?: number;
|
||||
};
|
||||
|
||||
type FreeSecretsManager = HasFeatures & {
|
||||
@@ -63,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
|
||||
|
||||
type ScalableSecretsManager = HasFeatures & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerAdditionalServiceAccount: number;
|
||||
annualPricePerUser?: number;
|
||||
annualPricePerAdditionalServiceAccount?: number;
|
||||
};
|
||||
|
||||
export type PersonalSubscriptionPricingTier = {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
/**
|
||||
* Abstraction for phishing detection settings
|
||||
*/
|
||||
export abstract class PhishingDetectionSettingsServiceAbstraction {
|
||||
/**
|
||||
* An observable for whether phishing detection is available for the active user account.
|
||||
*
|
||||
* Access is granted only when the PhishingDetection feature flag is enabled and
|
||||
* at least one of the following is true for the active account:
|
||||
* - the user has a personal premium subscription
|
||||
* - the user is a member of a Family org (ProductTierType.Families)
|
||||
* - the user is a member of an Enterprise org with `usePhishingBlocker` enabled
|
||||
*
|
||||
* Note: Non-specified organization types (e.g., Team orgs) do not grant access.
|
||||
*/
|
||||
abstract readonly available$: Observable<boolean>;
|
||||
/**
|
||||
* An observable for whether phishing detection is on for the active user account
|
||||
*
|
||||
* This is true when {@link available$} is true and when {@link enabled$} is true
|
||||
*/
|
||||
abstract readonly on$: Observable<boolean>;
|
||||
/**
|
||||
* An observable for whether phishing detection is enabled
|
||||
*/
|
||||
abstract readonly enabled$: Observable<boolean>;
|
||||
/**
|
||||
* Sets whether phishing detection is enabled
|
||||
*
|
||||
* @param enabled True to enable, false to disable
|
||||
*/
|
||||
abstract setEnabled: (userId: UserId, enabled: boolean) => Promise<void>;
|
||||
}
|
||||
@@ -35,5 +35,26 @@ describe("HibpApiService", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(BreachAccountResponse);
|
||||
});
|
||||
|
||||
it("should return empty array when no breaches found (REST semantics)", async () => {
|
||||
// Server now returns 200 OK with empty array [] instead of 404
|
||||
const mockResponse: any[] = [];
|
||||
const username = "safe@example.com";
|
||||
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sut.getHibpBreach(username);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/hibp/breach?username=" + encodeURIComponent(username),
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toBeInstanceOf(Array);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { UserId } from "../../../types/guid";
|
||||
|
||||
import { PhishingDetectionSettingsService } from "./phishing-detection-settings.service";
|
||||
|
||||
describe("PhishingDetectionSettingsService", () => {
|
||||
// Mock services
|
||||
let mockAccountService: MockProxy<AccountService>;
|
||||
let mockBillingService: MockProxy<BillingAccountProfileStateService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockPlatformService: MockProxy<PlatformUtilsService>;
|
||||
|
||||
// RxJS Subjects we control in the tests
|
||||
let activeAccountSubject: BehaviorSubject<Account | null>;
|
||||
let featureFlagSubject: BehaviorSubject<boolean>;
|
||||
let premiumStatusSubject: BehaviorSubject<boolean>;
|
||||
let organizationsSubject: BehaviorSubject<Organization[]>;
|
||||
|
||||
let service: PhishingDetectionSettingsService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
// Constant mock data
|
||||
const familyOrg = mock<Organization>({
|
||||
canAccess: true,
|
||||
isMember: true,
|
||||
usersGetPremium: true,
|
||||
productTierType: ProductTierType.Families,
|
||||
usePhishingBlocker: true,
|
||||
});
|
||||
const teamOrg = mock<Organization>({
|
||||
canAccess: true,
|
||||
isMember: true,
|
||||
usersGetPremium: true,
|
||||
productTierType: ProductTierType.Teams,
|
||||
usePhishingBlocker: true,
|
||||
});
|
||||
const enterpriseOrg = mock<Organization>({
|
||||
canAccess: true,
|
||||
isMember: true,
|
||||
usersGetPremium: true,
|
||||
productTierType: ProductTierType.Enterprise,
|
||||
usePhishingBlocker: true,
|
||||
});
|
||||
|
||||
const mockLogService = mock<LogService>();
|
||||
|
||||
const mockUserId = "mock-user-id" as UserId;
|
||||
const account = mock<Account>({ id: mockUserId });
|
||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
|
||||
beforeEach(() => {
|
||||
// Initialize subjects
|
||||
activeAccountSubject = new BehaviorSubject<Account | null>(null);
|
||||
featureFlagSubject = new BehaviorSubject<boolean>(false);
|
||||
premiumStatusSubject = new BehaviorSubject<boolean>(false);
|
||||
organizationsSubject = new BehaviorSubject<Organization[]>([]);
|
||||
|
||||
// Default implementations for required functions
|
||||
mockAccountService = mock<AccountService>();
|
||||
mockAccountService.activeAccount$ = activeAccountSubject.asObservable();
|
||||
|
||||
mockBillingService = mock<BillingAccountProfileStateService>();
|
||||
mockBillingService.hasPremiumPersonally$.mockReturnValue(premiumStatusSubject.asObservable());
|
||||
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
|
||||
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockOrganizationService.organizations$.mockReturnValue(organizationsSubject.asObservable());
|
||||
|
||||
mockPlatformService = mock<PlatformUtilsService>();
|
||||
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
service = new PhishingDetectionSettingsService(
|
||||
mockAccountService,
|
||||
mockBillingService,
|
||||
mockConfigService,
|
||||
mockLogService,
|
||||
mockOrganizationService,
|
||||
mockPlatformService,
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
// Helper to easily get the result of the observable we are testing
|
||||
const getAccess = () => firstValueFrom(service.available$);
|
||||
|
||||
describe("enabled$", () => {
|
||||
it("should default to true if an account is logged in", async () => {
|
||||
activeAccountSubject.next(account);
|
||||
const result = await firstValueFrom(service.enabled$);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return the stored value", async () => {
|
||||
activeAccountSubject.next(account);
|
||||
|
||||
await service.setEnabled(mockUserId, false);
|
||||
const resultDisabled = await firstValueFrom(service.enabled$);
|
||||
expect(resultDisabled).toBe(false);
|
||||
|
||||
await service.setEnabled(mockUserId, true);
|
||||
const resultEnabled = await firstValueFrom(service.enabled$);
|
||||
expect(resultEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setEnabled", () => {
|
||||
it("should update the stored value", async () => {
|
||||
activeAccountSubject.next(account);
|
||||
await service.setEnabled(mockUserId, false);
|
||||
let result = await firstValueFrom(service.enabled$);
|
||||
expect(result).toBe(false);
|
||||
|
||||
await service.setEnabled(mockUserId, true);
|
||||
result = await firstValueFrom(service.enabled$);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false immediately when the feature flag is disabled, regardless of other conditions", async () => {
|
||||
activeAccountSubject.next(account);
|
||||
premiumStatusSubject.next(true);
|
||||
organizationsSubject.next([familyOrg]);
|
||||
|
||||
featureFlagSubject.next(false);
|
||||
|
||||
await expect(getAccess()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if there is no active account present yet", async () => {
|
||||
activeAccountSubject.next(null); // No active account
|
||||
featureFlagSubject.next(true); // Flag is on
|
||||
|
||||
await expect(getAccess()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when feature flag is enabled and user has premium personally", async () => {
|
||||
activeAccountSubject.next(account);
|
||||
featureFlagSubject.next(true);
|
||||
organizationsSubject.next([]);
|
||||
premiumStatusSubject.next(true);
|
||||
|
||||
await expect(getAccess()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when feature flag is enabled and user is in a Family Organization", async () => {
|
||||
activeAccountSubject.next(account);
|
||||
featureFlagSubject.next(true);
|
||||
premiumStatusSubject.next(false); // User has no personal premium
|
||||
|
||||
organizationsSubject.next([familyOrg]);
|
||||
|
||||
await expect(getAccess()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when feature flag is enabled and user is in an Enterprise org with phishing blocker enabled", async () => {
|
||||
activeAccountSubject.next(account);
|
||||
featureFlagSubject.next(true);
|
||||
premiumStatusSubject.next(false);
|
||||
organizationsSubject.next([enterpriseOrg]);
|
||||
|
||||
await expect(getAccess()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when user has no access through personal premium or organizations", async () => {
|
||||
activeAccountSubject.next(account);
|
||||
featureFlagSubject.next(true);
|
||||
premiumStatusSubject.next(false);
|
||||
organizationsSubject.next([teamOrg]); // Team org does not give access
|
||||
|
||||
await expect(getAccess()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("shares/caches the available$ result between multiple subscribers", async () => {
|
||||
// Use a plain Subject for this test so we control when the premium observable emits
|
||||
// and avoid the BehaviorSubject's initial emission which can race with subscriptions.
|
||||
// Provide the Subject directly as the mock return value for the billing service
|
||||
const oneTimePremium = new Subject<boolean>();
|
||||
mockBillingService.hasPremiumPersonally$.mockReturnValueOnce(oneTimePremium.asObservable());
|
||||
|
||||
activeAccountSubject.next(account);
|
||||
featureFlagSubject.next(true);
|
||||
organizationsSubject.next([]);
|
||||
|
||||
const p1 = firstValueFrom(service.available$);
|
||||
const p2 = firstValueFrom(service.available$);
|
||||
|
||||
// Trigger the pipeline
|
||||
oneTimePremium.next(true);
|
||||
|
||||
const [first, second] = await Promise.all([p1, p2]);
|
||||
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(true);
|
||||
// The billing function should have been called at most once due to caching
|
||||
expect(mockBillingService.hasPremiumPersonally$).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { combineLatest, Observable, of, switchMap } from "rxjs";
|
||||
import { catchError, distinctUntilChanged, map, shareReplay, tap } from "rxjs/operators";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
import { PhishingDetectionSettingsServiceAbstraction } from "../abstractions/phishing-detection-settings.service.abstraction";
|
||||
|
||||
const ENABLE_PHISHING_DETECTION = new UserKeyDefinition(
|
||||
PHISHING_DETECTION_DISK,
|
||||
"enablePhishingDetection",
|
||||
{
|
||||
deserializer: (value: boolean) => value ?? true, // Default: enabled
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
export class PhishingDetectionSettingsService implements PhishingDetectionSettingsServiceAbstraction {
|
||||
readonly available$: Observable<boolean>;
|
||||
readonly enabled$: Observable<boolean>;
|
||||
readonly on$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private billingService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
private logService: LogService,
|
||||
private organizationService: OrganizationService,
|
||||
private platformService: PlatformUtilsService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.logService.debug(`[PhishingDetectionSettingsService] Initializing service...`);
|
||||
this.available$ = this.buildAvailablePipeline$().pipe(
|
||||
distinctUntilChanged(),
|
||||
tap((available) =>
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Phishing detection available: ${available}`,
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
this.enabled$ = this.buildEnabledPipeline$().pipe(
|
||||
distinctUntilChanged(),
|
||||
tap((enabled) =>
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Phishing detection enabled: ${{ enabled }}`,
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
this.on$ = combineLatest([this.available$, this.enabled$]).pipe(
|
||||
map(([available, enabled]) => available && enabled),
|
||||
distinctUntilChanged(),
|
||||
tap((on) =>
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Phishing detection is on: ${{ on }}`,
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
}
|
||||
|
||||
async setEnabled(userId: UserId, enabled: boolean): Promise<void> {
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Setting phishing detection enabled: ${{ enabled, userId }}`,
|
||||
);
|
||||
await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the observable pipeline to determine if phishing detection is available to the user
|
||||
*
|
||||
* @returns An observable pipeline that determines if phishing detection is available
|
||||
*/
|
||||
private buildAvailablePipeline$(): Observable<boolean> {
|
||||
// Phishing detection is unavailable on Safari due to platform limitations.
|
||||
if (this.platformService.isSafari()) {
|
||||
this.logService.warning(
|
||||
`[PhishingDetectionSettingsService] Phishing detection is unavailable on Safari due to platform limitations`,
|
||||
);
|
||||
return of(false);
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.accountService.activeAccount$,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
|
||||
]).pipe(
|
||||
switchMap(([account, featureEnabled]) => {
|
||||
if (!account || !featureEnabled) {
|
||||
return of(false);
|
||||
}
|
||||
return combineLatest([
|
||||
this.billingService.hasPremiumPersonally$(account.id).pipe(catchError(() => of(false))),
|
||||
this.organizationService.organizations$(account.id).pipe(catchError(() => of([]))),
|
||||
]).pipe(
|
||||
map(([hasPremium, organizations]) => hasPremium || this.orgGrantsAccess(organizations)),
|
||||
catchError(() => of(false)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the observable pipeline to determine if phishing detection is enabled by the user
|
||||
*
|
||||
* @returns True if phishing detection is enabled for the active user
|
||||
*/
|
||||
private buildEnabledPipeline$(): Observable<boolean> {
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) => {
|
||||
if (!account) {
|
||||
return of(false);
|
||||
}
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Refreshing phishing detection enabled state`,
|
||||
);
|
||||
return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id);
|
||||
}),
|
||||
map((enabled) => enabled ?? true),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if any of the user's organizations grant access to phishing detection
|
||||
*
|
||||
* @param organizations The organizations the user is a member of
|
||||
* @returns True if any organization grants access to phishing detection
|
||||
*/
|
||||
private orgGrantsAccess(organizations: Organization[]): boolean {
|
||||
return organizations.some((org) => {
|
||||
if (!org.canAccess || !org.isMember || !org.usersGetPremium) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
org.productTierType === ProductTierType.Families ||
|
||||
(org.productTierType === ProductTierType.Enterprise && org.usePhishingBlocker)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,8 @@ export enum EventType {
|
||||
Organization_CollectionManagement_LimitItemDeletionDisabled = 1615,
|
||||
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616,
|
||||
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617,
|
||||
Organization_ItemOrganization_Accepted = 1618,
|
||||
Organization_ItemOrganization_Declined = 1619,
|
||||
|
||||
Policy_Updated = 1700,
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
CreateDefaultLocation = "pm-19467-create-default-location",
|
||||
AutoConfirm = "pm-19934-auto-confirm-organization-users",
|
||||
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
|
||||
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
|
||||
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
|
||||
|
||||
/* Auth */
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
@@ -21,59 +22,68 @@ export enum FeatureFlag {
|
||||
/* Autofill */
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
WindowsDesktopAutotype = "windows-desktop-autotype",
|
||||
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
|
||||
SSHAgentV2 = "ssh-agent-v2",
|
||||
|
||||
/* Billing */
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
|
||||
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
|
||||
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
|
||||
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
|
||||
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",
|
||||
PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page",
|
||||
PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
|
||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
PasskeyUnlock = "pm-2035-passkey-unlock",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
|
||||
EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration",
|
||||
EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration",
|
||||
|
||||
/* Tools */
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
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",
|
||||
|
||||
/* DIRT */
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
EventManagementForHuntress = "event-management-for-huntress",
|
||||
PhishingDetection = "phishing-detection",
|
||||
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
|
||||
|
||||
/* Vault */
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
|
||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
|
||||
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
|
||||
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users",
|
||||
PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked",
|
||||
|
||||
/* Innovation */
|
||||
PM19148_InnovationArchive = "pm-19148-innovation-archive",
|
||||
|
||||
/* Desktop */
|
||||
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
|
||||
DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2",
|
||||
|
||||
/* UIF */
|
||||
RouterFocusManagement = "router-focus-management",
|
||||
|
||||
/* Secrets Manager */
|
||||
SM1719_RemoveSecretsManagerAds = "sm-1719-remove-secrets-manager-ads",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -91,69 +101,79 @@ const FALSE = false as boolean;
|
||||
*/
|
||||
export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.CreateDefaultLocation]: FALSE,
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
|
||||
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
|
||||
[FeatureFlag.MembersComponentRefactor]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
|
||||
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
|
||||
[FeatureFlag.SSHAgentV2]: FALSE,
|
||||
|
||||
/* Tools */
|
||||
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
|
||||
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
|
||||
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
|
||||
[FeatureFlag.SendUIRefresh]: FALSE,
|
||||
[FeatureFlag.SendEmailOTP]: FALSE,
|
||||
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
[FeatureFlag.EventManagementForHuntress]: FALSE,
|
||||
[FeatureFlag.PhishingDetection]: FALSE,
|
||||
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||
[FeatureFlag.AutofillConfirmation]: FALSE,
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
|
||||
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
|
||||
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
|
||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
[FeatureFlag.PM23341_Milestone_2]: FALSE,
|
||||
[FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE,
|
||||
[FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.PasskeyUnlock]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
|
||||
[FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE,
|
||||
[FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||
[FeatureFlag.InactiveUserServerNotification]: FALSE,
|
||||
[FeatureFlag.PushNotificationsWhenLocked]: FALSE,
|
||||
|
||||
/* Innovation */
|
||||
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
|
||||
|
||||
/* Desktop */
|
||||
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
|
||||
[FeatureFlag.DesktopUiMigrationMilestone2]: FALSE,
|
||||
|
||||
/* UIF */
|
||||
[FeatureFlag.RouterFocusManagement]: FALSE,
|
||||
|
||||
/* Secrets Manager */
|
||||
[FeatureFlag.SM1719_RemoveSecretsManagerAds]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
@@ -33,4 +33,6 @@ export enum NotificationType {
|
||||
|
||||
OrganizationBankAccountVerified = 23,
|
||||
ProviderBankAccountVerified = 24,
|
||||
|
||||
SyncPolicy = 25,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export abstract class AccountCryptographicStateService {
|
||||
/**
|
||||
* Emits the provided user's account cryptographic state or null if there is no account cryptographic state present for the user.
|
||||
*/
|
||||
abstract accountCryptographicState$(
|
||||
userId: UserId,
|
||||
): Observable<WrappedAccountCryptographicState | null>;
|
||||
|
||||
/**
|
||||
* Sets the account cryptographic state.
|
||||
* This is not yet validated, and is only validated upon SDK initialization.
|
||||
*/
|
||||
abstract setAccountCryptographicState(
|
||||
accountCryptographicState: WrappedAccountCryptographicState,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
|
||||
import { FakeStateProvider } from "@bitwarden/state-test-utils";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
|
||||
|
||||
import {
|
||||
ACCOUNT_CRYPTOGRAPHIC_STATE,
|
||||
DefaultAccountCryptographicStateService,
|
||||
} from "./default-account-cryptographic-state.service";
|
||||
|
||||
describe("DefaultAccountCryptographicStateService", () => {
|
||||
let service: DefaultAccountCryptographicStateService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
const mockUserId = "user-id" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
service = new DefaultAccountCryptographicStateService(stateProvider);
|
||||
});
|
||||
|
||||
describe("accountCryptographicState$", () => {
|
||||
it("returns null when no state is set", async () => {
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the account cryptographic state when set (V1)", async () => {
|
||||
const mockState: WrappedAccountCryptographicState = {
|
||||
V1: {
|
||||
private_key: "test-wrapped-state" as any,
|
||||
},
|
||||
};
|
||||
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState, mockUserId);
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
expect(result).toEqual(mockState);
|
||||
});
|
||||
|
||||
it("returns the account cryptographic state when set (V2)", async () => {
|
||||
const mockState: WrappedAccountCryptographicState = {
|
||||
V2: {
|
||||
private_key: "test-wrapped-private-key" as any,
|
||||
signing_key: "test-wrapped-signing-key" as any,
|
||||
signed_public_key: "test-signed-public-key" as any,
|
||||
security_state: "test-security-state",
|
||||
},
|
||||
};
|
||||
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState, mockUserId);
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
expect(result).toEqual(mockState);
|
||||
});
|
||||
|
||||
it("emits updated state when state changes", async () => {
|
||||
const mockState1: any = {
|
||||
V1: {
|
||||
private_key: "test-state-1" as any,
|
||||
},
|
||||
};
|
||||
const mockState2: any = {
|
||||
V1: {
|
||||
private_key: "test-state-2" as any,
|
||||
},
|
||||
};
|
||||
|
||||
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState1, mockUserId);
|
||||
|
||||
const observable = service.accountCryptographicState$(mockUserId);
|
||||
const results: (WrappedAccountCryptographicState | null)[] = [];
|
||||
const subscription = observable.subscribe((state) => results.push(state));
|
||||
|
||||
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState2, mockUserId);
|
||||
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual(mockState1);
|
||||
expect(results[1]).toEqual(mockState2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAccountCryptographicState", () => {
|
||||
it("sets the account cryptographic state", async () => {
|
||||
const mockState: WrappedAccountCryptographicState = {
|
||||
V1: {
|
||||
private_key: "test-wrapped-state" as any,
|
||||
},
|
||||
};
|
||||
|
||||
await service.setAccountCryptographicState(mockState, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockState);
|
||||
});
|
||||
|
||||
it("overwrites existing state", async () => {
|
||||
const mockState1: WrappedAccountCryptographicState = {
|
||||
V1: {
|
||||
private_key: "test-state-1" as any,
|
||||
},
|
||||
};
|
||||
const mockState2: WrappedAccountCryptographicState = {
|
||||
V1: {
|
||||
private_key: "test-state-2" as any,
|
||||
},
|
||||
};
|
||||
|
||||
await service.setAccountCryptographicState(mockState1, mockUserId);
|
||||
await service.setAccountCryptographicState(mockState2, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockState2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACCOUNT_CRYPTOGRAPHIC_STATE key definition", () => {
|
||||
it("deserializer returns object as-is", () => {
|
||||
const mockState: any = {
|
||||
V1: {
|
||||
private_key: "test" as any,
|
||||
},
|
||||
};
|
||||
const result = ACCOUNT_CRYPTOGRAPHIC_STATE.deserializer(mockState);
|
||||
expect(result).toBe(mockState);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
|
||||
import { CRYPTO_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AccountCryptographicStateService } from "./account-cryptographic-state.service";
|
||||
|
||||
export const ACCOUNT_CRYPTOGRAPHIC_STATE = new UserKeyDefinition<WrappedAccountCryptographicState>(
|
||||
CRYPTO_DISK,
|
||||
"accountCryptographicState",
|
||||
{
|
||||
deserializer: (obj) => obj as WrappedAccountCryptographicState,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export class DefaultAccountCryptographicStateService implements AccountCryptographicStateService {
|
||||
constructor(protected stateProvider: StateProvider) {}
|
||||
|
||||
accountCryptographicState$(userId: UserId): Observable<WrappedAccountCryptographicState | null> {
|
||||
return this.stateProvider.getUserState$(ACCOUNT_CRYPTOGRAPHIC_STATE, userId);
|
||||
}
|
||||
|
||||
async setAccountCryptographicState(
|
||||
accountCryptographicState: WrappedAccountCryptographicState,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
await this.stateProvider.setUserState(
|
||||
ACCOUNT_CRYPTOGRAPHIC_STATE,
|
||||
accountCryptographicState,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export abstract class CryptoFunctionService {
|
||||
abstract rsaEncrypt(
|
||||
data: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
algorithm: "sha1",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
@@ -100,10 +100,10 @@ export abstract class CryptoFunctionService {
|
||||
abstract rsaDecrypt(
|
||||
data: Uint8Array,
|
||||
privateKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
algorithm: "sha1",
|
||||
): Promise<Uint8Array>;
|
||||
abstract rsaExtractPublicKey(privateKey: Uint8Array): Promise<Uint8Array>;
|
||||
abstract rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]>;
|
||||
abstract rsaGenerateKeyPair(length: 2048): Promise<[Uint8Array, Uint8Array]>;
|
||||
/**
|
||||
* Generates a key of the given length suitable for use in AES encryption
|
||||
*/
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { EncString } from "../models/enc-string";
|
||||
|
||||
export abstract class EncryptService {
|
||||
/**
|
||||
* A temporary init method to make the encrypt service listen to feature-flag changes.
|
||||
* This will be removed once the feature flag has been rolled out.
|
||||
*/
|
||||
abstract init(configService: ConfigService): void;
|
||||
|
||||
/**
|
||||
* Encrypts a string to an EncString
|
||||
* @param plainValue - The value to encrypt
|
||||
|
||||
@@ -30,7 +30,7 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
|
||||
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> {
|
||||
if (salt == null) {
|
||||
const bytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
salt = Utils.fromBufferToUtf8(bytes);
|
||||
salt = Utils.fromBufferToUtf8(bytes.buffer as ArrayBuffer);
|
||||
}
|
||||
const material = await this.cryptoFunctionService.aesGenerateKey(bitLength);
|
||||
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
|
||||
@@ -91,4 +91,12 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
|
||||
|
||||
return new SymmetricCryptoKey(newKey);
|
||||
}
|
||||
|
||||
async deriveVaultExportKey(
|
||||
password: string,
|
||||
salt: string,
|
||||
kdfConfig: KdfConfig,
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
return await this.stretchKey(await this.deriveKeyFromPassword(password, salt, kdfConfig));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,4 +87,19 @@ export abstract class KeyGenerationService {
|
||||
* @returns 64 byte derived key.
|
||||
*/
|
||||
abstract stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey>;
|
||||
|
||||
/**
|
||||
* Derives a 64 byte key for encrypting and decrypting vault exports.
|
||||
*
|
||||
* @deprecated Do not use this for new use-cases.
|
||||
* @param password Password to derive the key from.
|
||||
* @param salt Salt for the key derivation function.
|
||||
* @param kdfConfig Configuration for the key derivation function.
|
||||
* @returns 64 byte derived key.
|
||||
*/
|
||||
abstract deriveVaultExportKey(
|
||||
password: string,
|
||||
salt: string,
|
||||
kdfConfig: KdfConfig,
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
@@ -15,28 +13,12 @@ import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
export class EncryptServiceImplementation implements EncryptService {
|
||||
private disableType0Decryption = false;
|
||||
|
||||
constructor(
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected logService: LogService,
|
||||
protected logMacFailures: boolean,
|
||||
) {}
|
||||
|
||||
init(configService: ConfigService): void {
|
||||
configService.serverConfig$.subscribe((newConfig) => {
|
||||
if (newConfig != null) {
|
||||
this.setDisableType0Decryption(
|
||||
newConfig.featureStates[FeatureFlag.PM25174_DisableType0Decryption] === true,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setDisableType0Decryption(disable: boolean): void {
|
||||
this.disableType0Decryption = disable;
|
||||
}
|
||||
|
||||
async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString> {
|
||||
if (plainValue == null) {
|
||||
this.logService.warning(
|
||||
@@ -60,7 +42,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise<string> {
|
||||
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
|
||||
}
|
||||
await SdkLoadService.Ready;
|
||||
@@ -68,7 +50,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise<Uint8Array> {
|
||||
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
|
||||
}
|
||||
await SdkLoadService.Ready;
|
||||
@@ -76,7 +58,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<Uint8Array> {
|
||||
if (this.disableType0Decryption && encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
if (encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
|
||||
}
|
||||
await SdkLoadService.Ready;
|
||||
@@ -148,10 +130,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No wrappingKey provided for unwrapping.");
|
||||
}
|
||||
|
||||
if (
|
||||
this.disableType0Decryption &&
|
||||
wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
|
||||
) {
|
||||
if (wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
|
||||
}
|
||||
|
||||
@@ -171,10 +150,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for unwrapping.");
|
||||
}
|
||||
if (
|
||||
this.disableType0Decryption &&
|
||||
wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
|
||||
) {
|
||||
if (wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
|
||||
}
|
||||
|
||||
@@ -194,10 +170,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for unwrapping.");
|
||||
}
|
||||
if (
|
||||
this.disableType0Decryption &&
|
||||
keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64
|
||||
) {
|
||||
if (keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
|
||||
}
|
||||
|
||||
@@ -252,15 +225,9 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("[Encrypt service] rsaDecrypt: No data provided for decryption.");
|
||||
}
|
||||
|
||||
let algorithm: "sha1" | "sha256";
|
||||
switch (data.encryptionType) {
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
|
||||
algorithm = "sha1";
|
||||
break;
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64:
|
||||
algorithm = "sha256";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid encryption type.");
|
||||
@@ -270,6 +237,6 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("[Encrypt service] rsaDecrypt: No private key provided for decryption.");
|
||||
}
|
||||
|
||||
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, algorithm);
|
||||
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, "sha1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("EncryptService", () => {
|
||||
describe("decryptString", () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString("encrypted_string");
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_string");
|
||||
const result = await encryptService.decryptString(encString, key);
|
||||
expect(result).toEqual("decrypted_string");
|
||||
expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith(
|
||||
@@ -172,8 +172,7 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
|
||||
encryptService.setDisableType0Decryption(true);
|
||||
it("throws if type is AesCbc256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_string");
|
||||
await expect(encryptService.decryptString(encString, key)).rejects.toThrow(
|
||||
@@ -185,7 +184,7 @@ describe("EncryptService", () => {
|
||||
describe("decryptBytes", () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString("encrypted_bytes");
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_bytes");
|
||||
const result = await encryptService.decryptBytes(encString, key);
|
||||
expect(result).toEqual(new Uint8Array(3));
|
||||
expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith(
|
||||
@@ -194,8 +193,7 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
|
||||
encryptService.setDisableType0Decryption(true);
|
||||
it("throws if type is AesCbc256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_bytes");
|
||||
await expect(encryptService.decryptBytes(encString, key)).rejects.toThrow(
|
||||
@@ -216,8 +214,7 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
|
||||
encryptService.setDisableType0Decryption(true);
|
||||
it("throws if type is AesCbc256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encBuffer = EncArrayBuffer.fromParts(
|
||||
EncryptionType.AesCbc256_B64,
|
||||
@@ -234,7 +231,10 @@ describe("EncryptService", () => {
|
||||
describe("unwrapDecapsulationKey", () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString("wrapped_decapsulation_key");
|
||||
const encString = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"wrapped_decapsulation_key",
|
||||
);
|
||||
const result = await encryptService.unwrapDecapsulationKey(encString, key);
|
||||
expect(result).toEqual(new Uint8Array(4));
|
||||
expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith(
|
||||
@@ -242,8 +242,7 @@ describe("EncryptService", () => {
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
|
||||
encryptService.setDisableType0Decryption(true);
|
||||
it("throws if type is AesCbc256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_decapsulation_key");
|
||||
await expect(encryptService.unwrapDecapsulationKey(encString, key)).rejects.toThrow(
|
||||
@@ -267,7 +266,10 @@ describe("EncryptService", () => {
|
||||
describe("unwrapEncapsulationKey", () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString("wrapped_encapsulation_key");
|
||||
const encString = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"wrapped_encapsulation_key",
|
||||
);
|
||||
const result = await encryptService.unwrapEncapsulationKey(encString, key);
|
||||
expect(result).toEqual(new Uint8Array(5));
|
||||
expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith(
|
||||
@@ -275,8 +277,7 @@ describe("EncryptService", () => {
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
|
||||
encryptService.setDisableType0Decryption(true);
|
||||
it("throws if type is AesCbc256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_encapsulation_key");
|
||||
await expect(encryptService.unwrapEncapsulationKey(encString, key)).rejects.toThrow(
|
||||
@@ -300,7 +301,10 @@ describe("EncryptService", () => {
|
||||
describe("unwrapSymmetricKey", () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString("wrapped_symmetric_key");
|
||||
const encString = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"wrapped_symmetric_key",
|
||||
);
|
||||
const result = await encryptService.unwrapSymmetricKey(encString, key);
|
||||
expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64)));
|
||||
expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith(
|
||||
@@ -308,8 +312,7 @@ describe("EncryptService", () => {
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
|
||||
encryptService.setDisableType0Decryption(true);
|
||||
it("throws if type is AesCbc256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_symmetric_key");
|
||||
await expect(encryptService.unwrapSymmetricKey(encString, key)).rejects.toThrow(
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EcbDecryptParameters } from "../../../platform/models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import { WebCryptoFunctionService } from "./web-crypto-function.service";
|
||||
|
||||
class TestSdkLoadService extends SdkLoadService {
|
||||
protected override load(): Promise<void> {
|
||||
// Simulate successful WASM load
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const RsaPublicKey =
|
||||
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl0Vawl/toXzkEvB82FEtqHP" +
|
||||
"4xlU2ab/v0crqIfXfIoWF/XXdHGIdrZeilnRXPPJT1B9dTsasttEZNnua/0Rek/cjNDHtzT52irfoZYS7X6HNIfOi54Q+egP" +
|
||||
@@ -40,6 +48,10 @@ const Sha512Mac =
|
||||
"5ea7817a0b7c5d4d9b00364ccd214669131fc17fe4aca";
|
||||
|
||||
describe("WebCrypto Function Service", () => {
|
||||
beforeAll(async () => {
|
||||
await new TestSdkLoadService().loadAndInit();
|
||||
});
|
||||
|
||||
describe("pbkdf2", () => {
|
||||
const regular256Key = "pj9prw/OHPleXI6bRdmlaD+saJS4awrMiQsQiDjeu2I=";
|
||||
const utf8256Key = "yqvoFXgMRmHR3QPYr5pyR4uVuoHkltv9aHUP63p8n7I=";
|
||||
@@ -287,7 +299,6 @@ describe("WebCrypto Function Service", () => {
|
||||
});
|
||||
|
||||
describe("rsaGenerateKeyPair", () => {
|
||||
testRsaGenerateKeyPair(1024);
|
||||
testRsaGenerateKeyPair(2048);
|
||||
|
||||
// Generating 4096 bit keys can be slow. Commenting it out to save CI.
|
||||
@@ -483,7 +494,7 @@ function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) {
|
||||
function testRsaGenerateKeyPair(length: 2048) {
|
||||
it(
|
||||
"should successfully generate a " + length + " bit key pair",
|
||||
async () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as forge from "node-forge";
|
||||
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { EncryptionType } from "../../../platform/enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import {
|
||||
@@ -260,57 +263,31 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
async rsaEncrypt(
|
||||
data: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
_algorithm: "sha1",
|
||||
): Promise<Uint8Array> {
|
||||
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
|
||||
// We cannot use the proper types here.
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
|
||||
};
|
||||
const impKey = await this.subtle.importKey("spki", publicKey, rsaParams, false, ["encrypt"]);
|
||||
const buffer = await this.subtle.encrypt(rsaParams, impKey, data);
|
||||
return new Uint8Array(buffer);
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.rsa_encrypt_data(data, publicKey);
|
||||
}
|
||||
|
||||
async rsaDecrypt(
|
||||
data: Uint8Array,
|
||||
privateKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
_algorithm: "sha1",
|
||||
): Promise<Uint8Array> {
|
||||
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
|
||||
// We cannot use the proper types here.
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
|
||||
};
|
||||
const impKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, false, ["decrypt"]);
|
||||
const buffer = await this.subtle.decrypt(rsaParams, impKey, data);
|
||||
return new Uint8Array(buffer);
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.rsa_decrypt_data(data, privateKey);
|
||||
}
|
||||
|
||||
async rsaExtractPublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
// Have to specify some algorithm
|
||||
hash: { name: this.toWebCryptoAlgorithm("sha1") },
|
||||
};
|
||||
const impPrivateKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, true, [
|
||||
"decrypt",
|
||||
]);
|
||||
const jwkPrivateKey = await this.subtle.exportKey("jwk", impPrivateKey);
|
||||
const jwkPublicKeyParams = {
|
||||
kty: "RSA",
|
||||
e: jwkPrivateKey.e,
|
||||
n: jwkPrivateKey.n,
|
||||
alg: "RSA-OAEP",
|
||||
ext: true,
|
||||
};
|
||||
const impPublicKey = await this.subtle.importKey("jwk", jwkPublicKeyParams, rsaParams, true, [
|
||||
"encrypt",
|
||||
]);
|
||||
const buffer = await this.subtle.exportKey("spki", impPublicKey);
|
||||
return new Uint8Array(buffer) as UnsignedPublicKey;
|
||||
async rsaExtractPublicKey(privateKey: Uint8Array): Promise<UnsignedPublicKey> {
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.rsa_extract_public_key(privateKey) as UnsignedPublicKey;
|
||||
}
|
||||
|
||||
async rsaGenerateKeyPair(_length: 2048): Promise<[UnsignedPublicKey, Uint8Array]> {
|
||||
await SdkLoadService.Ready;
|
||||
const privateKey = PureCrypto.rsa_generate_keypair();
|
||||
const publicKey = await this.rsaExtractPublicKey(privateKey);
|
||||
return [publicKey, privateKey];
|
||||
}
|
||||
|
||||
async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise<CsprngArray> {
|
||||
@@ -330,20 +307,6 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
return new Uint8Array(rawKey) as CsprngArray;
|
||||
}
|
||||
|
||||
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> {
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: length,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
|
||||
// Have to specify some algorithm
|
||||
hash: { name: this.toWebCryptoAlgorithm("sha1") },
|
||||
};
|
||||
const keyPair = await this.subtle.generateKey(rsaParams, true, ["encrypt", "decrypt"]);
|
||||
const publicKey = await this.subtle.exportKey("spki", keyPair.publicKey);
|
||||
const privateKey = await this.subtle.exportKey("pkcs8", keyPair.privateKey);
|
||||
return [new Uint8Array(publicKey), new Uint8Array(privateKey)];
|
||||
}
|
||||
|
||||
randomBytes(length: number): Promise<CsprngArray> {
|
||||
const arr = new Uint8Array(length);
|
||||
this.crypto.getRandomValues(arr);
|
||||
|
||||
@@ -39,6 +39,7 @@ export abstract class DeviceTrustServiceAbstraction {
|
||||
|
||||
/** Retrieves the device key if it exists from state or secure storage if supported for the active user. */
|
||||
abstract getDeviceKey(userId: UserId): Promise<DeviceKey | null>;
|
||||
abstract setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void>;
|
||||
abstract decryptUserKeyWithDeviceKey(
|
||||
userId: UserId,
|
||||
encryptedDevicePrivateKey: EncString,
|
||||
|
||||
@@ -356,7 +356,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
|
||||
async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot set device key.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { SyncService } from "../../platform/sync";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
|
||||
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
|
||||
|
||||
import { DefaultEncryptedMigrator } from "./default-encrypted-migrator";
|
||||
import { EncryptedMigration } from "./migrations/encrypted-migration";
|
||||
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
|
||||
|
||||
jest.mock("./migrations/minimum-kdf-migration");
|
||||
|
||||
describe("EncryptedMigrator", () => {
|
||||
const mockKdfConfigService = mock<KdfConfigService>();
|
||||
const mockChangeKdfService = mock<ChangeKdfService>();
|
||||
const mockLogService = mock<LogService>();
|
||||
const configService = mock<ConfigService>();
|
||||
const masterPasswordService = mock<MasterPasswordServiceAbstraction>();
|
||||
const syncService = mock<SyncService>();
|
||||
|
||||
let sut: DefaultEncryptedMigrator;
|
||||
const mockMigration = mock<MinimumKdfMigration>();
|
||||
|
||||
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
|
||||
const mockMasterPassword = "masterPassword123";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock the MinimumKdfMigration constructor to return our mock
|
||||
(MinimumKdfMigration as jest.MockedClass<typeof MinimumKdfMigration>).mockImplementation(
|
||||
() => mockMigration,
|
||||
);
|
||||
|
||||
sut = new DefaultEncryptedMigrator(
|
||||
mockKdfConfigService,
|
||||
mockChangeKdfService,
|
||||
mockLogService,
|
||||
configService,
|
||||
masterPasswordService,
|
||||
syncService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("runMigrations", () => {
|
||||
it("should throw error when userId is null", async () => {
|
||||
await expect(sut.runMigrations(null as any, null)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should throw error when userId is undefined", async () => {
|
||||
await expect(sut.runMigrations(undefined as any, null)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should not run migration when needsMigration returns 'noMigrationNeeded'", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
|
||||
|
||||
await sut.runMigrations(mockUserId, null);
|
||||
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should run migration when needsMigration returns 'needsMigration'", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
await sut.runMigrations(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
|
||||
});
|
||||
|
||||
it("should run migration when needsMigration returns 'needsMigrationWithMasterPassword'", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
|
||||
await sut.runMigrations(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
|
||||
});
|
||||
|
||||
it("should throw error when migration needs master password but null is provided", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
|
||||
await sut.runMigrations(mockUserId, null);
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should run multiple migrations", async () => {
|
||||
const mockSecondMigration = mock<EncryptedMigration>();
|
||||
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
(sut as any).migrations.push({
|
||||
name: "Test Second Migration",
|
||||
migration: mockSecondMigration,
|
||||
});
|
||||
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
await sut.runMigrations(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
|
||||
expect(mockSecondMigration.runMigrations).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockMasterPassword,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsMigrations", () => {
|
||||
it("should return 'noMigrationNeeded' when no migrations are needed", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'needsMigration' when at least one migration needs to run", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigration");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'needsMigrationWithMasterPassword' when at least one migration needs master password", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigrationWithMasterPassword");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should prioritize 'needsMigrationWithMasterPassword' over 'needsMigration'", async () => {
|
||||
const mockSecondMigration = mock<EncryptedMigration>();
|
||||
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
(sut as any).migrations.push({
|
||||
name: "Test Second Migration",
|
||||
migration: mockSecondMigration,
|
||||
});
|
||||
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigrationWithMasterPassword");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'needsMigration' when some migrations need running but none need master password", async () => {
|
||||
const mockSecondMigration = mock<EncryptedMigration>();
|
||||
mockSecondMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
|
||||
|
||||
(sut as any).migrations.push({
|
||||
name: "Test Second Migration",
|
||||
migration: mockSecondMigration,
|
||||
});
|
||||
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigration");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should throw error when userId is null", async () => {
|
||||
await expect(sut.needsMigrations(null as any)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should throw error when userId is undefined", async () => {
|
||||
await expect(sut.needsMigrations(undefined as any)).rejects.toThrow("userId");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { assertNonNullish } from "../../auth/utils";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { SyncService } from "../../platform/sync";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
|
||||
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
|
||||
|
||||
import { EncryptedMigrator } from "./encrypted-migrator.abstraction";
|
||||
import { EncryptedMigration, MigrationRequirement } from "./migrations/encrypted-migration";
|
||||
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
|
||||
|
||||
export class DefaultEncryptedMigrator implements EncryptedMigrator {
|
||||
private migrations: { name: string; migration: EncryptedMigration }[] = [];
|
||||
private isRunningMigration = false;
|
||||
|
||||
constructor(
|
||||
readonly kdfConfigService: KdfConfigService,
|
||||
readonly changeKdfService: ChangeKdfService,
|
||||
private readonly logService: LogService,
|
||||
readonly configService: ConfigService,
|
||||
readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
readonly syncService: SyncService,
|
||||
) {
|
||||
// Register migrations here
|
||||
this.migrations.push({
|
||||
name: "Minimum PBKDF2 Iteration Count Migration",
|
||||
migration: new MinimumKdfMigration(
|
||||
kdfConfigService,
|
||||
changeKdfService,
|
||||
logService,
|
||||
configService,
|
||||
masterPasswordService,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
// Ensure that the requirements for running all migrations are met
|
||||
const needsMigration = await this.needsMigrations(userId);
|
||||
if (needsMigration === "noMigrationNeeded") {
|
||||
return;
|
||||
} else if (needsMigration === "needsMigrationWithMasterPassword" && masterPassword == null) {
|
||||
// If a migration needs a password, but none is provided, the migrations are skipped. If a manual caller
|
||||
// during a login / unlock flow calls without a master password in a login / unlock strategy that has no
|
||||
// password, such as biometric unlock, the migrations are skipped.
|
||||
//
|
||||
// The fallback to this, the encrypted migrations scheduler, will first check if a migration needs a password
|
||||
// and then prompt the user. If the user enters their password, runMigrations is called again with the password.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// No concurrent migrations allowed, so acquire a service-wide lock
|
||||
if (this.isRunningMigration) {
|
||||
return;
|
||||
}
|
||||
this.isRunningMigration = true;
|
||||
|
||||
// Run all migrations sequentially in the order they were registered
|
||||
this.logService.mark("[Encrypted Migrator] Start");
|
||||
this.logService.info(`[Encrypted Migrator] Starting migrations for user: ${userId}`);
|
||||
let ranMigration = false;
|
||||
for (const { name, migration } of this.migrations) {
|
||||
if ((await migration.needsMigration(userId)) !== "noMigrationNeeded") {
|
||||
this.logService.info(`[Encrypted Migrator] Running migration: ${name}`);
|
||||
const start = performance.now();
|
||||
await migration.runMigrations(userId, masterPassword);
|
||||
this.logService.measure(start, "[Encrypted Migrator]", name, "ExecutionTime");
|
||||
ranMigration = true;
|
||||
}
|
||||
}
|
||||
this.logService.mark("[Encrypted Migrator] Finish");
|
||||
this.logService.info(`[Encrypted Migrator] Completed migrations for user: ${userId}`);
|
||||
if (ranMigration) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error(
|
||||
`[Encrypted Migrator] Error running migrations for user: ${userId}`,
|
||||
error,
|
||||
);
|
||||
throw error; // Re-throw the error to be handled by the caller
|
||||
} finally {
|
||||
this.isRunningMigration = false;
|
||||
}
|
||||
}
|
||||
|
||||
async needsMigrations(userId: UserId): Promise<MigrationRequirement> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
const migrationRequirements = await Promise.all(
|
||||
this.migrations.map(async ({ migration }) => migration.needsMigration(userId)),
|
||||
);
|
||||
|
||||
if (migrationRequirements.includes("needsMigrationWithMasterPassword")) {
|
||||
return "needsMigrationWithMasterPassword";
|
||||
} else if (migrationRequirements.includes("needsMigration")) {
|
||||
return "needsMigration";
|
||||
} else {
|
||||
return "noMigrationNeeded";
|
||||
}
|
||||
}
|
||||
|
||||
isRunningMigrations(): boolean {
|
||||
return this.isRunningMigration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { MigrationRequirement } from "./migrations/encrypted-migration";
|
||||
|
||||
export abstract class EncryptedMigrator {
|
||||
/**
|
||||
* Runs migrations on a decrypted user, with the cryptographic state initialized.
|
||||
* This only runs the migrations that are needed for the user.
|
||||
* This needs to be run after the decrypted user key has been set to state.
|
||||
*
|
||||
* If the master password is required but not provided, the migrations will not run, and the function will return early.
|
||||
* If migrations are already running, the migrations will not run again, and the function will return early.
|
||||
*
|
||||
* @param userId The ID of the user to run migrations for.
|
||||
* @param masterPassword The user's current master password.
|
||||
* @throws If the user does not exist
|
||||
* @throws If the user is locked or logged out
|
||||
* @throws If a migration fails
|
||||
*/
|
||||
abstract runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
|
||||
/**
|
||||
* Checks if the user needs to run any migrations.
|
||||
* This is used to determine if the user should be prompted to run migrations.
|
||||
* @param userId The ID of the user to check migrations for.
|
||||
*/
|
||||
abstract needsMigrations(userId: UserId): Promise<MigrationRequirement>;
|
||||
|
||||
/**
|
||||
* Indicates whether migrations are currently running.
|
||||
*/
|
||||
abstract isRunningMigrations(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UserId } from "../../../types/guid";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* IMPORTANT: Please read this when implementing new migrations.
|
||||
*
|
||||
* An encrypted migration defines an online migration that mutates the persistent state of the user on the server, or locally.
|
||||
* It should only be run once per user (or for local migrations, once per device). Migrations get scheduled automatically,
|
||||
* during actions such as login and unlock, or during sync.
|
||||
*
|
||||
* Migrations can require the master-password, which is provided by the user if required.
|
||||
* Migrations are run as soon as possible non-lazily, and MAY block unlock / login, if they have to run.
|
||||
*
|
||||
* Most importantly, implementing a migration should be done such that concurrent migrations may fail, but must never
|
||||
* leave the user in a broken state. Locally, these are scheduled with an application-global lock. However, no such guarantees
|
||||
* are made for the server, and other devices may run the migration concurrently.
|
||||
*
|
||||
* When adding a migration, it *MUST* be feature-flagged for the initial roll-out.
|
||||
*/
|
||||
export interface EncryptedMigration {
|
||||
/**
|
||||
* Runs the migration.
|
||||
* @throws If the migration fails, such as when no network is available.
|
||||
* @throws If the requirements for migration are not met (e.g. the user is locked)
|
||||
*/
|
||||
runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
|
||||
/**
|
||||
* Returns whether the migration needs to be run for the user, and if it does, whether the master password is required.
|
||||
*/
|
||||
needsMigration(userId: UserId): Promise<MigrationRequirement>;
|
||||
}
|
||||
|
||||
export type MigrationRequirement =
|
||||
| "needsMigration"
|
||||
| "needsMigrationWithMasterPassword"
|
||||
| "noMigrationNeeded";
|
||||
@@ -0,0 +1,184 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
Argon2KdfConfig,
|
||||
KdfConfigService,
|
||||
KdfType,
|
||||
PBKDF2KdfConfig,
|
||||
} from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
|
||||
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
|
||||
|
||||
import { MinimumKdfMigration } from "./minimum-kdf-migration";
|
||||
|
||||
describe("MinimumKdfMigration", () => {
|
||||
const mockKdfConfigService = mock<KdfConfigService>();
|
||||
const mockChangeKdfService = mock<ChangeKdfService>();
|
||||
const mockLogService = mock<LogService>();
|
||||
const mockConfigService = mock<ConfigService>();
|
||||
const mockMasterPasswordService = mock<MasterPasswordServiceAbstraction>();
|
||||
|
||||
let sut: MinimumKdfMigration;
|
||||
|
||||
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
|
||||
const mockMasterPassword = "masterPassword";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
sut = new MinimumKdfMigration(
|
||||
mockKdfConfigService,
|
||||
mockChangeKdfService,
|
||||
mockLogService,
|
||||
mockConfigService,
|
||||
mockMasterPasswordService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("needsMigration", () => {
|
||||
it("should return 'noMigrationNeeded' when user does not have a master password`", async () => {
|
||||
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(false);
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
});
|
||||
|
||||
it("should return 'noMigrationNeeded' when user uses argon2id`", async () => {
|
||||
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(true);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(new Argon2KdfConfig(3, 64, 4));
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
});
|
||||
|
||||
it("should return 'noMigrationNeeded' when PBKDF2 iterations are already above minimum", async () => {
|
||||
const mockKdfConfig = {
|
||||
kdfType: KdfType.PBKDF2_SHA256,
|
||||
iterations: PBKDF2KdfConfig.ITERATIONS.min + 1000,
|
||||
};
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
|
||||
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'noMigrationNeeded' when PBKDF2 iterations equal minimum", async () => {
|
||||
const mockKdfConfig = {
|
||||
kdfType: KdfType.PBKDF2_SHA256,
|
||||
iterations: PBKDF2KdfConfig.ITERATIONS.min,
|
||||
};
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'noMigrationNeeded' when feature flag is disabled", async () => {
|
||||
const mockKdfConfig = {
|
||||
kdfType: KdfType.PBKDF2_SHA256,
|
||||
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
|
||||
};
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.ForceUpdateKDFSettings,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 'needsMigrationWithMasterPassword' when PBKDF2 iterations are below minimum and feature flag is enabled", async () => {
|
||||
const mockKdfConfig = {
|
||||
kdfType: KdfType.PBKDF2_SHA256,
|
||||
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
|
||||
};
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigrationWithMasterPassword");
|
||||
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.ForceUpdateKDFSettings,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when userId is null", async () => {
|
||||
await expect(sut.needsMigration(null as any)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should throw error when userId is undefined", async () => {
|
||||
await expect(sut.needsMigration(undefined as any)).rejects.toThrow("userId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMigrations", () => {
|
||||
it("should update KDF parameters with minimum PBKDF2 iterations", async () => {
|
||||
await sut.runMigrations(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
|
||||
);
|
||||
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
// Verify the PBKDF2KdfConfig has the correct iteration count
|
||||
const kdfConfigArg = (mockChangeKdfService.updateUserKdfParams as jest.Mock).mock.calls[0][1];
|
||||
expect(kdfConfigArg.iterations).toBe(PBKDF2KdfConfig.ITERATIONS.defaultValue);
|
||||
});
|
||||
|
||||
it("should throw error when userId is null", async () => {
|
||||
await expect(sut.runMigrations(null as any, mockMasterPassword)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should throw error when userId is undefined", async () => {
|
||||
await expect(sut.runMigrations(undefined as any, mockMasterPassword)).rejects.toThrow(
|
||||
"userId",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when masterPassword is null", async () => {
|
||||
await expect(sut.runMigrations(mockUserId, null as any)).rejects.toThrow("masterPassword");
|
||||
});
|
||||
|
||||
it("should throw error when masterPassword is undefined", async () => {
|
||||
await expect(sut.runMigrations(mockUserId, undefined as any)).rejects.toThrow(
|
||||
"masterPassword",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle errors from changeKdfService", async () => {
|
||||
const mockError = new Error("KDF update failed");
|
||||
mockChangeKdfService.updateUserKdfParams.mockRejectedValue(mockError);
|
||||
|
||||
await expect(sut.runMigrations(mockUserId, mockMasterPassword)).rejects.toThrow(
|
||||
"KDF update failed",
|
||||
);
|
||||
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
|
||||
);
|
||||
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { assertNonNullish } from "../../../auth/utils";
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
|
||||
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
|
||||
|
||||
import { EncryptedMigration, MigrationRequirement } from "./encrypted-migration";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* This migrator ensures the user's account has a minimum PBKDF2 iteration count.
|
||||
* It will update the entire account, logging out old clients if necessary.
|
||||
*/
|
||||
export class MinimumKdfMigration implements EncryptedMigration {
|
||||
constructor(
|
||||
private readonly kdfConfigService: KdfConfigService,
|
||||
private readonly changeKdfService: ChangeKdfService,
|
||||
private readonly logService: LogService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
|
||||
assertNonNullish(userId, "userId");
|
||||
assertNonNullish(masterPassword, "masterPassword");
|
||||
|
||||
this.logService.info(
|
||||
`[MinimumKdfMigration] Updating user ${userId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.defaultValue}`,
|
||||
);
|
||||
await this.changeKdfService.updateUserKdfParams(
|
||||
masterPassword!,
|
||||
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
|
||||
userId,
|
||||
);
|
||||
await this.kdfConfigService.setKdfConfig(
|
||||
userId,
|
||||
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
|
||||
);
|
||||
}
|
||||
|
||||
async needsMigration(userId: UserId): Promise<MigrationRequirement> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
if (!(await this.masterPasswordService.userHasMasterPassword(userId))) {
|
||||
return "noMigrationNeeded";
|
||||
}
|
||||
|
||||
// Only PBKDF2 users below the minimum iteration count need migration
|
||||
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
if (
|
||||
kdfConfig.kdfType !== KdfType.PBKDF2_SHA256 ||
|
||||
kdfConfig.iterations >= PBKDF2KdfConfig.ITERATIONS.min
|
||||
) {
|
||||
return "noMigrationNeeded";
|
||||
}
|
||||
|
||||
if (!(await this.configService.getFeatureFlag(FeatureFlag.ForceUpdateKDFSettings))) {
|
||||
return "noMigrationNeeded";
|
||||
}
|
||||
|
||||
return "needsMigrationWithMasterPassword";
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { makeEncString } from "../../../spec";
|
||||
import { KdfRequest } from "../../models/request/kdf.request";
|
||||
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { EncString } from "../crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordAuthenticationHash,
|
||||
@@ -17,11 +18,13 @@ import {
|
||||
} from "../master-password/types/master-password.types";
|
||||
|
||||
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
|
||||
import { DefaultChangeKdfService } from "./change-kdf-service";
|
||||
import { DefaultChangeKdfService } from "./change-kdf.service";
|
||||
|
||||
describe("ChangeKdfService", () => {
|
||||
const changeKdfApiService = mock<ChangeKdfApiService>();
|
||||
const sdkService = mock<SdkService>();
|
||||
const keyService = mock<KeyService>();
|
||||
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
|
||||
let sut: DefaultChangeKdfService;
|
||||
|
||||
@@ -48,7 +51,12 @@ describe("ChangeKdfService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any;
|
||||
sut = new DefaultChangeKdfService(changeKdfApiService, sdkService);
|
||||
sut = new DefaultChangeKdfService(
|
||||
changeKdfApiService,
|
||||
sdkService,
|
||||
keyService,
|
||||
masterPasswordService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -163,6 +171,20 @@ describe("ChangeKdfService", () => {
|
||||
expect(changeKdfApiService.updateUserKdfParams).toHaveBeenCalledWith(expectedRequest);
|
||||
});
|
||||
|
||||
it("should set master key and hash after KDF update", async () => {
|
||||
const masterPassword = "masterPassword";
|
||||
const mockMasterKey = {} as any;
|
||||
const mockHash = "localHash";
|
||||
|
||||
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue(mockHash);
|
||||
|
||||
await sut.updateUserKdfParams(masterPassword, mockNewKdfConfig, mockUserId);
|
||||
|
||||
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId);
|
||||
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockHash, mockUserId);
|
||||
});
|
||||
|
||||
it("should properly dispose of SDK resources", async () => {
|
||||
const masterPassword = "masterPassword";
|
||||
jest.spyOn(mockNewKdfConfig, "toSdkConfig").mockReturnValue({} as any);
|
||||
@@ -1,12 +1,14 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
import { KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { KdfRequest } from "../../models/request/kdf.request";
|
||||
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
fromSdkAuthenticationData,
|
||||
MasterPasswordAuthenticationData,
|
||||
@@ -14,12 +16,14 @@ import {
|
||||
} from "../master-password/types/master-password.types";
|
||||
|
||||
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
|
||||
import { ChangeKdfService } from "./change-kdf-service.abstraction";
|
||||
import { ChangeKdfService } from "./change-kdf.service.abstraction";
|
||||
|
||||
export class DefaultChangeKdfService implements ChangeKdfService {
|
||||
constructor(
|
||||
private changeKdfApiService: ChangeKdfApiService,
|
||||
private sdkService: SdkService,
|
||||
private keyService: KeyService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async updateUserKdfParams(masterPassword: string, kdf: KdfConfig, userId: UserId): Promise<void> {
|
||||
@@ -56,5 +60,19 @@ export class DefaultChangeKdfService implements ChangeKdfService {
|
||||
const request = new KdfRequest(authenticationData, unlockData);
|
||||
request.authenticateWith(oldAuthenticationData);
|
||||
await this.changeKdfApiService.updateUserKdfParams(request);
|
||||
|
||||
// Update the locally stored master key and hash, so that UV, etc. still works
|
||||
const masterKey = await this.keyService.makeMasterKey(
|
||||
masterPassword,
|
||||
unlockData.salt,
|
||||
unlockData.kdf,
|
||||
);
|
||||
const localMasterKeyHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
masterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
|
||||
|
||||
export abstract class KeyConnectorApiService {
|
||||
abstract getConfirmationDetails(
|
||||
orgSsoIdentifier: string,
|
||||
): Promise<KeyConnectorConfirmationDetailsResponse>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export interface KeyConnectorDomainConfirmation {
|
||||
keyConnectorUrl: string;
|
||||
organizationSsoIdentifier: string;
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ import { KdfConfig } from "@bitwarden/key-management";
|
||||
export interface NewSsoUserKeyConnectorConversion {
|
||||
kdfConfig: KdfConfig;
|
||||
keyConnectorUrl: string;
|
||||
// SSO organization identifier, not UUID
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
|
||||
export class KeyConnectorConfirmationDetailsResponse extends BaseResponse {
|
||||
organizationName: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.organizationName = this.getResponseProperty("OrganizationName");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
|
||||
|
||||
import { DefaultKeyConnectorApiService } from "./default-key-connector-api.service";
|
||||
|
||||
describe("DefaultKeyConnectorApiService", () => {
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let sut: DefaultKeyConnectorApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
sut = new DefaultKeyConnectorApiService(apiService);
|
||||
});
|
||||
|
||||
describe("getConfirmationDetails", () => {
|
||||
it("encodes orgSsoIdentifier in URL", async () => {
|
||||
const orgSsoIdentifier = "test org/with special@chars";
|
||||
const expectedEncodedIdentifier = encodeURIComponent(orgSsoIdentifier);
|
||||
const mockResponse = {};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
await sut.getConfirmationDetails(orgSsoIdentifier);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/accounts/key-connector/confirmation-details/${expectedEncodedIdentifier}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns expected response", async () => {
|
||||
const orgSsoIdentifier = "test-org";
|
||||
const expectedOrgName = "example";
|
||||
const mockResponse = { OrganizationName: expectedOrgName };
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sut.getConfirmationDetails(orgSsoIdentifier);
|
||||
|
||||
expect(result).toBeInstanceOf(KeyConnectorConfirmationDetailsResponse);
|
||||
expect(result.organizationName).toBe(expectedOrgName);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/accounts/key-connector/confirmation-details/test-org",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { KeyConnectorApiService } from "../abstractions/key-connector-api.service";
|
||||
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
|
||||
|
||||
export class DefaultKeyConnectorApiService implements KeyConnectorApiService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async getConfirmationDetails(
|
||||
orgSsoIdentifier: string,
|
||||
): Promise<KeyConnectorConfirmationDetailsResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/accounts/key-connector/confirmation-details/" + encodeURIComponent(orgSsoIdentifier),
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new KeyConnectorConfirmationDetailsResponse(r);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { SetKeyConnectorKeyRequest } from "@bitwarden/common/key-management/key-
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
// 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 { Argon2KdfConfig, PBKDF2KdfConfig, KeyService, KdfType } from "@bitwarden/key-management";
|
||||
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import { BitwardenClient } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
@@ -16,21 +17,26 @@ import { Organization } from "../../../admin-console/models/domain/organization"
|
||||
import { ProfileOrganizationResponse } from "../../../admin-console/models/response/profile-organization.response";
|
||||
import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response";
|
||||
import { TokenService } from "../../../auth/services/token.service";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { RegisterSdkService } from "../../../platform/abstractions/sdk/register-sdk.service";
|
||||
import { Rc } from "../../../platform/misc/reference-counting/rc";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { OrganizationId, UserId } from "../../../types/guid";
|
||||
import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { AccountCryptographicStateService } from "../../account-cryptography/account-cryptographic-state.service";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
|
||||
import { SecurityStateService } from "../../security-state/abstractions/security-state.service";
|
||||
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
|
||||
import { NewSsoUserKeyConnectorConversion } from "../models/new-sso-user-key-connector-conversion";
|
||||
|
||||
import {
|
||||
USES_KEY_CONNECTOR,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
KeyConnectorService,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
USES_KEY_CONNECTOR,
|
||||
} from "./key-connector.service";
|
||||
|
||||
describe("KeyConnectorService", () => {
|
||||
@@ -43,6 +49,10 @@ describe("KeyConnectorService", () => {
|
||||
const organizationService = mock<OrganizationService>();
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const logoutCallback = jest.fn();
|
||||
const configService = mock<ConfigService>();
|
||||
const registerSdkService = mock<RegisterSdkService>();
|
||||
const securityStateService = mock<SecurityStateService>();
|
||||
const accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
@@ -50,6 +60,7 @@ describe("KeyConnectorService", () => {
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const mockSsoOrgIdentifier = "test-sso-org-id";
|
||||
const mockOrgId = Utils.newGuid() as OrganizationId;
|
||||
|
||||
const mockMasterKeyResponse: KeyConnectorUserKeyResponse = new KeyConnectorUserKeyResponse({
|
||||
@@ -61,7 +72,7 @@ describe("KeyConnectorService", () => {
|
||||
const conversion: NewSsoUserKeyConnectorConversion = {
|
||||
kdfConfig: new PBKDF2KdfConfig(600_000),
|
||||
keyConnectorUrl,
|
||||
organizationId: mockOrgId,
|
||||
organizationId: mockSsoOrgIdentifier,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -82,6 +93,10 @@ describe("KeyConnectorService", () => {
|
||||
keyGenerationService,
|
||||
logoutCallback,
|
||||
stateProvider,
|
||||
configService,
|
||||
registerSdkService,
|
||||
securityStateService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -419,44 +434,52 @@ describe("KeyConnectorService", () => {
|
||||
});
|
||||
|
||||
describe("convertNewSsoUserToKeyConnector", () => {
|
||||
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockMasterKey = getMockMasterKey();
|
||||
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
|
||||
string,
|
||||
EncString,
|
||||
];
|
||||
let mockMakeUserKeyResult: [UserKey, EncString];
|
||||
describe("V2", () => {
|
||||
const mockKeyConnectorKey = Utils.fromBufferToB64(new Uint8Array(64));
|
||||
const mockUserKeyString = Utils.fromBufferToB64(new Uint8Array(64));
|
||||
const mockPrivateKey = "mockPrivateKey789";
|
||||
const mockKeyConnectorKeyWrappedUserKey = "2.mockWrappedUserKey";
|
||||
const mockSigningKey = "mockSigningKey";
|
||||
const mockSignedPublicKey = "mockSignedPublicKey";
|
||||
const mockSecurityState = "mockSecurityState";
|
||||
|
||||
beforeEach(() => {
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const encString = new EncString("mockEncryptedString");
|
||||
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
|
||||
let mockSdkRef: any;
|
||||
let mockSdk: any;
|
||||
|
||||
keyGenerationService.createKey.mockResolvedValue(passwordKey);
|
||||
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
|
||||
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
|
||||
tokenService.getEmail.mockResolvedValue(mockEmail);
|
||||
});
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
|
||||
it.each([
|
||||
[KdfType.PBKDF2_SHA256, 700_000, undefined, undefined],
|
||||
[KdfType.Argon2id, 11, 65, 5],
|
||||
])(
|
||||
"sets up a new SSO user with key connector",
|
||||
async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => {
|
||||
const expectedKdfConfig =
|
||||
kdfType == KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(kdfIterations)
|
||||
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
|
||||
|
||||
const conversion: NewSsoUserKeyConnectorConversion = {
|
||||
kdfConfig: expectedKdfConfig,
|
||||
keyConnectorUrl: keyConnectorUrl,
|
||||
organizationId: mockOrgId,
|
||||
mockSdkRef = {
|
||||
value: {
|
||||
auth: jest.fn().mockReturnValue({
|
||||
registration: jest.fn().mockReturnValue({
|
||||
post_keys_for_key_connector_registration: jest.fn().mockResolvedValue({
|
||||
key_connector_key: mockKeyConnectorKey,
|
||||
user_key: mockUserKeyString,
|
||||
key_connector_key_wrapped_user_key: mockKeyConnectorKeyWrappedUserKey,
|
||||
account_cryptographic_state: {
|
||||
V2: {
|
||||
private_key: mockPrivateKey,
|
||||
signing_key: mockSigningKey,
|
||||
signed_public_key: mockSignedPublicKey,
|
||||
security_state: mockSecurityState,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
[Symbol.dispose]: jest.fn(),
|
||||
};
|
||||
|
||||
mockSdk = {
|
||||
take: jest.fn().mockReturnValue(mockSdkRef),
|
||||
};
|
||||
|
||||
registerSdkService.registerClient$.mockReturnValue(of(mockSdk));
|
||||
});
|
||||
|
||||
it("should set up a new SSO user with key connector using V2", async () => {
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
@@ -465,11 +488,253 @@ describe("KeyConnectorService", () => {
|
||||
|
||||
await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId);
|
||||
|
||||
expect(registerSdkService.registerClient$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockSdk.take).toHaveBeenCalled();
|
||||
expect(mockSdkRef.value.auth).toHaveBeenCalled();
|
||||
|
||||
const mockRegistration = mockSdkRef.value
|
||||
.auth()
|
||||
.registration().post_keys_for_key_connector_registration;
|
||||
expect(mockRegistration).toHaveBeenCalledWith(
|
||||
keyConnectorUrl,
|
||||
mockSsoOrgIdentifier,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
expect.any(SymmetricCryptoKey),
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(
|
||||
expect.any(SymmetricCryptoKey),
|
||||
mockUserId,
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
expect.any(EncString),
|
||||
mockUserId,
|
||||
);
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
|
||||
{
|
||||
V2: {
|
||||
private_key: mockPrivateKey,
|
||||
signing_key: mockSigningKey,
|
||||
signed_public_key: mockSignedPublicKey,
|
||||
security_state: mockSecurityState,
|
||||
},
|
||||
},
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId);
|
||||
expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId);
|
||||
expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
|
||||
mockSecurityState,
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId);
|
||||
|
||||
expect(await firstValueFrom(conversionState.state$)).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw error when SDK is not available", async () => {
|
||||
registerSdkService.registerClient$.mockReturnValue(
|
||||
of(null as unknown as Rc<BitwardenClient>),
|
||||
);
|
||||
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(conversion);
|
||||
|
||||
await expect(
|
||||
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
|
||||
).rejects.toThrow("SDK not available");
|
||||
|
||||
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
|
||||
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.setUserKey).not.toHaveBeenCalled();
|
||||
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
|
||||
expect(
|
||||
accountCryptographicStateService.setAccountCryptographicState,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
|
||||
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
|
||||
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
|
||||
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error when account cryptographic state is not V2", async () => {
|
||||
mockSdkRef.value
|
||||
.auth()
|
||||
.registration()
|
||||
.post_keys_for_key_connector_registration.mockResolvedValue({
|
||||
key_connector_key: mockKeyConnectorKey,
|
||||
user_key: mockUserKeyString,
|
||||
key_connector_key_wrapped_user_key: mockKeyConnectorKeyWrappedUserKey,
|
||||
account_cryptographic_state: {
|
||||
V1: {
|
||||
private_key: mockPrivateKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(conversion);
|
||||
|
||||
await expect(
|
||||
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
|
||||
).rejects.toThrow("Unexpected account cryptographic state version");
|
||||
|
||||
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
|
||||
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.setUserKey).not.toHaveBeenCalled();
|
||||
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
|
||||
expect(
|
||||
accountCryptographicStateService.setAccountCryptographicState,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
|
||||
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
|
||||
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
|
||||
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error when post_keys_for_key_connector_registration fails", async () => {
|
||||
const sdkError = new Error("Key Connector registration failed");
|
||||
mockSdkRef.value
|
||||
.auth()
|
||||
.registration()
|
||||
.post_keys_for_key_connector_registration.mockRejectedValue(sdkError);
|
||||
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(conversion);
|
||||
|
||||
await expect(
|
||||
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
|
||||
).rejects.toThrow("Key Connector registration failed");
|
||||
|
||||
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
|
||||
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.setUserKey).not.toHaveBeenCalled();
|
||||
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
|
||||
expect(
|
||||
accountCryptographicStateService.setAccountCryptographicState,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
|
||||
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
|
||||
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
|
||||
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("V1", () => {
|
||||
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockMasterKey = getMockMasterKey();
|
||||
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
|
||||
string,
|
||||
EncString,
|
||||
];
|
||||
let mockMakeUserKeyResult: [UserKey, EncString];
|
||||
|
||||
beforeEach(() => {
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const encString = new EncString("mockEncryptedString");
|
||||
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
|
||||
|
||||
keyGenerationService.createKey.mockResolvedValue(passwordKey);
|
||||
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
|
||||
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
|
||||
tokenService.getEmail.mockResolvedValue(mockEmail);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[KdfType.PBKDF2_SHA256, 700_000, undefined, undefined],
|
||||
[KdfType.Argon2id, 11, 65, 5],
|
||||
])(
|
||||
"sets up a new SSO user with key connector",
|
||||
async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => {
|
||||
const expectedKdfConfig =
|
||||
kdfType == KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(kdfIterations)
|
||||
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
|
||||
|
||||
const conversion: NewSsoUserKeyConnectorConversion = {
|
||||
kdfConfig: expectedKdfConfig,
|
||||
keyConnectorUrl: keyConnectorUrl,
|
||||
organizationId: mockSsoOrgIdentifier,
|
||||
};
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(conversion);
|
||||
|
||||
await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId);
|
||||
|
||||
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
passwordKey.keyB64,
|
||||
mockEmail,
|
||||
expectedKdfConfig,
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
|
||||
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
mockMakeUserKeyResult[1],
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
keyConnectorUrl,
|
||||
new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey),
|
||||
),
|
||||
);
|
||||
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith(
|
||||
new SetKeyConnectorKeyRequest(
|
||||
mockMakeUserKeyResult[1].encryptedString!,
|
||||
expectedKdfConfig,
|
||||
mockSsoOrgIdentifier,
|
||||
new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify that conversion data is cleared from conversionState
|
||||
expect(await firstValueFrom(conversionState.state$)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("handles api error", async () => {
|
||||
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
|
||||
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(conversion);
|
||||
|
||||
await expect(
|
||||
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
|
||||
).rejects.toThrow(new Error("Key Connector error"));
|
||||
|
||||
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
passwordKey.keyB64,
|
||||
mockEmail,
|
||||
expectedKdfConfig,
|
||||
new PBKDF2KdfConfig(600_000),
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
@@ -488,76 +753,29 @@ describe("KeyConnectorService", () => {
|
||||
Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey),
|
||||
),
|
||||
);
|
||||
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith(
|
||||
new SetKeyConnectorKeyRequest(
|
||||
mockMakeUserKeyResult[1].encryptedString!,
|
||||
expectedKdfConfig,
|
||||
mockOrgId,
|
||||
new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!),
|
||||
),
|
||||
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
|
||||
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
|
||||
|
||||
expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError");
|
||||
});
|
||||
|
||||
it("should throw error when conversion data is null", async () => {
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(null);
|
||||
|
||||
// Verify that conversion data is cleared from conversionState
|
||||
expect(await firstValueFrom(conversionState.state$)).toBeNull();
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
|
||||
).rejects.toThrow(new Error("Key Connector conversion not found"));
|
||||
|
||||
it("handles api error", async () => {
|
||||
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
|
||||
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(conversion);
|
||||
|
||||
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
|
||||
new Error("Key Connector error"),
|
||||
);
|
||||
|
||||
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
passwordKey.keyB64,
|
||||
mockEmail,
|
||||
new PBKDF2KdfConfig(600_000),
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
|
||||
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
mockMakeUserKeyResult[1],
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
keyConnectorUrl,
|
||||
new KeyConnectorUserKeyRequest(Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey)),
|
||||
);
|
||||
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
|
||||
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
|
||||
|
||||
expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError");
|
||||
});
|
||||
|
||||
it("should throw error when conversion data is null", async () => {
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(null);
|
||||
|
||||
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
|
||||
new Error("Key Connector conversion not found"),
|
||||
);
|
||||
|
||||
// Verify that no key generation or API calls were made
|
||||
expect(keyGenerationService.createKey).not.toHaveBeenCalled();
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled();
|
||||
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
|
||||
// Verify that no key generation or API calls were made
|
||||
expect(keyGenerationService.createKey).not.toHaveBeenCalled();
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled();
|
||||
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -603,7 +821,10 @@ describe("KeyConnectorService", () => {
|
||||
const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId);
|
||||
const data = await firstValueFrom(data$);
|
||||
|
||||
expect(data).toEqual({ keyConnectorUrl: conversion.keyConnectorUrl });
|
||||
expect(data).toEqual({
|
||||
keyConnectorUrl: conversion.keyConnectorUrl,
|
||||
organizationSsoIdentifier: conversion.organizationId,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return observable of null value when no data is set", async () => {
|
||||
|
||||
@@ -9,22 +9,36 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion";
|
||||
// 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 { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import {
|
||||
Argon2KdfConfig,
|
||||
KdfConfig,
|
||||
KdfType,
|
||||
KeyService,
|
||||
PBKDF2KdfConfig,
|
||||
} from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserType } from "../../../admin-console/enums";
|
||||
import { Organization } from "../../../admin-console/models/domain/organization";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { KeysRequest } from "../../../models/request/keys.request";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { RegisterSdkService } from "../../../platform/abstractions/sdk/register-sdk.service";
|
||||
import { asUuid } from "../../../platform/abstractions/sdk/sdk.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { KEY_CONNECTOR_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { MasterKey } from "../../../types/key";
|
||||
import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { AccountCryptographicStateService } from "../../account-cryptography/account-cryptographic-state.service";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
|
||||
import { SecurityStateService } from "../../security-state/abstractions/security-state.service";
|
||||
import { SignedPublicKey, SignedSecurityState, WrappedSigningKey } from "../../types";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
|
||||
import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation";
|
||||
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
|
||||
@@ -75,6 +89,10 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise<void>,
|
||||
private stateProvider: StateProvider,
|
||||
private configService: ConfigService,
|
||||
private registerSdkService: RegisterSdkService,
|
||||
private securityStateService: SecurityStateService,
|
||||
private accountCryptographicStateService: AccountCryptographicStateService,
|
||||
) {
|
||||
this.convertAccountRequired$ = accountService.activeAccount$.pipe(
|
||||
filter((account) => account != null),
|
||||
@@ -152,8 +170,106 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
throw new Error("Key Connector conversion not found");
|
||||
}
|
||||
|
||||
const { kdfConfig, keyConnectorUrl, organizationId } = conversion;
|
||||
const { kdfConfig, keyConnectorUrl, organizationId: ssoOrganizationIdentifier } = conversion;
|
||||
|
||||
if (
|
||||
await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(
|
||||
FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration,
|
||||
),
|
||||
)
|
||||
) {
|
||||
await this.convertNewSsoUserToKeyConnectorV2(
|
||||
userId,
|
||||
keyConnectorUrl,
|
||||
ssoOrganizationIdentifier,
|
||||
);
|
||||
} else {
|
||||
await this.convertNewSsoUserToKeyConnectorV1(
|
||||
userId,
|
||||
kdfConfig,
|
||||
keyConnectorUrl,
|
||||
ssoOrganizationIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
await this.stateProvider
|
||||
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
|
||||
.update(() => null);
|
||||
}
|
||||
|
||||
async convertNewSsoUserToKeyConnectorV2(
|
||||
userId: UserId,
|
||||
keyConnectorUrl: string,
|
||||
ssoOrganizationIdentifier: string,
|
||||
) {
|
||||
const result = await firstValueFrom(
|
||||
this.registerSdkService.registerClient$(userId).pipe(
|
||||
map((sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
|
||||
using ref = sdk.take();
|
||||
|
||||
return ref.value
|
||||
.auth()
|
||||
.registration()
|
||||
.post_keys_for_key_connector_registration(
|
||||
keyConnectorUrl,
|
||||
ssoOrganizationIdentifier,
|
||||
asUuid(userId),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (!("V2" in result.account_cryptographic_state)) {
|
||||
const version = Object.keys(result.account_cryptographic_state);
|
||||
throw new Error(`Unexpected account cryptographic state version ${version}`);
|
||||
}
|
||||
|
||||
await this.masterPasswordService.setMasterKey(
|
||||
SymmetricCryptoKey.fromString(result.key_connector_key) as MasterKey,
|
||||
userId,
|
||||
);
|
||||
await this.keyService.setUserKey(
|
||||
SymmetricCryptoKey.fromString(result.user_key) as UserKey,
|
||||
userId,
|
||||
);
|
||||
await this.masterPasswordService.setMasterKeyEncryptedUserKey(
|
||||
new EncString(result.key_connector_key_wrapped_user_key),
|
||||
userId,
|
||||
);
|
||||
|
||||
await this.accountCryptographicStateService.setAccountCryptographicState(
|
||||
result.account_cryptographic_state,
|
||||
userId,
|
||||
);
|
||||
// Legacy states
|
||||
await this.keyService.setPrivateKey(result.account_cryptographic_state.V2.private_key, userId);
|
||||
await this.keyService.setUserSigningKey(
|
||||
result.account_cryptographic_state.V2.signing_key as WrappedSigningKey,
|
||||
userId,
|
||||
);
|
||||
await this.securityStateService.setAccountSecurityState(
|
||||
result.account_cryptographic_state.V2.security_state as SignedSecurityState,
|
||||
userId,
|
||||
);
|
||||
if (result.account_cryptographic_state.V2.signed_public_key != null) {
|
||||
await this.keyService.setSignedPublicKey(
|
||||
result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async convertNewSsoUserToKeyConnectorV1(
|
||||
userId: UserId,
|
||||
kdfConfig: KdfConfig,
|
||||
keyConnectorUrl: string,
|
||||
ssoOrganizationIdentifier: string,
|
||||
) {
|
||||
const password = await this.keyGenerationService.createKey(512);
|
||||
|
||||
const masterKey = await this.keyService.makeMasterKey(
|
||||
@@ -182,14 +298,10 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
const setPasswordRequest = new SetKeyConnectorKeyRequest(
|
||||
userKey[1].encryptedString,
|
||||
kdfConfig,
|
||||
organizationId,
|
||||
ssoOrganizationIdentifier,
|
||||
keys,
|
||||
);
|
||||
await this.apiService.postSetKeyConnectorKey(setPasswordRequest);
|
||||
|
||||
await this.stateProvider
|
||||
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
|
||||
.update(() => null);
|
||||
}
|
||||
|
||||
async setNewSsoUserKeyConnectorConversionData(
|
||||
@@ -202,9 +314,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
}
|
||||
|
||||
requiresDomainConfirmation$(userId: UserId): Observable<KeyConnectorDomainConfirmation | null> {
|
||||
return this.stateProvider
|
||||
.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId)
|
||||
.pipe(map((data) => (data != null ? { keyConnectorUrl: data.keyConnectorUrl } : null)));
|
||||
return this.stateProvider.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId).pipe(
|
||||
map((data) =>
|
||||
data != null
|
||||
? {
|
||||
keyConnectorUrl: data.keyConnectorUrl,
|
||||
organizationSsoIdentifier: data.organizationId,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private handleKeyConnectorError(e: any) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SignedPublicKey, WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SecurityStateResponse } from "../../security-state/response/security-state.response";
|
||||
|
||||
import { PublicKeyEncryptionKeyPairResponse } from "./public-key-encryption-key-pair.response";
|
||||
@@ -52,4 +54,31 @@ export class PrivateKeysResponseModel {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toWrappedAccountCryptographicState(): WrappedAccountCryptographicState {
|
||||
if (this.signatureKeyPair === null && this.securityState === null) {
|
||||
// V1 user
|
||||
return {
|
||||
V1: {
|
||||
private_key: this.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
},
|
||||
};
|
||||
} else if (this.signatureKeyPair !== null && this.securityState !== null) {
|
||||
// V2 user
|
||||
return {
|
||||
V2: {
|
||||
private_key: this.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
signing_key: this.signatureKeyPair.wrappedSigningKey,
|
||||
signed_public_key: this.publicKeyEncryptionKeyPair.signedPublicKey as SignedPublicKey,
|
||||
security_state: this.securityState.securityState as string,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
throw new Error("Both signatureKeyPair and securityState must be present or absent together");
|
||||
}
|
||||
}
|
||||
|
||||
isV2Encryption(): boolean {
|
||||
return this.signatureKeyPair !== null && this.securityState !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,30 @@ export abstract class MasterPasswordServiceAbstraction {
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
) => Promise<UserKey>;
|
||||
|
||||
/**
|
||||
* Returns whether the user has a master password set.
|
||||
* @param userId The user ID.
|
||||
* @throws If the user ID is missing.
|
||||
*/
|
||||
abstract userHasMasterPassword(userId: UserId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Derives a master key from the provided password and master password unlock data,
|
||||
* then sets it to state for the specified user. This is a temporary backwards compatibility function
|
||||
* to support existing code that relies on direct master key access.
|
||||
* Note: This will be removed in https://bitwarden.atlassian.net/browse/PM-30676
|
||||
*
|
||||
* @param password The master password.
|
||||
* @param masterPasswordUnlockData The master password unlock data containing the KDF settings and salt.
|
||||
* @param userId The user ID.
|
||||
* @throws If the password, master password unlock data, or user ID is missing.
|
||||
*/
|
||||
abstract setLegacyMasterKeyFromUnlockData(
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction {
|
||||
|
||||
@@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
|
||||
this.masterKeyHashSubject.next(initialMasterKeyHash);
|
||||
}
|
||||
|
||||
userHasMasterPassword(userId: UserId): Promise<boolean> {
|
||||
return this.mock.userHasMasterPassword(userId);
|
||||
}
|
||||
|
||||
emailToSalt(email: string): MasterPasswordSalt {
|
||||
return this.mock.emailToSalt(email);
|
||||
}
|
||||
@@ -123,4 +127,12 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
|
||||
masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null> {
|
||||
return this.mock.masterPasswordUnlockData$(userId);
|
||||
}
|
||||
|
||||
setLegacyMasterKeyFromUnlockData(
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
return this.mock.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
@@ -415,6 +416,125 @@ describe("MasterPasswordService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("setLegacyMasterKeyFromUnlockData", () => {
|
||||
const password = "test-password";
|
||||
|
||||
it("derives master key from password and sets it in state", async () => {
|
||||
const masterKey = makeSymmetricCryptoKey(32, 5) as MasterKey;
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
|
||||
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
|
||||
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfPBKDF2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
|
||||
|
||||
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
|
||||
password,
|
||||
masterPasswordUnlockData.salt,
|
||||
masterPasswordUnlockData.kdf,
|
||||
);
|
||||
|
||||
const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$);
|
||||
expect(state).toEqual(masterKey);
|
||||
});
|
||||
|
||||
it("works with argon2 kdf config", async () => {
|
||||
const masterKey = makeSymmetricCryptoKey(32, 6) as MasterKey;
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
|
||||
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
|
||||
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfArgon2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
|
||||
|
||||
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
|
||||
password,
|
||||
masterPasswordUnlockData.salt,
|
||||
masterPasswordUnlockData.kdf,
|
||||
);
|
||||
|
||||
const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$);
|
||||
expect(state).toEqual(masterKey);
|
||||
});
|
||||
|
||||
it("computes and sets master key hash in state", async () => {
|
||||
const masterKey = makeSymmetricCryptoKey(32, 7) as MasterKey;
|
||||
const expectedHashBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const expectedHashB64 = "AQIDBAUGBwg=";
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
|
||||
cryptoFunctionService.pbkdf2.mockResolvedValue(expectedHashBytes);
|
||||
jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(expectedHashB64);
|
||||
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfPBKDF2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
|
||||
|
||||
expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith(
|
||||
masterKey.inner().encryptionKey,
|
||||
password,
|
||||
"sha256",
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
|
||||
const hashState = await firstValueFrom(sut.masterKeyHash$(userId));
|
||||
expect(hashState).toEqual(expectedHashB64);
|
||||
});
|
||||
|
||||
it("throws if password is null", async () => {
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfPBKDF2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.setLegacyMasterKeyFromUnlockData(
|
||||
null as unknown as string,
|
||||
masterPasswordUnlockData,
|
||||
userId,
|
||||
),
|
||||
).rejects.toThrow("password is null or undefined.");
|
||||
});
|
||||
|
||||
it("throws if masterPasswordUnlockData is null", async () => {
|
||||
await expect(
|
||||
sut.setLegacyMasterKeyFromUnlockData(
|
||||
password,
|
||||
null as unknown as MasterPasswordUnlockData,
|
||||
userId,
|
||||
),
|
||||
).rejects.toThrow("masterPasswordUnlockData is null or undefined.");
|
||||
});
|
||||
|
||||
it("throws if userId is null", async () => {
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfPBKDF2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.setLegacyMasterKeyFromUnlockData(
|
||||
password,
|
||||
masterPasswordUnlockData,
|
||||
null as unknown as UserId,
|
||||
),
|
||||
).rejects.toThrow("userId is null or undefined.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MASTER_PASSWORD_UNLOCK_KEY", () => {
|
||||
it("has the correct configuration", () => {
|
||||
expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom, map, Observable } from "rxjs";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
@@ -25,6 +26,7 @@ import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptedString, EncString } from "../../crypto/models/enc-string";
|
||||
import { USES_KEY_CONNECTOR } from "../../key-connector/services/key-connector.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
@@ -85,6 +87,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async userHasMasterPassword(userId: UserId): Promise<boolean> {
|
||||
assertNonNullish(userId, "userId");
|
||||
// A user has a master-password if they have a master-key encrypted user key *but* are not a key connector user
|
||||
// Note: We can't use the key connector service as an abstraction here because it causes a run-time dependency injection cycle between KC service and MP service.
|
||||
const usesKeyConnector = await firstValueFrom(
|
||||
this.stateProvider.getUser(userId, USES_KEY_CONNECTOR).state$,
|
||||
);
|
||||
const usesMasterKey = await firstValueFrom(
|
||||
this.stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$,
|
||||
);
|
||||
return usesMasterKey && !usesKeyConnector;
|
||||
}
|
||||
|
||||
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
|
||||
assertNonNullish(userId, "userId");
|
||||
return this.accountService.accounts$.pipe(
|
||||
@@ -307,6 +322,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
masterPasswordUnlockData.kdf.toSdkConfig(),
|
||||
),
|
||||
);
|
||||
|
||||
return userKey as UserKey;
|
||||
}
|
||||
|
||||
@@ -327,4 +343,51 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
|
||||
return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$;
|
||||
}
|
||||
|
||||
async setLegacyMasterKeyFromUnlockData(
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
assertNonNullish(password, "password");
|
||||
assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData");
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
const masterKey = (await this.keyGenerationService.deriveKeyFromPassword(
|
||||
password,
|
||||
masterPasswordUnlockData.salt,
|
||||
masterPasswordUnlockData.kdf,
|
||||
)) as MasterKey;
|
||||
const localKeyHash = await this.hashMasterKey(
|
||||
password,
|
||||
masterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
|
||||
await this.setMasterKey(masterKey, userId);
|
||||
await this.setMasterKeyHash(localKeyHash, userId);
|
||||
}
|
||||
|
||||
// Copied from KeyService to avoid circular dependency. This will be dropped together with `setLegacyMatserKeyFromUnlockData`.
|
||||
private async hashMasterKey(
|
||||
password: string,
|
||||
key: MasterKey,
|
||||
hashPurpose: HashPurpose,
|
||||
): Promise<string> {
|
||||
if (password == null) {
|
||||
throw new Error("password is required.");
|
||||
}
|
||||
if (key == null) {
|
||||
throw new Error("key is required.");
|
||||
}
|
||||
|
||||
const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1;
|
||||
const hash = await this.cryptoFunctionService.pbkdf2(
|
||||
key.inner().encryptionKey,
|
||||
password,
|
||||
"sha256",
|
||||
iterations,
|
||||
);
|
||||
return Utils.fromBufferToB64(hash);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user