mirror of
https://github.com/bitwarden/browser
synced 2026-02-18 02:19:18 +00:00
Merge branch 'main' into PM-25685
This commit is contained in:
@@ -14,10 +14,4 @@ export abstract class AuditService {
|
||||
* @returns A promise that resolves to an array of BreachAccountResponse objects.
|
||||
*/
|
||||
abstract breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
|
||||
/**
|
||||
* Checks if a domain is known for phishing.
|
||||
* @param domain The domain to check.
|
||||
* @returns A promise that resolves to a boolean indicating if the domain is known for phishing.
|
||||
*/
|
||||
abstract getKnownPhishingDomains: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
import { combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
import { PolicyService } from "../policy/policy.service.abstraction";
|
||||
|
||||
export function canAccessVaultTab(org: Organization): boolean {
|
||||
return org.canViewAllCollections;
|
||||
@@ -51,6 +56,17 @@ export function canAccessOrgAdmin(org: Organization): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function canAccessEmergencyAccess(
|
||||
userId: UserId,
|
||||
configService: ConfigService,
|
||||
policyService: PolicyService,
|
||||
) {
|
||||
return combineLatest([
|
||||
configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
|
||||
policyService.policiesByType$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Please use the general `getById` custom rxjs operator instead.
|
||||
*/
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
allowAdminAccessToAllCollectionItems: false,
|
||||
familySponsorshipLastSyncDate: new Date(),
|
||||
userIsManagedByOrganization: false,
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
useOrganizationDomains: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
isAdminInitiated: false,
|
||||
|
||||
@@ -62,7 +62,7 @@ export class OrganizationData {
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
@@ -130,7 +130,7 @@ export class OrganizationData {
|
||||
this.limitItemDeletion = response.limitItemDeletion;
|
||||
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
|
||||
this.useRiskInsights = response.useRiskInsights;
|
||||
this.useAccessIntelligence = response.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
|
||||
this.isAdminInitiated = response.isAdminInitiated;
|
||||
this.ssoEnabled = response.ssoEnabled;
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("Organization", () => {
|
||||
limitItemDeletion: false,
|
||||
allowAdminAccessToAllCollectionItems: true,
|
||||
userIsManagedByOrganization: false,
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
isAdminInitiated: false,
|
||||
ssoEnabled: false,
|
||||
|
||||
@@ -93,7 +93,7 @@ export class Organization {
|
||||
* matches one of the verified domains of that organization, and the user is a member of it.
|
||||
*/
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
@@ -157,7 +157,7 @@ export class Organization {
|
||||
this.limitItemDeletion = obj.limitItemDeletion;
|
||||
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
|
||||
this.useRiskInsights = obj.useRiskInsights;
|
||||
this.useAccessIntelligence = obj.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
|
||||
this.isAdminInitiated = obj.isAdminInitiated;
|
||||
this.ssoEnabled = obj.ssoEnabled;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { PolicyType } from "../../enums";
|
||||
|
||||
export type PolicyRequest = {
|
||||
type: PolicyType;
|
||||
enabled: boolean;
|
||||
data: any;
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
limitCollectionDeletion: boolean;
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -80,6 +80,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
|
||||
"AllowAdminAccessToAllCollectionItems",
|
||||
);
|
||||
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
@@ -129,7 +129,8 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
"AllowAdminAccessToAllCollectionItems",
|
||||
);
|
||||
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
|
||||
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
|
||||
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
|
||||
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
|
||||
|
||||
@@ -554,6 +554,77 @@ describe("PolicyService", () => {
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
describe("SingleOrg policy exemptions", () => {
|
||||
it("returns true for SingleOrg policy when AutoConfirm is enabled, even for users who can manage policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org6", PolicyType.SingleOrg, true),
|
||||
policyData("policy2", "org6", PolicyType.AutoConfirm, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is not enabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([policyData("policy1", "org6", PolicyType.SingleOrg, true)]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is disabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org6", PolicyType.SingleOrg, true),
|
||||
policyData("policy2", "org6", PolicyType.AutoConfirm, false),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for SingleOrg policy for regular users when AutoConfirm is not enabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([policyData("policy1", "org1", PolicyType.SingleOrg, true)]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for SingleOrg policy when AutoConfirm is enabled in a different organization", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org6", PolicyType.SingleOrg, true),
|
||||
policyData("policy2", "org1", PolicyType.AutoConfirm, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinePoliciesIntoMasterPasswordPolicyOptions", () => {
|
||||
|
||||
@@ -40,18 +40,16 @@ export class DefaultPolicyService implements PolicyService {
|
||||
}
|
||||
|
||||
policiesByType$(policyType: PolicyType, userId: UserId) {
|
||||
const filteredPolicies$ = this.policies$(userId).pipe(
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("No userId provided");
|
||||
}
|
||||
|
||||
const allPolicies$ = this.policies$(userId);
|
||||
const organizations$ = this.organizationService.organizations$(userId);
|
||||
|
||||
return combineLatest([filteredPolicies$, organizations$]).pipe(
|
||||
return combineLatest([allPolicies$, organizations$]).pipe(
|
||||
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +75,7 @@ export class DefaultPolicyService implements PolicyService {
|
||||
policy.enabled &&
|
||||
organization.status >= OrganizationUserStatusType.Accepted &&
|
||||
organization.usePolicies &&
|
||||
!this.isExemptFromPolicy(policy.type, organization)
|
||||
!this.isExemptFromPolicy(policy.type, organization, policies)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -265,7 +263,11 @@ export class DefaultPolicyService implements PolicyService {
|
||||
* Determines whether an orgUser is exempt from a specific policy because of their role
|
||||
* Generally orgUsers who can manage policies are exempt from them, but some policies are stricter
|
||||
*/
|
||||
private isExemptFromPolicy(policyType: PolicyType, organization: Organization) {
|
||||
private isExemptFromPolicy(
|
||||
policyType: PolicyType,
|
||||
organization: Organization,
|
||||
allPolicies: Policy[],
|
||||
) {
|
||||
switch (policyType) {
|
||||
case PolicyType.MaximumVaultTimeout:
|
||||
// Max Vault Timeout applies to everyone except owners
|
||||
@@ -283,9 +285,19 @@ export class DefaultPolicyService implements PolicyService {
|
||||
case PolicyType.RemoveUnlockWithPin:
|
||||
// Remove Unlock with PIN policy
|
||||
return false;
|
||||
case PolicyType.AutoConfirm:
|
||||
return false;
|
||||
case PolicyType.OrganizationDataOwnership:
|
||||
// organization data ownership policy applies to everyone except admins and owners
|
||||
return organization.isAdmin;
|
||||
case PolicyType.SingleOrg:
|
||||
// Check if AutoConfirm policy is enabled for this organization
|
||||
return allPolicies.find(
|
||||
(p) =>
|
||||
p.organizationId === organization.id && p.type === PolicyType.AutoConfirm && p.enabled,
|
||||
)
|
||||
? false
|
||||
: organization.canManagePolicies;
|
||||
default:
|
||||
return organization.canManagePolicies;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import { MasterPasswordPolicyResponse } from "./master-password-policy.response"
|
||||
import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-decryption-options.response";
|
||||
|
||||
export class IdentityTokenResponse extends BaseResponse {
|
||||
// Authentication Information
|
||||
accessToken: string;
|
||||
expiresIn?: number;
|
||||
refreshToken?: string;
|
||||
tokenType: string;
|
||||
|
||||
// Decryption Information
|
||||
resetMasterPassword: boolean;
|
||||
privateKey: string; // userKeyEncryptedPrivateKey
|
||||
key?: EncString; // masterKeyEncryptedUserKey
|
||||
|
||||
@@ -191,6 +191,140 @@ describe("UserVerificationService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRequest", () => {
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
i18nService.t
|
||||
.calledWith("verificationCodeRequired")
|
||||
.mockReturnValue("Verification code is required");
|
||||
i18nService.t
|
||||
.calledWith("masterPasswordRequired")
|
||||
.mockReturnValue("Master Password is required");
|
||||
});
|
||||
|
||||
describe("OTP verification", () => {
|
||||
it("should build request with OTP secret", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.OTP,
|
||||
secret: "123456",
|
||||
} as any;
|
||||
|
||||
const result = await sut.buildRequest(verification);
|
||||
|
||||
expect(result.otp).toBe("123456");
|
||||
});
|
||||
|
||||
it("should throw if OTP secret is empty", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.OTP,
|
||||
secret: "",
|
||||
} as any;
|
||||
|
||||
await expect(sut.buildRequest(verification)).rejects.toThrow(
|
||||
"Verification code is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if OTP secret is null", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.OTP,
|
||||
secret: null,
|
||||
} as any;
|
||||
|
||||
await expect(sut.buildRequest(verification)).rejects.toThrow(
|
||||
"Verification code is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Master password verification", () => {
|
||||
beforeEach(() => {
|
||||
kdfConfigService.getKdfConfig.mockResolvedValue("kdfConfig" as unknown as KdfConfig);
|
||||
masterPasswordService.saltForUser$.mockReturnValue(of("salt" as any));
|
||||
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
masterPasswordAuthenticationHash: "hash",
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should build request with master password secret", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
const result = await sut.buildRequest(verification);
|
||||
|
||||
expect(result.masterPasswordHash).toBe("hash");
|
||||
});
|
||||
|
||||
it("should use default SecretVerificationRequest if no custom class provided", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
const result = await sut.buildRequest(verification);
|
||||
|
||||
expect(result).toHaveProperty("masterPasswordHash");
|
||||
});
|
||||
|
||||
it("should get KDF config for the active user", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
await sut.buildRequest(verification);
|
||||
|
||||
expect(kdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should get salt for the active user", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
await sut.buildRequest(verification);
|
||||
|
||||
expect(masterPasswordService.saltForUser$).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should call makeMasterPasswordAuthenticationData with correct parameters", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
await sut.buildRequest(verification);
|
||||
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
"password123",
|
||||
"kdfConfig",
|
||||
"salt",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if master password secret is empty", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "",
|
||||
} as any;
|
||||
|
||||
await expect(sut.buildRequest(verification)).rejects.toThrow("Master Password is required");
|
||||
});
|
||||
|
||||
it("should throw if master password secret is null", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: null,
|
||||
} as any;
|
||||
|
||||
await expect(sut.buildRequest(verification)).rejects.toThrow("Master Password is required");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyUserByMasterPassword", () => {
|
||||
beforeAll(() => {
|
||||
i18nService.t.calledWith("invalidMasterPassword").mockReturnValue("Invalid master password");
|
||||
@@ -228,7 +362,6 @@ describe("UserVerificationService", () => {
|
||||
expect(result).toEqual({
|
||||
policyOptions: null,
|
||||
masterKey: "masterKey",
|
||||
kdfConfig: "kdfConfig",
|
||||
email: "email",
|
||||
});
|
||||
});
|
||||
@@ -288,7 +421,6 @@ describe("UserVerificationService", () => {
|
||||
expect(result).toEqual({
|
||||
policyOptions: "MasterPasswordPolicyOptions",
|
||||
masterKey: "masterKey",
|
||||
kdfConfig: "kdfConfig",
|
||||
email: "email",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
VerificationWithSecret,
|
||||
verificationHasSecret,
|
||||
} from "../../types/verification";
|
||||
import { getUserId } from "../account.service";
|
||||
|
||||
/**
|
||||
* Used for general-purpose user verification throughout the app.
|
||||
@@ -101,7 +102,6 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
async buildRequest<T extends SecretVerificationRequest>(
|
||||
verification: ServerSideVerification,
|
||||
requestClass?: new () => T,
|
||||
alreadyHashed?: boolean,
|
||||
) {
|
||||
this.validateSecretInput(verification);
|
||||
|
||||
@@ -111,20 +111,17 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
if (verification.type === VerificationType.OTP) {
|
||||
request.otp = verification.secret;
|
||||
} else {
|
||||
const [userId, email] = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
|
||||
);
|
||||
let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
|
||||
if (!masterKey && !alreadyHashed) {
|
||||
masterKey = await this.keyService.makeMasterKey(
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const kdf = await this.kdfConfigService.getKdfConfig(userId as UserId);
|
||||
const salt = await firstValueFrom(this.masterPasswordService.saltForUser$(userId as UserId));
|
||||
|
||||
const authenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
verification.secret,
|
||||
email,
|
||||
await this.kdfConfigService.getKdfConfig(userId),
|
||||
kdf,
|
||||
salt,
|
||||
);
|
||||
}
|
||||
request.masterPasswordHash = alreadyHashed
|
||||
? verification.secret
|
||||
: await this.keyService.hashMasterKey(verification.secret, masterKey);
|
||||
request.authenticateWith(authenticationData);
|
||||
}
|
||||
|
||||
return request;
|
||||
@@ -239,7 +236,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
);
|
||||
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
return { policyOptions, masterKey, kdfConfig, email };
|
||||
return { policyOptions, masterKey, email };
|
||||
}
|
||||
|
||||
private async verifyUserByPIN(verification: PinVerification, userId: UserId): Promise<boolean> {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// 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 { KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { VerificationType } from "../enums/verification-type";
|
||||
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
|
||||
|
||||
export type OtpVerification = { type: VerificationType.OTP; secret: string };
|
||||
export type MasterPasswordVerification = { type: VerificationType.MasterPassword; secret: string };
|
||||
export type MasterPasswordVerification = {
|
||||
type: VerificationType.MasterPassword;
|
||||
/** Secret here means the master password, *NOT* a hash of it */
|
||||
secret: string;
|
||||
};
|
||||
export type PinVerification = { type: VerificationType.PIN; secret: string };
|
||||
export type BiometricsVerification = { type: VerificationType.Biometrics };
|
||||
|
||||
@@ -25,8 +25,8 @@ export function verificationHasSecret(
|
||||
export type ServerSideVerification = OtpVerification | MasterPasswordVerification;
|
||||
|
||||
export type MasterPasswordVerificationResponse = {
|
||||
/** @deprecated */
|
||||
masterKey: MasterKey;
|
||||
kdfConfig: KdfConfig;
|
||||
email: string;
|
||||
policyOptions: MasterPasswordPolicyResponse | null;
|
||||
};
|
||||
|
||||
@@ -166,7 +166,7 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
|
||||
if (!policy?.enabled || policy?.data == null) {
|
||||
return null;
|
||||
}
|
||||
const data = policy.data?.defaultUriMatchStrategy;
|
||||
const data = policy.data?.uriMatchDetection;
|
||||
// Validate that data is a valid UriMatchStrategy value
|
||||
return Object.values(UriMatchStrategy).includes(data) ? data : null;
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
BusinessSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTier,
|
||||
} from "../types/subscription-pricing-tier";
|
||||
|
||||
export abstract class SubscriptionPricingServiceAbstraction {
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of personal subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
abstract getPersonalSubscriptionPricingTiers$(): Observable<PersonalSubscriptionPricingTier[]>;
|
||||
|
||||
/**
|
||||
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
abstract getBusinessSubscriptionPricingTiers$(): Observable<BusinessSubscriptionPricingTier[]>;
|
||||
|
||||
/**
|
||||
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers for developers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
abstract getDeveloperSubscriptionPricingTiers$(): Observable<BusinessSubscriptionPricingTier[]>;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export enum PlanType {
|
||||
EnterpriseMonthly2019 = 4,
|
||||
EnterpriseAnnually2019 = 5,
|
||||
Custom = 6,
|
||||
FamiliesAnnually = 7,
|
||||
FamiliesAnnually2025 = 7,
|
||||
TeamsMonthly2020 = 8,
|
||||
TeamsAnnually2020 = 9,
|
||||
EnterpriseMonthly2020 = 10,
|
||||
@@ -23,4 +23,5 @@ export enum PlanType {
|
||||
EnterpriseMonthly = 19,
|
||||
EnterpriseAnnually = 20,
|
||||
TeamsStarter = 21,
|
||||
FamiliesAnnually = 22,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
417
libs/common/src/billing/services/subscription-pricing.service.ts
Normal file
417
libs/common/src/billing/services/subscription-pricing.service.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import {
|
||||
combineLatest,
|
||||
combineLatestWith,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
take,
|
||||
throwError,
|
||||
} from "rxjs";
|
||||
import { catchError } from "rxjs/operators";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PlanType } 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "../abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
BusinessSubscriptionPricingTier,
|
||||
BusinessSubscriptionPricingTierIds,
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadenceIds,
|
||||
} from "../types/subscription-pricing-tier";
|
||||
|
||||
export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction {
|
||||
/**
|
||||
* Fallback premium pricing used when the feature flag is disabled.
|
||||
* These values represent the legacy pricing model and will not reflect
|
||||
* server-side price changes. They are retained for backward compatibility
|
||||
* during the feature flag rollout period.
|
||||
*/
|
||||
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
|
||||
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of personal subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
getPersonalSubscriptionPricingTiers$ = (): Observable<PersonalSubscriptionPricingTier[]> =>
|
||||
combineLatest([this.premium$, this.families$]).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to load personal subscription pricing tiers", error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
getBusinessSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
||||
combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to load business subscription pricing tiers", error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers for developers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
getDeveloperSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
||||
combineLatest([this.free$, this.teams$, this.enterprise$]).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to load developer subscription pricing tiers", error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||
this.billingApiService.getPlans(),
|
||||
).pipe(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 premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
||||
.pipe(
|
||||
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
|
||||
switchMap((fetchPremiumFromPricingService) =>
|
||||
fetchPremiumFromPricingService
|
||||
? this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat.price,
|
||||
storage: premiumPlan.storage.price,
|
||||
})),
|
||||
)
|
||||
: of({
|
||||
seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
|
||||
storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
|
||||
}),
|
||||
),
|
||||
map((premiumPrices) => ({
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: this.i18nService.t("premium"),
|
||||
description: this.i18nService.t("planDescPremium"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: premiumPrices.seat,
|
||||
annualPricePerAdditionalStorageGB: premiumPrices.storage,
|
||||
features: [
|
||||
this.featureTranslations.builtInAuthenticator(),
|
||||
this.featureTranslations.secureFileStorage(),
|
||||
this.featureTranslations.emergencyAccess(),
|
||||
this.featureTranslations.breachMonitoring(),
|
||||
this.featureTranslations.andMoreFeatures(),
|
||||
],
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
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),
|
||||
)!;
|
||||
|
||||
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(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans): BusinessSubscriptionPricingTier => {
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Free,
|
||||
name: this.i18nService.t("planNameFree"),
|
||||
description: this.i18nService.t("planDescFreeV2", "1"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
|
||||
this.featureTranslations.alwaysFree(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.twoSecretsIncluded(),
|
||||
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)!;
|
||||
|
||||
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(),
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
private featureTranslations = {
|
||||
builtInAuthenticator: () => ({
|
||||
key: "builtInAuthenticator",
|
||||
value: this.i18nService.t("builtInAuthenticator"),
|
||||
}),
|
||||
emergencyAccess: () => ({
|
||||
key: "emergencyAccess",
|
||||
value: this.i18nService.t("emergencyAccess"),
|
||||
}),
|
||||
breachMonitoring: () => ({
|
||||
key: "breachMonitoring",
|
||||
value: this.i18nService.t("breachMonitoring"),
|
||||
}),
|
||||
andMoreFeatures: () => ({
|
||||
key: "andMoreFeatures",
|
||||
value: this.i18nService.t("andMoreFeatures"),
|
||||
}),
|
||||
premiumAccounts: () => ({
|
||||
key: "premiumAccounts",
|
||||
value: this.i18nService.t("premiumAccounts"),
|
||||
}),
|
||||
secureFileStorage: () => ({
|
||||
key: "secureFileStorage",
|
||||
value: this.i18nService.t("secureFileStorage"),
|
||||
}),
|
||||
familiesUnlimitedSharing: () => ({
|
||||
key: "familiesUnlimitedSharing",
|
||||
value: this.i18nService.t("familiesUnlimitedSharing"),
|
||||
}),
|
||||
familiesUnlimitedCollections: () => ({
|
||||
key: "familiesUnlimitedCollections",
|
||||
value: this.i18nService.t("familiesUnlimitedCollections"),
|
||||
}),
|
||||
familiesSharedStorage: () => ({
|
||||
key: "familiesSharedStorage",
|
||||
value: this.i18nService.t("familiesSharedStorage"),
|
||||
}),
|
||||
limitedUsersV2: (users: number) => ({
|
||||
key: "limitedUsersV2",
|
||||
value: this.i18nService.t("limitedUsersV2", users),
|
||||
}),
|
||||
limitedCollectionsV2: (collections: number) => ({
|
||||
key: "limitedCollectionsV2",
|
||||
value: this.i18nService.t("limitedCollectionsV2", collections),
|
||||
}),
|
||||
alwaysFree: () => ({
|
||||
key: "alwaysFree",
|
||||
value: this.i18nService.t("alwaysFree"),
|
||||
}),
|
||||
twoSecretsIncluded: () => ({
|
||||
key: "twoSecretsIncluded",
|
||||
value: this.i18nService.t("twoSecretsIncluded"),
|
||||
}),
|
||||
projectsIncludedV2: (projects: number) => ({
|
||||
key: "projectsIncludedV2",
|
||||
value: this.i18nService.t("projectsIncludedV2", projects),
|
||||
}),
|
||||
secureItemSharing: () => ({
|
||||
key: "secureItemSharing",
|
||||
value: this.i18nService.t("secureItemSharing"),
|
||||
}),
|
||||
eventLogMonitoring: () => ({
|
||||
key: "eventLogMonitoring",
|
||||
value: this.i18nService.t("eventLogMonitoring"),
|
||||
}),
|
||||
directoryIntegration: () => ({
|
||||
key: "directoryIntegration",
|
||||
value: this.i18nService.t("directoryIntegration"),
|
||||
}),
|
||||
scimSupport: () => ({
|
||||
key: "scimSupport",
|
||||
value: this.i18nService.t("scimSupport"),
|
||||
}),
|
||||
unlimitedSecretsAndProjects: () => ({
|
||||
key: "unlimitedSecretsAndProjects",
|
||||
value: this.i18nService.t("unlimitedSecretsAndProjects"),
|
||||
}),
|
||||
includedMachineAccountsV2: (included: number) => ({
|
||||
key: "includedMachineAccountsV2",
|
||||
value: this.i18nService.t("includedMachineAccountsV2", included),
|
||||
}),
|
||||
enterpriseSecurityPolicies: () => ({
|
||||
key: "enterpriseSecurityPolicies",
|
||||
value: this.i18nService.t("enterpriseSecurityPolicies"),
|
||||
}),
|
||||
passwordLessSso: () => ({
|
||||
key: "passwordLessSso",
|
||||
value: this.i18nService.t("passwordLessSso"),
|
||||
}),
|
||||
accountRecovery: () => ({
|
||||
key: "accountRecovery",
|
||||
value: this.i18nService.t("accountRecovery"),
|
||||
}),
|
||||
selfHostOption: () => ({
|
||||
key: "selfHostOption",
|
||||
value: this.i18nService.t("selfHostOption"),
|
||||
}),
|
||||
complimentaryFamiliesPlan: () => ({
|
||||
key: "complimentaryFamiliesPlan",
|
||||
value: this.i18nService.t("complimentaryFamiliesPlan"),
|
||||
}),
|
||||
unlimitedUsers: () => ({
|
||||
key: "unlimitedUsers",
|
||||
value: this.i18nService.t("unlimitedUsers"),
|
||||
}),
|
||||
strengthenCybersecurity: () => ({
|
||||
key: "strengthenCybersecurity",
|
||||
value: this.i18nService.t("strengthenCybersecurity"),
|
||||
}),
|
||||
boostProductivity: () => ({
|
||||
key: "boostProductivity",
|
||||
value: this.i18nService.t("boostProductivity"),
|
||||
}),
|
||||
seamlessIntegration: () => ({
|
||||
key: "seamlessIntegration",
|
||||
value: this.i18nService.t("seamlessIntegration"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
85
libs/common/src/billing/types/subscription-pricing-tier.ts
Normal file
85
libs/common/src/billing/types/subscription-pricing-tier.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export const PersonalSubscriptionPricingTierIds = {
|
||||
Premium: "premium",
|
||||
Families: "families",
|
||||
} as const;
|
||||
|
||||
export const BusinessSubscriptionPricingTierIds = {
|
||||
Free: "free",
|
||||
Teams: "teams",
|
||||
Enterprise: "enterprise",
|
||||
Custom: "custom",
|
||||
} as const;
|
||||
|
||||
export const SubscriptionCadenceIds = {
|
||||
Annually: "annually",
|
||||
Monthly: "monthly",
|
||||
} as const;
|
||||
|
||||
export type PersonalSubscriptionPricingTierId =
|
||||
(typeof PersonalSubscriptionPricingTierIds)[keyof typeof PersonalSubscriptionPricingTierIds];
|
||||
export type BusinessSubscriptionPricingTierId =
|
||||
(typeof BusinessSubscriptionPricingTierIds)[keyof typeof BusinessSubscriptionPricingTierIds];
|
||||
export type SubscriptionCadence =
|
||||
(typeof SubscriptionCadenceIds)[keyof typeof SubscriptionCadenceIds];
|
||||
|
||||
type HasFeatures = {
|
||||
features: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
type HasAdditionalStorage = {
|
||||
annualPricePerAdditionalStorageGB: number;
|
||||
};
|
||||
|
||||
type StandalonePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
type: "standalone";
|
||||
annualPrice: number;
|
||||
};
|
||||
|
||||
type PackagedPasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
type: "packaged";
|
||||
users: number;
|
||||
annualPrice: number;
|
||||
};
|
||||
|
||||
type FreePasswordManager = HasFeatures & {
|
||||
type: "free";
|
||||
};
|
||||
|
||||
type CustomPasswordManager = HasFeatures & {
|
||||
type: "custom";
|
||||
};
|
||||
|
||||
type ScalablePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
};
|
||||
|
||||
type FreeSecretsManager = HasFeatures & {
|
||||
type: "free";
|
||||
};
|
||||
|
||||
type ScalableSecretsManager = HasFeatures & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerAdditionalServiceAccount: number;
|
||||
};
|
||||
|
||||
export type PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierId;
|
||||
name: string;
|
||||
description: string;
|
||||
availableCadences: Omit<SubscriptionCadence, "monthly">[]; // personal plans are only ever annual
|
||||
passwordManager: StandalonePasswordManager | PackagedPasswordManager;
|
||||
};
|
||||
|
||||
export type BusinessSubscriptionPricingTier = {
|
||||
id: BusinessSubscriptionPricingTierId;
|
||||
name: string;
|
||||
description: string;
|
||||
availableCadences: SubscriptionCadence[];
|
||||
passwordManager: FreePasswordManager | ScalablePasswordManager | CustomPasswordManager;
|
||||
secretsManager?: FreeSecretsManager | ScalableSecretsManager;
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* Auth */
|
||||
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
|
||||
/* Autofill */
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
@@ -30,6 +31,8 @@ export enum FeatureFlag {
|
||||
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",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -37,6 +40,7 @@ export enum FeatureFlag {
|
||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
|
||||
WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2",
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
|
||||
@@ -56,6 +60,7 @@ export enum FeatureFlag {
|
||||
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",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -104,9 +109,11 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||
[FeatureFlag.AutofillConfirmation]: FALSE,
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
@@ -117,6 +124,8 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
@@ -124,6 +133,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
|
||||
[FeatureFlag.WindowsBiometricsV2]: FALSE,
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
|
||||
export abstract class ProcessReloadServiceAbstraction {
|
||||
abstract startProcessReload(authService: AuthService): Promise<void>;
|
||||
abstract startProcessReload(): Promise<void>;
|
||||
abstract cancelProcessReload(): void;
|
||||
}
|
||||
|
||||
@@ -22,13 +22,15 @@ export class MasterPasswordUnlockResponse extends BaseResponse {
|
||||
|
||||
this.kdf = new KdfConfigResponse(this.getResponseProperty("Kdf"));
|
||||
|
||||
const masterKeyEncryptedUserKey = this.getResponseProperty("MasterKeyEncryptedUserKey");
|
||||
if (masterKeyEncryptedUserKey == null || typeof masterKeyEncryptedUserKey !== "string") {
|
||||
// Note: MasterKeyEncryptedUserKey and masterKeyWrappedUserKey are the same thing, and
|
||||
// used inconsistently in the codebase
|
||||
const masterKeyWrappedUserKey = this.getResponseProperty("MasterKeyEncryptedUserKey");
|
||||
if (masterKeyWrappedUserKey == null || typeof masterKeyWrappedUserKey !== "string") {
|
||||
throw new Error(
|
||||
"MasterPasswordUnlockResponse does not contain a valid master key encrypted user key",
|
||||
);
|
||||
}
|
||||
this.masterKeyWrappedUserKey = masterKeyEncryptedUserKey as MasterKeyWrappedUserKey;
|
||||
this.masterKeyWrappedUserKey = masterKeyWrappedUserKey as MasterKeyWrappedUserKey;
|
||||
}
|
||||
|
||||
toMasterPasswordUnlockData() {
|
||||
|
||||
@@ -30,16 +30,17 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract
|
||||
private biometricStateService: BiometricStateService,
|
||||
private accountService: AccountService,
|
||||
private logService: LogService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
async startProcessReload(authService: AuthService): Promise<void> {
|
||||
async startProcessReload(): Promise<void> {
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
if (accounts != null) {
|
||||
const keys = Object.keys(accounts);
|
||||
if (keys.length > 0) {
|
||||
for (const userId of keys) {
|
||||
let status = await firstValueFrom(authService.authStatusFor$(userId as UserId));
|
||||
status = await authService.getAuthStatus(userId);
|
||||
let status = await firstValueFrom(this.authService.authStatusFor$(userId as UserId));
|
||||
status = await this.authService.getAuthStatus(userId);
|
||||
if (status === AuthenticationStatus.Unlocked) {
|
||||
this.logService.info(
|
||||
"[Process Reload Service] User unlocked, preventing process reload",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export abstract class VaultTimeoutService {
|
||||
abstract checkVaultTimeout(): Promise<void>;
|
||||
abstract lock(userId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -5,31 +5,17 @@ import { BehaviorSubject, from, of } from "rxjs";
|
||||
|
||||
// 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 { CollectionService } from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutService } from "@bitwarden/auth/common";
|
||||
// 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 { BiometricsService } from "@bitwarden/key-management";
|
||||
import { StateService } from "@bitwarden/state";
|
||||
import { LockService, LogoutService } from "@bitwarden/auth/common";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
|
||||
import { AccountInfo } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../../platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { TaskSchedulerService } from "../../../platform/scheduling";
|
||||
import { StateEventRunnerService } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SearchService } from "../../../vault/abstractions/search.service";
|
||||
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
|
||||
|
||||
@@ -38,23 +24,13 @@ import { VaultTimeoutService } from "./vault-timeout.service";
|
||||
|
||||
describe("VaultTimeoutService", () => {
|
||||
let accountService: FakeAccountService;
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let cipherService: MockProxy<CipherService>;
|
||||
let folderService: MockProxy<FolderService>;
|
||||
let collectionService: MockProxy<CollectionService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let searchService: MockProxy<SearchService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
|
||||
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let biometricsService: MockProxy<BiometricsService>;
|
||||
let lockService: MockProxy<LockService>;
|
||||
let logoutService: MockProxy<LogoutService>;
|
||||
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
|
||||
|
||||
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
|
||||
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
|
||||
@@ -65,25 +41,14 @@ describe("VaultTimeoutService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
cipherService = mock();
|
||||
folderService = mock();
|
||||
collectionService = mock();
|
||||
platformUtilsService = mock();
|
||||
messagingService = mock();
|
||||
searchService = mock();
|
||||
stateService = mock();
|
||||
tokenService = mock();
|
||||
authService = mock();
|
||||
vaultTimeoutSettingsService = mock();
|
||||
stateEventRunnerService = mock();
|
||||
taskSchedulerService = mock<TaskSchedulerService>();
|
||||
lockService = mock<LockService>();
|
||||
logService = mock<LogService>();
|
||||
biometricsService = mock<BiometricsService>();
|
||||
logoutService = mock<LogoutService>();
|
||||
|
||||
lockedCallback = jest.fn();
|
||||
|
||||
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
|
||||
@@ -94,22 +59,12 @@ describe("VaultTimeoutService", () => {
|
||||
|
||||
vaultTimeoutService = new VaultTimeoutService(
|
||||
accountService,
|
||||
masterPasswordService,
|
||||
cipherService,
|
||||
folderService,
|
||||
collectionService,
|
||||
platformUtilsService,
|
||||
messagingService,
|
||||
searchService,
|
||||
stateService,
|
||||
tokenService,
|
||||
authService,
|
||||
vaultTimeoutSettingsService,
|
||||
stateEventRunnerService,
|
||||
taskSchedulerService,
|
||||
logService,
|
||||
biometricsService,
|
||||
lockedCallback,
|
||||
lockService,
|
||||
logoutService,
|
||||
);
|
||||
});
|
||||
@@ -145,9 +100,6 @@ describe("VaultTimeoutService", () => {
|
||||
authService.getAuthStatus.mockImplementation((userId) => {
|
||||
return Promise.resolve(accounts[userId]?.authStatus);
|
||||
});
|
||||
tokenService.hasAccessToken$.mockImplementation((userId) => {
|
||||
return of(accounts[userId]?.isAuthenticated ?? false);
|
||||
});
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation((userId) => {
|
||||
return new BehaviorSubject<VaultTimeout>(accounts[userId]?.vaultTimeout);
|
||||
@@ -203,13 +155,7 @@ describe("VaultTimeoutService", () => {
|
||||
};
|
||||
|
||||
const expectUserToHaveLocked = (userId: string) => {
|
||||
// This does NOT assert all the things that the lock process does
|
||||
expect(tokenService.hasAccessToken$).toHaveBeenCalledWith(userId);
|
||||
expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId);
|
||||
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId });
|
||||
expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId);
|
||||
expect(cipherService.clearCache).toHaveBeenCalledWith(userId);
|
||||
expect(lockedCallback).toHaveBeenCalledWith(userId);
|
||||
expect(lockService.lock).toHaveBeenCalledWith(userId);
|
||||
};
|
||||
|
||||
const expectUserToHaveLoggedOut = (userId: string) => {
|
||||
@@ -217,7 +163,7 @@ describe("VaultTimeoutService", () => {
|
||||
};
|
||||
|
||||
const expectNoAction = (userId: string) => {
|
||||
expect(lockedCallback).not.toHaveBeenCalledWith(userId);
|
||||
expect(lockService.lock).not.toHaveBeenCalledWith(userId);
|
||||
expect(logoutService.logout).not.toHaveBeenCalledWith(userId, "vaultTimeout");
|
||||
};
|
||||
|
||||
@@ -347,12 +293,8 @@ describe("VaultTimeoutService", () => {
|
||||
expectNoAction("1");
|
||||
expectUserToHaveLocked("2");
|
||||
|
||||
// Active users should have additional steps ran
|
||||
expect(searchService.clearIndex).toHaveBeenCalled();
|
||||
expect(folderService.clearDecryptedFolderState).toHaveBeenCalled();
|
||||
|
||||
expectUserToHaveLoggedOut("3"); // They have chosen logout as their action and it's available, log them out
|
||||
expectUserToHaveLoggedOut("4"); // They may have had lock as their chosen action but it's not available to them so logout
|
||||
expectUserToHaveLocked("4"); // They don't have lock available. But this is handled in lock service so we do not check for logout here
|
||||
});
|
||||
|
||||
it("should lock an account if they haven't been active passed their vault timeout even if a view is open when they are not the active user.", async () => {
|
||||
@@ -392,70 +334,4 @@ describe("VaultTimeoutService", () => {
|
||||
expectNoAction("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lock", () => {
|
||||
const setupLock = () => {
|
||||
setupAccounts(
|
||||
{
|
||||
user1: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
},
|
||||
user2: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: "user1",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
it("should call state event runner with currently active user if no user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
await vaultTimeoutService.lock();
|
||||
|
||||
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
|
||||
});
|
||||
|
||||
it("should call locked callback with the locking user if no userID is passed in.", async () => {
|
||||
setupLock();
|
||||
|
||||
await vaultTimeoutService.lock();
|
||||
|
||||
expect(lockedCallback).toHaveBeenCalledWith("user1");
|
||||
});
|
||||
|
||||
it("should call state event runner with user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
const user2 = "user2" as UserId;
|
||||
|
||||
await vaultTimeoutService.lock(user2);
|
||||
|
||||
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", user2);
|
||||
});
|
||||
|
||||
it("should call messaging service locked message with user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
const user2 = "user2" as UserId;
|
||||
|
||||
await vaultTimeoutService.lock(user2);
|
||||
|
||||
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: user2 });
|
||||
});
|
||||
|
||||
it("should call locked callback with user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
const user2 = "user2" as UserId;
|
||||
|
||||
await vaultTimeoutService.lock(user2);
|
||||
|
||||
expect(lockedCallback).toHaveBeenCalledWith(user2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
import { combineLatest, concatMap, firstValueFrom } from "rxjs";
|
||||
|
||||
// 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 { CollectionService } from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutService } from "@bitwarden/auth/common";
|
||||
// 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 { BiometricsService } from "@bitwarden/key-management";
|
||||
import { LockService, LogoutService } from "@bitwarden/auth/common";
|
||||
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../../platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { TaskSchedulerService, ScheduledTaskNames } from "../../../platform/scheduling";
|
||||
import { StateEventRunnerService } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SearchService } from "../../../vault/abstractions/search.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
|
||||
import { VaultTimeoutSettingsService } from "../abstractions/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../abstractions/vault-timeout.service";
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
@@ -36,22 +22,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private cipherService: CipherService,
|
||||
private folderService: FolderService,
|
||||
private collectionService: CollectionService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
private messagingService: MessagingService,
|
||||
private searchService: SearchService,
|
||||
private stateService: StateService,
|
||||
private tokenService: TokenService,
|
||||
private authService: AuthService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private stateEventRunnerService: StateEventRunnerService,
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
protected logService: LogService,
|
||||
private biometricService: BiometricsService,
|
||||
private lockedCallback: (userId: UserId) => Promise<void> = null,
|
||||
private lockService: LockService,
|
||||
private logoutService: LogoutService,
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
@@ -104,64 +80,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
);
|
||||
}
|
||||
|
||||
async lock(userId?: UserId): Promise<void> {
|
||||
await this.biometricService.setShouldAutopromptNow(false);
|
||||
|
||||
const lockingUserId =
|
||||
userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
|
||||
|
||||
const authed = await firstValueFrom(this.tokenService.hasAccessToken$(lockingUserId));
|
||||
if (!authed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableActions = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
|
||||
);
|
||||
const supportsLock = availableActions.includes(VaultTimeoutAction.Lock);
|
||||
if (!supportsLock) {
|
||||
await this.logoutService.logout(userId, "vaultTimeout");
|
||||
}
|
||||
|
||||
// HACK: Start listening for the transition of the locking user from something to the locked state.
|
||||
// This is very much a hack to ensure that the authentication status to retrievable right after
|
||||
// it does its work. Particularly the `lockedCallback` and `"locked"` message. Instead
|
||||
// lockedCallback should be deprecated and people should subscribe and react to `authStatusFor$` themselves.
|
||||
const lockPromise = firstValueFrom(
|
||||
this.authService.authStatusFor$(lockingUserId).pipe(
|
||||
filter((authStatus) => authStatus === AuthenticationStatus.Locked),
|
||||
timeout({
|
||||
first: 5_000,
|
||||
with: () => {
|
||||
throw new Error("The lock process did not complete in a reasonable amount of time.");
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.searchService.clearIndex(lockingUserId);
|
||||
|
||||
await this.folderService.clearDecryptedFolderState(lockingUserId);
|
||||
await this.masterPasswordService.clearMasterKey(lockingUserId);
|
||||
|
||||
await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
|
||||
|
||||
await this.cipherService.clearCache(lockingUserId);
|
||||
|
||||
await this.stateEventRunnerService.handleEvent("lock", lockingUserId);
|
||||
|
||||
// HACK: Sit here and wait for the the auth status to transition to `Locked`
|
||||
// to ensure the message and lockedCallback will get the correct status
|
||||
// if/when they call it.
|
||||
await lockPromise;
|
||||
|
||||
this.messagingService.send("locked", { userId: lockingUserId });
|
||||
|
||||
if (this.lockedCallback != null) {
|
||||
await this.lockedCallback(lockingUserId);
|
||||
}
|
||||
}
|
||||
|
||||
private async shouldLock(
|
||||
userId: string,
|
||||
lastActive: Date,
|
||||
@@ -206,6 +124,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
);
|
||||
timeoutAction === VaultTimeoutAction.LogOut
|
||||
? await this.logoutService.logout(userId, "vaultTimeout")
|
||||
: await this.lock(userId);
|
||||
: await this.lockService.lock(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.ex
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { SshKeyExport } from "./ssh-key.export";
|
||||
|
||||
describe("Cipher Export", () => {
|
||||
describe("toView", () => {
|
||||
it.each([[null], [undefined]])(
|
||||
@@ -41,4 +43,36 @@ describe("Cipher Export", () => {
|
||||
expect(resultView.deletedDate).toEqual(request.deletedDate);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SshKeyExport.toView", () => {
|
||||
const validSshKey = {
|
||||
privateKey: "PRIVATE_KEY",
|
||||
publicKey: "PUBLIC_KEY",
|
||||
keyFingerprint: "FINGERPRINT",
|
||||
};
|
||||
|
||||
it.each([null, undefined, "", " "])("should throw when privateKey is %p", (value) => {
|
||||
const sshKey = { ...validSshKey, privateKey: value } as any;
|
||||
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key private key is required.");
|
||||
});
|
||||
|
||||
it.each([null, undefined, "", " "])("should throw when publicKey is %p", (value) => {
|
||||
const sshKey = { ...validSshKey, publicKey: value } as any;
|
||||
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key public key is required.");
|
||||
});
|
||||
|
||||
it.each([null, undefined, "", " "])("should throw when keyFingerprint is %p", (value) => {
|
||||
const sshKey = { ...validSshKey, keyFingerprint: value } as any;
|
||||
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key fingerprint is required.");
|
||||
});
|
||||
|
||||
it("should succeed with valid inputs", () => {
|
||||
const sshKey = { ...validSshKey };
|
||||
const result = SshKeyExport.toView(sshKey);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.privateKey).toBe(validSshKey.privateKey);
|
||||
expect(result?.publicKey).toBe(validSshKey.publicKey);
|
||||
expect(result?.keyFingerprint).toBe(validSshKey.keyFingerprint);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,22 @@ export class SshKeyExport {
|
||||
return req;
|
||||
}
|
||||
|
||||
static toView(req: SshKeyExport, view = new SshKeyView()) {
|
||||
static toView(req?: SshKeyExport, view = new SshKeyView()): SshKeyView | undefined {
|
||||
if (req == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!req.privateKey || req.privateKey.trim() === "") {
|
||||
throw new Error("SSH key private key is required.");
|
||||
}
|
||||
if (!req.publicKey || req.publicKey.trim() === "") {
|
||||
throw new Error("SSH key public key is required.");
|
||||
}
|
||||
if (!req.keyFingerprint || req.keyFingerprint.trim() === "") {
|
||||
throw new Error("SSH key fingerprint is required.");
|
||||
}
|
||||
|
||||
view.privateKey = req.privateKey;
|
||||
view.publicKey = req.publicKey;
|
||||
view.keyFingerprint = req.keyFingerprint;
|
||||
|
||||
@@ -80,9 +80,4 @@ export class AuditService implements AuditServiceAbstraction {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
async getKnownPhishingDomains(): Promise<string[]> {
|
||||
const response = await this.apiService.send("GET", "/phishing-domains", null, true, true);
|
||||
return response as string[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CipherRiskResult, CipherId } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { isPasswordAtRisk } from "./cipher-risk.service";
|
||||
|
||||
describe("isPasswordAtRisk", () => {
|
||||
const mockId = "00000000-0000-0000-0000-000000000000" as unknown as CipherId;
|
||||
|
||||
const createRisk = (overrides: Partial<CipherRiskResult> = {}): CipherRiskResult => ({
|
||||
id: mockId,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("exposed password risk", () => {
|
||||
it.each([
|
||||
{ value: 5, expected: true, desc: "found with value > 0" },
|
||||
{ value: 0, expected: false, desc: "found but value is 0" },
|
||||
])("should return $expected when password is $desc", ({ value, expected }) => {
|
||||
const risk = createRisk({ exposed_result: { type: "Found", value } });
|
||||
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should return false when password is not checked", () => {
|
||||
expect(isPasswordAtRisk(createRisk())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("password reuse risk", () => {
|
||||
it.each([
|
||||
{ count: 2, expected: true, desc: "reused (reuse_count > 1)" },
|
||||
{ count: 1, expected: false, desc: "not reused" },
|
||||
{ count: undefined, expected: false, desc: "undefined" },
|
||||
])("should return $expected when reuse_count is $desc", ({ count, expected }) => {
|
||||
const risk = createRisk({ reuse_count: count });
|
||||
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("password strength risk", () => {
|
||||
it.each([
|
||||
{ strength: 0, expected: true },
|
||||
{ strength: 1, expected: true },
|
||||
{ strength: 2, expected: true },
|
||||
{ strength: 3, expected: false },
|
||||
{ strength: 4, expected: false },
|
||||
])("should return $expected when password strength is $strength", ({ strength, expected }) => {
|
||||
const risk = createRisk({ password_strength: strength });
|
||||
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple risk factors", () => {
|
||||
it.each<{ desc: string; overrides: Partial<CipherRiskResult>; expected: boolean }>([
|
||||
{
|
||||
desc: "exposed and reused",
|
||||
overrides: {
|
||||
exposed_result: { type: "Found" as const, value: 3 },
|
||||
reuse_count: 2,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "reused and weak strength",
|
||||
overrides: { password_strength: 2, reuse_count: 2 },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "all three risk factors",
|
||||
overrides: {
|
||||
password_strength: 1,
|
||||
exposed_result: { type: "Found" as const, value: 10 },
|
||||
reuse_count: 3,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "no risk factors",
|
||||
overrides: { reuse_count: undefined },
|
||||
expected: false,
|
||||
},
|
||||
])("should return $expected when $desc present", ({ overrides, expected }) => {
|
||||
const risk = createRisk(overrides);
|
||||
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import type {
|
||||
CipherRiskResult,
|
||||
CipherRiskOptions,
|
||||
ExposedPasswordResult,
|
||||
PasswordReuseMap,
|
||||
CipherId,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserId, CipherId } from "../../types/guid";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
|
||||
export abstract class CipherRiskService {
|
||||
@@ -51,5 +49,21 @@ export abstract class CipherRiskService {
|
||||
abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap>;
|
||||
}
|
||||
|
||||
// Re-export SDK types for convenience
|
||||
export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap };
|
||||
/**
|
||||
* Evaluates if a password represented by a CipherRiskResult is considered at risk.
|
||||
*
|
||||
* A password is considered at risk if any of the following conditions are true:
|
||||
* - The password has been exposed in data breaches
|
||||
* - The password is reused across multiple ciphers
|
||||
* - The password has weak strength (password_strength < 3)
|
||||
*
|
||||
* @param risk - The CipherRiskResult to evaluate
|
||||
* @returns true if the password is at risk, false otherwise
|
||||
*/
|
||||
export function isPasswordAtRisk(risk: CipherRiskResult): boolean {
|
||||
return (
|
||||
(risk.exposed_result.type === "Found" && risk.exposed_result.value > 0) ||
|
||||
(risk.reuse_count ?? 1) > 1 ||
|
||||
risk.password_strength < 3
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AttachmentResponse } from "../response/attachment.response";
|
||||
|
||||
export class AttachmentData {
|
||||
id: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
key: string;
|
||||
size: string;
|
||||
sizeName: string;
|
||||
id?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
key?: string;
|
||||
size?: string;
|
||||
sizeName?: string;
|
||||
|
||||
constructor(response?: AttachmentResponse) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CardApi } from "../api/card.api";
|
||||
|
||||
export class CardData {
|
||||
cardholderName: string;
|
||||
brand: string;
|
||||
number: string;
|
||||
expMonth: string;
|
||||
expYear: string;
|
||||
code: string;
|
||||
cardholderName?: string;
|
||||
brand?: string;
|
||||
number?: string;
|
||||
expMonth?: string;
|
||||
expYear?: string;
|
||||
code?: string;
|
||||
|
||||
constructor(data?: CardApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
|
||||
@@ -17,18 +15,18 @@ import { SecureNoteData } from "./secure-note.data";
|
||||
import { SshKeyData } from "./ssh-key.data";
|
||||
|
||||
export class CipherData {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
folderId: string;
|
||||
edit: boolean;
|
||||
viewPassword: boolean;
|
||||
permissions: CipherPermissionsApi;
|
||||
organizationUseTotp: boolean;
|
||||
favorite: boolean;
|
||||
id: string = "";
|
||||
organizationId?: string;
|
||||
folderId?: string;
|
||||
edit: boolean = false;
|
||||
viewPassword: boolean = true;
|
||||
permissions?: CipherPermissionsApi;
|
||||
organizationUseTotp: boolean = false;
|
||||
favorite: boolean = false;
|
||||
revisionDate: string;
|
||||
type: CipherType;
|
||||
name: string;
|
||||
notes: string;
|
||||
type: CipherType = CipherType.Login;
|
||||
name: string = "";
|
||||
notes?: string;
|
||||
login?: LoginData;
|
||||
secureNote?: SecureNoteData;
|
||||
card?: CardData;
|
||||
@@ -39,13 +37,14 @@ export class CipherData {
|
||||
passwordHistory?: PasswordHistoryData[];
|
||||
collectionIds?: string[];
|
||||
creationDate: string;
|
||||
deletedDate: string | undefined;
|
||||
archivedDate: string | undefined;
|
||||
reprompt: CipherRepromptType;
|
||||
key: string;
|
||||
deletedDate?: string;
|
||||
archivedDate?: string;
|
||||
reprompt: CipherRepromptType = CipherRepromptType.None;
|
||||
key?: string;
|
||||
|
||||
constructor(response?: CipherResponse, collectionIds?: string[]) {
|
||||
if (response == null) {
|
||||
this.creationDate = this.revisionDate = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,7 +100,9 @@ export class CipherData {
|
||||
|
||||
static fromJSON(obj: Jsonify<CipherData>) {
|
||||
const result = Object.assign(new CipherData(), obj);
|
||||
result.permissions = CipherPermissionsApi.fromJSON(obj.permissions);
|
||||
if (obj.permissions != null) {
|
||||
result.permissions = CipherPermissionsApi.fromJSON(obj.permissions);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Fido2CredentialApi } from "../api/fido2-credential.api";
|
||||
|
||||
export class Fido2CredentialData {
|
||||
credentialId: string;
|
||||
keyType: "public-key";
|
||||
keyAlgorithm: "ECDSA";
|
||||
keyCurve: "P-256";
|
||||
keyValue: string;
|
||||
rpId: string;
|
||||
userHandle: string;
|
||||
userName: string;
|
||||
counter: string;
|
||||
rpName: string;
|
||||
userDisplayName: string;
|
||||
discoverable: string;
|
||||
creationDate: string;
|
||||
credentialId!: string;
|
||||
keyType!: string;
|
||||
keyAlgorithm!: string;
|
||||
keyCurve!: string;
|
||||
keyValue!: string;
|
||||
rpId!: string;
|
||||
userHandle?: string;
|
||||
userName?: string;
|
||||
counter!: string;
|
||||
rpName?: string;
|
||||
userDisplayName?: string;
|
||||
discoverable!: string;
|
||||
creationDate!: string;
|
||||
|
||||
constructor(data?: Fido2CredentialApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { FieldType, LinkedIdType } from "../../enums";
|
||||
import { FieldApi } from "../api/field.api";
|
||||
|
||||
export class FieldData {
|
||||
type: FieldType;
|
||||
name: string;
|
||||
value: string;
|
||||
linkedId: LinkedIdType | null;
|
||||
type: FieldType = FieldType.Text;
|
||||
name?: string;
|
||||
value?: string;
|
||||
linkedId?: LinkedIdType;
|
||||
|
||||
constructor(response?: FieldApi) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { IdentityApi } from "../api/identity.api";
|
||||
|
||||
export class IdentityData {
|
||||
title: string;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
address3: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
company: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
ssn: string;
|
||||
username: string;
|
||||
passportNumber: string;
|
||||
licenseNumber: string;
|
||||
title?: string;
|
||||
firstName?: string;
|
||||
middleName?: string;
|
||||
lastName?: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
address3?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
company?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
ssn?: string;
|
||||
username?: string;
|
||||
passportNumber?: string;
|
||||
licenseNumber?: string;
|
||||
|
||||
constructor(data?: IdentityApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { LoginUriApi } from "../api/login-uri.api";
|
||||
|
||||
export class LoginUriData {
|
||||
uri: string;
|
||||
uriChecksum: string;
|
||||
match: UriMatchStrategySetting = null;
|
||||
uri?: string;
|
||||
uriChecksum?: string;
|
||||
match?: UriMatchStrategySetting;
|
||||
|
||||
constructor(data?: LoginUriApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { LoginApi } from "../api/login.api";
|
||||
|
||||
import { Fido2CredentialData } from "./fido2-credential.data";
|
||||
import { LoginUriData } from "./login-uri.data";
|
||||
|
||||
export class LoginData {
|
||||
uris: LoginUriData[];
|
||||
username: string;
|
||||
password: string;
|
||||
passwordRevisionDate: string;
|
||||
totp: string;
|
||||
autofillOnPageLoad: boolean;
|
||||
uris?: LoginUriData[];
|
||||
username?: string;
|
||||
password?: string;
|
||||
passwordRevisionDate?: string;
|
||||
totp?: string;
|
||||
autofillOnPageLoad?: boolean;
|
||||
fido2Credentials?: Fido2CredentialData[];
|
||||
|
||||
constructor(data?: LoginApi) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { PasswordHistoryResponse } from "../response/password-history.response";
|
||||
|
||||
export class PasswordHistoryData {
|
||||
password: string;
|
||||
lastUsedDate: string;
|
||||
password!: string;
|
||||
lastUsedDate!: string;
|
||||
|
||||
constructor(response?: PasswordHistoryResponse) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SecureNoteType } from "../../enums";
|
||||
import { SecureNoteApi } from "../api/secure-note.api";
|
||||
|
||||
export class SecureNoteData {
|
||||
type: SecureNoteType;
|
||||
type: SecureNoteType = SecureNoteType.Generic;
|
||||
|
||||
constructor(data?: SecureNoteApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
|
||||
export class SshKeyData {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
keyFingerprint: string;
|
||||
privateKey!: string;
|
||||
publicKey!: string;
|
||||
keyFingerprint!: string;
|
||||
|
||||
constructor(data?: SshKeyApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -39,6 +39,12 @@ describe("Attachment", () => {
|
||||
key: undefined,
|
||||
fileName: undefined,
|
||||
});
|
||||
expect(data.id).toBeUndefined();
|
||||
expect(data.url).toBeUndefined();
|
||||
expect(data.fileName).toBeUndefined();
|
||||
expect(data.key).toBeUndefined();
|
||||
expect(data.size).toBeUndefined();
|
||||
expect(data.sizeName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
|
||||
@@ -29,6 +29,13 @@ describe("Card", () => {
|
||||
expYear: undefined,
|
||||
code: undefined,
|
||||
});
|
||||
|
||||
expect(data.cardholderName).toBeUndefined();
|
||||
expect(data.brand).toBeUndefined();
|
||||
expect(data.number).toBeUndefined();
|
||||
expect(data.expMonth).toBeUndefined();
|
||||
expect(data.expYear).toBeUndefined();
|
||||
expect(data.code).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
|
||||
@@ -44,22 +44,22 @@ describe("Cipher DTO", () => {
|
||||
const data = new CipherData();
|
||||
const cipher = new Cipher(data);
|
||||
|
||||
expect(cipher.id).toBeUndefined();
|
||||
expect(cipher.id).toEqual("");
|
||||
expect(cipher.organizationId).toBeUndefined();
|
||||
expect(cipher.folderId).toBeUndefined();
|
||||
expect(cipher.name).toBeInstanceOf(EncString);
|
||||
expect(cipher.notes).toBeUndefined();
|
||||
expect(cipher.type).toBeUndefined();
|
||||
expect(cipher.favorite).toBeUndefined();
|
||||
expect(cipher.organizationUseTotp).toBeUndefined();
|
||||
expect(cipher.edit).toBeUndefined();
|
||||
expect(cipher.viewPassword).toBeUndefined();
|
||||
expect(cipher.type).toEqual(CipherType.Login);
|
||||
expect(cipher.favorite).toEqual(false);
|
||||
expect(cipher.organizationUseTotp).toEqual(false);
|
||||
expect(cipher.edit).toEqual(false);
|
||||
expect(cipher.viewPassword).toEqual(true);
|
||||
expect(cipher.revisionDate).toBeInstanceOf(Date);
|
||||
expect(cipher.collectionIds).toEqual([]);
|
||||
expect(cipher.localData).toBeUndefined();
|
||||
expect(cipher.creationDate).toBeInstanceOf(Date);
|
||||
expect(cipher.deletedDate).toBeUndefined();
|
||||
expect(cipher.reprompt).toBeUndefined();
|
||||
expect(cipher.reprompt).toEqual(CipherRepromptType.None);
|
||||
expect(cipher.attachments).toBeUndefined();
|
||||
expect(cipher.fields).toBeUndefined();
|
||||
expect(cipher.passwordHistory).toBeUndefined();
|
||||
@@ -836,6 +836,38 @@ describe("Cipher DTO", () => {
|
||||
expect(actual).toBeInstanceOf(Cipher);
|
||||
});
|
||||
|
||||
it("handles null permissions correctly without calling CipherPermissionsApi constructor", () => {
|
||||
const spy = jest.spyOn(CipherPermissionsApi.prototype, "constructor" as any);
|
||||
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
|
||||
const actual = Cipher.fromJSON({
|
||||
name: "myName",
|
||||
revisionDate: revisionDate.toISOString(),
|
||||
permissions: null,
|
||||
} as Jsonify<Cipher>);
|
||||
|
||||
expect(actual.permissions).toBeUndefined();
|
||||
expect(actual).toBeInstanceOf(Cipher);
|
||||
// Verify that CipherPermissionsApi constructor was not called for null permissions
|
||||
expect(spy).not.toHaveBeenCalledWith(null);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("calls CipherPermissionsApi constructor when permissions are provided", () => {
|
||||
const spy = jest.spyOn(CipherPermissionsApi.prototype, "constructor" as any);
|
||||
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
|
||||
const permissionsObj = { delete: true, restore: false };
|
||||
const actual = Cipher.fromJSON({
|
||||
name: "myName",
|
||||
revisionDate: revisionDate.toISOString(),
|
||||
permissions: permissionsObj,
|
||||
} as Jsonify<Cipher>);
|
||||
|
||||
expect(actual.permissions).toBeInstanceOf(CipherPermissionsApi);
|
||||
expect(actual.permissions.delete).toBe(true);
|
||||
expect(actual.permissions.restore).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test.each([
|
||||
// Test description, CipherType, expected output
|
||||
["LoginView", CipherType.Login, { login: "myLogin_fromJSON" }],
|
||||
@@ -1056,6 +1088,7 @@ describe("Cipher DTO", () => {
|
||||
card: undefined,
|
||||
secureNote: undefined,
|
||||
sshKey: undefined,
|
||||
data: undefined,
|
||||
favorite: false,
|
||||
reprompt: SdkCipherRepromptType.None,
|
||||
organizationUseTotp: true,
|
||||
|
||||
@@ -421,6 +421,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
card: undefined,
|
||||
secureNote: undefined,
|
||||
sshKey: undefined,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
switch (this.type) {
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("Field", () => {
|
||||
const field = new Field(data);
|
||||
|
||||
expect(field).toEqual({
|
||||
type: undefined,
|
||||
type: FieldType.Text,
|
||||
name: undefined,
|
||||
value: undefined,
|
||||
linkedId: undefined,
|
||||
|
||||
@@ -53,6 +53,27 @@ describe("Identity", () => {
|
||||
title: undefined,
|
||||
username: undefined,
|
||||
});
|
||||
|
||||
expect(data).toEqual({
|
||||
title: undefined,
|
||||
firstName: undefined,
|
||||
middleName: undefined,
|
||||
lastName: undefined,
|
||||
address1: undefined,
|
||||
address2: undefined,
|
||||
address3: undefined,
|
||||
city: undefined,
|
||||
state: undefined,
|
||||
postalCode: undefined,
|
||||
country: undefined,
|
||||
company: undefined,
|
||||
email: undefined,
|
||||
phone: undefined,
|
||||
ssn: undefined,
|
||||
username: undefined,
|
||||
passportNumber: undefined,
|
||||
licenseNumber: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mockEnc, mockFromJson } from "../../../../spec";
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { UriMatchStrategy } from "../../../models/domain/domain-service";
|
||||
import { LoginUriApi } from "../api/login-uri.api";
|
||||
import { LoginUriData } from "../data/login-uri.data";
|
||||
|
||||
import { LoginUri } from "./login-uri";
|
||||
@@ -31,6 +32,9 @@ describe("LoginUri", () => {
|
||||
uri: undefined,
|
||||
uriChecksum: undefined,
|
||||
});
|
||||
expect(data.uri).toBeUndefined();
|
||||
expect(data.uriChecksum).toBeUndefined();
|
||||
expect(data.match).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
@@ -61,6 +65,23 @@ describe("LoginUri", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("handle null match", () => {
|
||||
const apiData = Object.assign(new LoginUriApi(), {
|
||||
uri: "testUri",
|
||||
uriChecksum: "testChecksum",
|
||||
match: null,
|
||||
});
|
||||
|
||||
const loginUriData = new LoginUriData(apiData);
|
||||
|
||||
// The data model stores it as-is (null or undefined)
|
||||
expect(loginUriData.match).toBeNull();
|
||||
|
||||
// But the domain model converts null to undefined
|
||||
const loginUri = new LoginUri(loginUriData);
|
||||
expect(loginUri.match).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("validateChecksum", () => {
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
|
||||
@@ -118,7 +139,7 @@ describe("LoginUri", () => {
|
||||
});
|
||||
|
||||
describe("SDK Login Uri Mapping", () => {
|
||||
it("should map to SDK login uri", () => {
|
||||
it("maps to SDK login uri", () => {
|
||||
const loginUri = new LoginUri(data);
|
||||
const sdkLoginUri = loginUri.toSdkLoginUri();
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ describe("Login DTO", () => {
|
||||
password: undefined,
|
||||
totp: undefined,
|
||||
});
|
||||
|
||||
expect(data.username).toBeUndefined();
|
||||
expect(data.password).toBeUndefined();
|
||||
expect(data.passwordRevisionDate).toBeUndefined();
|
||||
expect(data.totp).toBeUndefined();
|
||||
expect(data.autofillOnPageLoad).toBeUndefined();
|
||||
expect(data.uris).toBeUndefined();
|
||||
expect(data.fido2Credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert from full LoginData", () => {
|
||||
|
||||
@@ -111,10 +111,7 @@ export class Login extends Domain {
|
||||
});
|
||||
|
||||
if (this.uris != null && this.uris.length > 0) {
|
||||
l.uris = [];
|
||||
this.uris.forEach((u) => {
|
||||
l.uris.push(u.toLoginUriData());
|
||||
});
|
||||
l.uris = this.uris.map((u) => u.toLoginUriData());
|
||||
}
|
||||
|
||||
if (this.fido2Credentials != null && this.fido2Credentials.length > 0) {
|
||||
|
||||
@@ -20,6 +20,9 @@ describe("Password", () => {
|
||||
expect(password).toBeInstanceOf(Password);
|
||||
expect(password.password).toBeInstanceOf(EncString);
|
||||
expect(password.lastUsedDate).toBeInstanceOf(Date);
|
||||
|
||||
expect(data.password).toBeUndefined();
|
||||
expect(data.lastUsedDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
@@ -83,4 +86,47 @@ describe("Password", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkPasswordHistory", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates Password from SDK object", () => {
|
||||
const sdkPasswordHistory = {
|
||||
password: "2.encPassword|encryptedData" as EncryptedString,
|
||||
lastUsedDate: "2022-01-31T12:00:00.000Z",
|
||||
};
|
||||
|
||||
const password = Password.fromSdkPasswordHistory(sdkPasswordHistory);
|
||||
|
||||
expect(password).toBeInstanceOf(Password);
|
||||
expect(password?.password).toBeInstanceOf(EncString);
|
||||
expect(password?.password.encryptedString).toBe("2.encPassword|encryptedData");
|
||||
expect(password?.lastUsedDate).toEqual(new Date("2022-01-31T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("returns undefined for null input", () => {
|
||||
const result = Password.fromSdkPasswordHistory(null as any);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for undefined input", () => {
|
||||
const result = Password.fromSdkPasswordHistory(undefined);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles empty SDK object", () => {
|
||||
const sdkPasswordHistory = {
|
||||
password: "" as EncryptedString,
|
||||
lastUsedDate: "",
|
||||
};
|
||||
|
||||
const password = Password.fromSdkPasswordHistory(sdkPasswordHistory);
|
||||
|
||||
expect(password).toBeInstanceOf(Password);
|
||||
expect(password?.password).toBeInstanceOf(EncString);
|
||||
expect(password?.lastUsedDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,22 +16,27 @@ describe("SecureNote", () => {
|
||||
const data = new SecureNoteData();
|
||||
const secureNote = new SecureNote(data);
|
||||
|
||||
expect(secureNote).toEqual({
|
||||
type: undefined,
|
||||
});
|
||||
expect(data).toBeDefined();
|
||||
expect(secureNote).toEqual({ type: SecureNoteType.Generic });
|
||||
expect(data.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("Convert from undefined", () => {
|
||||
const data = new SecureNoteData(undefined);
|
||||
expect(data.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
const secureNote = new SecureNote(data);
|
||||
|
||||
expect(secureNote).toEqual({
|
||||
type: 0,
|
||||
});
|
||||
expect(secureNote).toEqual({ type: 0 });
|
||||
expect(data.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("toSecureNoteData", () => {
|
||||
const secureNote = new SecureNote(data);
|
||||
expect(secureNote.toSecureNoteData()).toEqual(data);
|
||||
expect(secureNote.toSecureNoteData().type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("Decrypt", async () => {
|
||||
@@ -49,6 +54,14 @@ describe("SecureNote", () => {
|
||||
it("returns undefined if object is null", () => {
|
||||
expect(SecureNote.fromJSON(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SecureNote instance from JSON object", () => {
|
||||
const jsonObj = { type: SecureNoteType.Generic };
|
||||
const result = SecureNote.fromJSON(jsonObj);
|
||||
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
expect(result.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSdkSecureNote", () => {
|
||||
@@ -63,4 +76,71 @@ describe("SecureNote", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkSecureNote", () => {
|
||||
it("returns undefined when null is provided", () => {
|
||||
const result = SecureNote.fromSdkSecureNote(null);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when undefined is provided", () => {
|
||||
const result = SecureNote.fromSdkSecureNote(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SecureNote with Generic type from SDK object", () => {
|
||||
const sdkSecureNote = {
|
||||
type: SecureNoteType.Generic,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
expect(result.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("preserves the type value from SDK object", () => {
|
||||
const sdkSecureNote = {
|
||||
type: SecureNoteType.Generic,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result.type).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a new SecureNote instance", () => {
|
||||
const sdkSecureNote = {
|
||||
type: SecureNoteType.Generic,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result).not.toBe(sdkSecureNote);
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
});
|
||||
|
||||
it("handles SDK object with undefined type", () => {
|
||||
const sdkSecureNote = {
|
||||
type: undefined as SecureNoteType,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
expect(result.type).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns symmetric with toSdkSecureNote", () => {
|
||||
const original = new SecureNote();
|
||||
original.type = SecureNoteType.Generic;
|
||||
|
||||
const sdkFormat = original.toSdkSecureNote();
|
||||
const reconstructed = SecureNote.fromSdkSecureNote(sdkFormat);
|
||||
|
||||
expect(reconstructed.type).toBe(original.type);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { EncString as SdkEncString, SshKey as SdkSshKey } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { mockEnc } from "../../../../spec";
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
@@ -37,6 +38,9 @@ describe("Sshkey", () => {
|
||||
expect(sshKey.privateKey).toBeInstanceOf(EncString);
|
||||
expect(sshKey.publicKey).toBeInstanceOf(EncString);
|
||||
expect(sshKey.keyFingerprint).toBeInstanceOf(EncString);
|
||||
expect(data.privateKey).toBeUndefined();
|
||||
expect(data.publicKey).toBeUndefined();
|
||||
expect(data.keyFingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toSshKeyData", () => {
|
||||
@@ -64,6 +68,21 @@ describe("Sshkey", () => {
|
||||
it("returns undefined if object is null", () => {
|
||||
expect(SshKey.fromJSON(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SshKey instance from JSON object", () => {
|
||||
const jsonObj = {
|
||||
privateKey: "2.privateKey|encryptedData",
|
||||
publicKey: "2.publicKey|encryptedData",
|
||||
keyFingerprint: "2.keyFingerprint|encryptedData",
|
||||
};
|
||||
|
||||
const result = SshKey.fromJSON(jsonObj);
|
||||
|
||||
expect(result).toBeInstanceOf(SshKey);
|
||||
expect(result.privateKey).toBeDefined();
|
||||
expect(result.publicKey).toBeDefined();
|
||||
expect(result.keyFingerprint).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSdkSshKey", () => {
|
||||
@@ -78,4 +97,58 @@ describe("Sshkey", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkSshKey", () => {
|
||||
it("returns undefined when null is provided", () => {
|
||||
const result = SshKey.fromSdkSshKey(null);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when undefined is provided", () => {
|
||||
const result = SshKey.fromSdkSshKey(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SshKey from SDK object", () => {
|
||||
const sdkSshKey: SdkSshKey = {
|
||||
privateKey: "2.privateKey|encryptedData" as SdkEncString,
|
||||
publicKey: "2.publicKey|encryptedData" as SdkEncString,
|
||||
fingerprint: "2.keyFingerprint|encryptedData" as SdkEncString,
|
||||
};
|
||||
|
||||
const result = SshKey.fromSdkSshKey(sdkSshKey);
|
||||
|
||||
expect(result).toBeInstanceOf(SshKey);
|
||||
expect(result.privateKey).toBeDefined();
|
||||
expect(result.publicKey).toBeDefined();
|
||||
expect(result.keyFingerprint).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates a new SshKey instance", () => {
|
||||
const sdkSshKey: SdkSshKey = {
|
||||
privateKey: "2.privateKey|encryptedData" as SdkEncString,
|
||||
publicKey: "2.publicKey|encryptedData" as SdkEncString,
|
||||
fingerprint: "2.keyFingerprint|encryptedData" as SdkEncString,
|
||||
};
|
||||
|
||||
const result = SshKey.fromSdkSshKey(sdkSshKey);
|
||||
|
||||
expect(result).not.toBe(sdkSshKey);
|
||||
expect(result).toBeInstanceOf(SshKey);
|
||||
});
|
||||
|
||||
it("is symmetric with toSdkSshKey", () => {
|
||||
const original = new SshKey(data);
|
||||
const sdkFormat = original.toSdkSshKey();
|
||||
const reconstructed = SshKey.fromSdkSshKey(sdkFormat);
|
||||
|
||||
expect(reconstructed.privateKey.encryptedString).toBe(original.privateKey.encryptedString);
|
||||
expect(reconstructed.publicKey.encryptedString).toBe(original.publicKey.encryptedString);
|
||||
expect(reconstructed.keyFingerprint.encryptedString).toBe(
|
||||
original.keyFingerprint.encryptedString,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,12 @@ export class CipherView implements View, InitializerMetadata {
|
||||
return this.passwordHistory && this.passwordHistory.length > 0;
|
||||
}
|
||||
|
||||
get hasLoginPassword(): boolean {
|
||||
return (
|
||||
this.type === CipherType.Login && this.login?.password != null && this.login.password !== ""
|
||||
);
|
||||
}
|
||||
|
||||
get hasAttachments(): boolean {
|
||||
return !!this.attachments && this.attachments.length > 0;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal";
|
||||
import type { CipherRiskOptions, CipherRiskResult } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserId, CipherId } from "../../types/guid";
|
||||
import { CipherService } from "../abstractions/cipher.service";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
@@ -19,9 +19,9 @@ describe("DefaultCipherRiskService", () => {
|
||||
let mockCipherService: jest.Mocked<CipherService>;
|
||||
|
||||
const mockUserId = "test-user-id" as UserId;
|
||||
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
|
||||
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3";
|
||||
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4";
|
||||
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2" as CipherId;
|
||||
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3" as CipherId;
|
||||
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4" as CipherId;
|
||||
|
||||
beforeEach(() => {
|
||||
sdkService = new MockSdkService();
|
||||
@@ -534,5 +534,56 @@ describe("DefaultCipherRiskService", () => {
|
||||
// Verify password_reuse_map was called twice (fresh computation each time)
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should wait for a decrypted vault before computing risk", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "password1";
|
||||
|
||||
// Simulate the observable emitting null (undecrypted vault) first, then the decrypted ciphers
|
||||
const cipherViewsSubject = new BehaviorSubject<CipherView[] | null>(null);
|
||||
mockCipherService.cipherViews$.mockReturnValue(
|
||||
cipherViewsSubject as Observable<CipherView[]>,
|
||||
);
|
||||
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// Initiate the async call but don't await yet
|
||||
const computePromise = cipherRiskService.computeCipherRiskForUser(
|
||||
asUuid<CipherId>(mockCipherId1),
|
||||
mockUserId,
|
||||
true,
|
||||
);
|
||||
|
||||
// Simulate a tick to allow the service to process the null emission
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Now emit the actual decrypted ciphers
|
||||
cipherViewsSubject.next([cipher]);
|
||||
|
||||
const result = await computePromise;
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ password: "password1" })],
|
||||
{
|
||||
passwordMap: expect.any(Object),
|
||||
checkExposed: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual(expect.objectContaining({ id: expect.anything() }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import {
|
||||
CipherLoginDetails,
|
||||
CipherRiskOptions,
|
||||
PasswordReuseMap,
|
||||
CipherId,
|
||||
CipherRiskResult,
|
||||
CipherId as SdkCipherId,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserId, CipherId } from "../../types/guid";
|
||||
import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
@@ -52,7 +53,9 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
checkExposed: boolean = true,
|
||||
): Promise<CipherRiskResult> {
|
||||
// Get all ciphers for the user
|
||||
const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId));
|
||||
const allCiphers = await firstValueFrom(
|
||||
this.cipherService.cipherViews$(userId).pipe(filterOutNullish()),
|
||||
);
|
||||
|
||||
// Find the specific cipher
|
||||
const targetCipher = allCiphers?.find((c) => asUuid<CipherId>(c.id) === cipherId);
|
||||
@@ -106,7 +109,7 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
.map(
|
||||
(cipher) =>
|
||||
({
|
||||
id: asUuid<CipherId>(cipher.id),
|
||||
id: asUuid<SdkCipherId>(cipher.id),
|
||||
password: cipher.login.password!,
|
||||
username: cipher.login.username,
|
||||
}) satisfies CipherLoginDetails,
|
||||
|
||||
@@ -51,10 +51,10 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
{
|
||||
useRiskInsights: true,
|
||||
useAccessIntelligence: true,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -70,10 +70,10 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -91,7 +91,7 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: true,
|
||||
useAccessIntelligence: true,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -101,7 +101,7 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -163,7 +163,7 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: true,
|
||||
useAccessIntelligence: true,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -173,7 +173,7 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ export class DefaultTaskService implements TaskService {
|
||||
|
||||
tasksEnabled$ = perUserCache$((userId) => {
|
||||
return this.organizationService.organizations$(userId).pipe(
|
||||
map((orgs) => orgs.some((o) => o.useRiskInsights)),
|
||||
map((orgs) => orgs.some((o) => o.useAccessIntelligence)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user