1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 17:13:24 +00:00

Merge branch 'main' into tools/pm-18793/port-credential-generator-service-to-providers

This commit is contained in:
✨ Audrey ✨
2025-04-01 16:36:44 -04:00
544 changed files with 8197 additions and 6034 deletions

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ListResponse } from "../../../models/response/list.response";
import { PolicyType } from "../../enums";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
@@ -7,19 +5,23 @@ import { Policy } from "../../models/domain/policy";
import { PolicyRequest } from "../../models/request/policy.request";
import { PolicyResponse } from "../../models/response/policy.response";
export class PolicyApiServiceAbstraction {
getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
export abstract class PolicyApiServiceAbstraction {
abstract getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
abstract getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
getPoliciesByToken: (
abstract getPoliciesByToken: (
organizationId: string,
token: string,
email: string,
organizationUserId: string,
) => Promise<Policy[] | undefined>;
getMasterPasswordPolicyOptsForOrgUser: (
abstract getMasterPasswordPolicyOptsForOrgUser: (
orgId: string,
) => Promise<MasterPasswordPolicyOptions | null>;
putPolicy: (organizationId: string, type: PolicyType, request: PolicyRequest) => Promise<any>;
abstract putPolicy: (
organizationId: string,
type: PolicyType,
request: PolicyRequest,
) => Promise<any>;
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
@@ -11,43 +9,27 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p
export abstract class PolicyService {
/**
* All policies for the active user from sync data.
* All policies for the provided user from sync data.
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
* Consider using {@link get$} or {@link getAll$} instead, which will only return policies that should be enforced against the user.
* Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user.
*/
policies$: Observable<Policy[]>;
abstract policies$: (userId: UserId) => Observable<Policy[]>;
/**
* @returns the first {@link Policy} found that applies to the active user.
* @returns all {@link Policy} objects of a given type that apply to the specified user.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
* @see {@link getAll$} if you need all policies of a given type
* @param userId the {@link UserId} to search against
*/
get$: (policyType: PolicyType) => Observable<Policy>;
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
/**
* @returns all {@link Policy} objects of a given type that apply to the specified user (or the active user if not specified).
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
*/
getAll$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
/**
* All {@link Policy} objects for the specified user (from sync data).
* May include policies that are disabled or otherwise do not apply to the user.
* Consider using {@link getAll$} instead, which will only return policies that should be enforced against the user.
*/
getAll: (policyType: PolicyType) => Promise<Policy[]>;
/**
* @returns true if a policy of the specified type applies to the active user, otherwise false.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* This does not take into account the policy's configuration - if that is important, use {@link getAll$} to get the
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
* {@link Policy} objects and then filter by Policy.data.
*/
policyAppliesToActiveUser$: (policyType: PolicyType) => Observable<boolean>;
policyAppliesToUser: (policyType: PolicyType) => Promise<boolean>;
abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>;
// Policy specific interfaces
@@ -56,28 +38,31 @@ export abstract class PolicyService {
* @returns a set of options which represent the minimum Master Password settings that the user must
* comply with in order to comply with **all** Master Password policies.
*/
masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable<MasterPasswordPolicyOptions>;
abstract masterPasswordPolicyOptions$: (
userId: UserId,
policies?: Policy[],
) => Observable<MasterPasswordPolicyOptions | undefined>;
/**
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
*/
evaluateMasterPassword: (
abstract evaluateMasterPassword: (
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
) => boolean;
/**
* @returns Reset Password policy options for the specified organization and a boolean indicating whether the policy
* @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy
* is enabled
*/
getResetPasswordPolicyOptions: (
abstract getResetPasswordPolicyOptions: (
policies: Policy[],
orgId: string,
) => [ResetPasswordPolicyOptions, boolean];
}
export abstract class InternalPolicyService extends PolicyService {
upsert: (policy: PolicyData) => Promise<void>;
replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
}

View File

@@ -1,68 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
export abstract class vNextPolicyService {
/**
* All policies for the provided user from sync data.
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
* Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user.
*/
abstract policies$: (userId: UserId) => Observable<Policy[]>;
/**
* @returns all {@link Policy} objects of a given type that apply to the specified user.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
* @param userId the {@link UserId} to search against
*/
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
/**
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
* {@link Policy} objects and then filter by Policy.data.
*/
abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>;
// Policy specific interfaces
/**
* Combines all Master Password policies that apply to the user.
* @returns a set of options which represent the minimum Master Password settings that the user must
* comply with in order to comply with **all** Master Password policies.
*/
abstract masterPasswordPolicyOptions$: (
userId: UserId,
policies?: Policy[],
) => Observable<MasterPasswordPolicyOptions | undefined>;
/**
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
*/
abstract evaluateMasterPassword: (
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
) => boolean;
/**
* @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy
* is enabled
*/
abstract getResetPasswordPolicyOptions: (
policies: Policy[],
orgId: string,
) => [ResetPasswordPolicyOptions, boolean];
}
export abstract class vNextInternalPolicyService extends vNextPolicyService {
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
}

View File

@@ -15,11 +15,11 @@ import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domai
import { Organization } from "../../../admin-console/models/domain/organization";
import { Policy } from "../../../admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
import { POLICIES } from "../../../admin-console/services/policy/policy.service";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { DefaultvNextPolicyService, getFirstPolicy } from "./default-vnext-policy.service";
import { DefaultPolicyService, getFirstPolicy } from "./default-policy.service";
import { POLICIES } from "./policy-state";
describe("PolicyService", () => {
const userId = "userId" as UserId;
@@ -27,7 +27,7 @@ describe("PolicyService", () => {
let organizationService: MockProxy<OrganizationService>;
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
let policyService: DefaultvNextPolicyService;
let policyService: DefaultPolicyService;
beforeEach(() => {
const accountService = mockAccountServiceWith(userId);
@@ -59,7 +59,7 @@ describe("PolicyService", () => {
organizationService.organizations$.calledWith(userId).mockReturnValue(organizations$);
policyService = new DefaultvNextPolicyService(stateProvider, organizationService);
policyService = new DefaultPolicyService(stateProvider, organizationService);
});
it("upsert", async () => {

View File

@@ -3,7 +3,7 @@ import { combineLatest, map, Observable, of } from "rxjs";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { vNextPolicyService } from "../../abstractions/policy/vnext-policy.service";
import { PolicyService } from "../../abstractions/policy/policy.service.abstraction";
import { OrganizationUserStatusType, PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
@@ -11,7 +11,7 @@ import { Organization } from "../../models/domain/organization";
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
import { POLICIES } from "./vnext-policy-state";
import { POLICIES } from "./policy-state";
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) {
return Object.values(policiesMap || {}).map((f) => new Policy(f));
@@ -21,7 +21,7 @@ export const getFirstPolicy = map<Policy[], Policy | undefined>((policies) => {
return policies.at(0) ?? undefined;
});
export class DefaultvNextPolicyService implements vNextPolicyService {
export class DefaultPolicyService implements PolicyService {
constructor(
private stateProvider: StateProvider,
private organizationService: OrganizationService,
@@ -89,7 +89,7 @@ export class DefaultvNextPolicyService implements vNextPolicyService {
const policies$ = policies ? of(policies) : this.policies$(userId);
return policies$.pipe(
map((obsPolicies) => {
const enforcedOptions: MasterPasswordPolicyOptions = new MasterPasswordPolicyOptions();
let enforcedOptions: MasterPasswordPolicyOptions | undefined = undefined;
const filteredPolicies =
obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? [];
@@ -102,6 +102,10 @@ export class DefaultvNextPolicyService implements vNextPolicyService {
return;
}
if (!enforcedOptions) {
enforcedOptions = new MasterPasswordPolicyOptions();
}
if (
currentPolicy.data.minComplexity != null &&
currentPolicy.data.minComplexity > enforcedOptions.minComplexity

View File

@@ -1,6 +1,8 @@
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map, switchMap } from "rxjs";
import { ApiService } from "../../../abstractions/api.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { getUserId } from "../../../auth/services/account.service";
import { HttpStatusCode } from "../../../enums";
import { ErrorResponse } from "../../../models/response/error.response";
import { ListResponse } from "../../../models/response/list.response";
@@ -18,6 +20,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
constructor(
private policyService: InternalPolicyService,
private apiService: ApiService,
private accountService: AccountService,
) {}
async getPolicy(organizationId: string, type: PolicyType): Promise<PolicyResponse> {
@@ -93,8 +96,14 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
return null;
}
return await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$([masterPasswordPolicy]),
return firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.masterPasswordPolicyOptions$(userId, [masterPasswordPolicy]),
),
map((policy) => policy ?? null),
),
);
} catch (error) {
// If policy not found, return null
@@ -114,8 +123,9 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
true,
true,
);
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const response = new PolicyResponse(r);
const data = new PolicyData(response);
await this.policyService.upsert(data);
await this.policyService.upsert(data, userId);
}
}

View File

@@ -1,556 +0,0 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState, FakeSingleUserState } from "../../../../spec/fake-state";
import {
OrganizationUserStatusType,
OrganizationUserType,
PolicyType,
} from "../../../admin-console/enums";
import { PermissionsApi } from "../../../admin-console/models/api/permissions.api";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { PolicyData } from "../../../admin-console/models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domain/master-password-policy-options";
import { Organization } from "../../../admin-console/models/domain/organization";
import { Policy } from "../../../admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
describe("PolicyService", () => {
const userId = "userId" as UserId;
let stateProvider: FakeStateProvider;
let organizationService: MockProxy<OrganizationService>;
let activeUserState: FakeActiveUserState<Record<PolicyId, PolicyData>>;
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
let policyService: PolicyService;
beforeEach(() => {
const accountService = mockAccountServiceWith(userId);
stateProvider = new FakeStateProvider(accountService);
organizationService = mock<OrganizationService>();
activeUserState = stateProvider.activeUser.getFake(POLICIES);
singleUserState = stateProvider.singleUser.getFake(activeUserState.userId, POLICIES);
const organizations$ = of([
// User
organization("org1", true, true, OrganizationUserStatusType.Confirmed, false),
// Owner
organization(
"org2",
true,
true,
OrganizationUserStatusType.Confirmed,
false,
OrganizationUserType.Owner,
),
// Does not use policies
organization("org3", true, false, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org4", true, true, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org5", true, true, OrganizationUserStatusType.Confirmed, false),
// Can manage policies
organization("org6", true, true, OrganizationUserStatusType.Confirmed, true),
]);
organizationService.organizations$.mockReturnValue(organizations$);
policyService = new PolicyService(stateProvider, organizationService);
});
it("upsert", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
]),
);
await policyService.upsert(policyData("99", "test-organization", PolicyType.DisableSend, true));
expect(await firstValueFrom(policyService.policies$)).toEqual([
{
id: "1",
organizationId: "test-organization",
type: PolicyType.MaximumVaultTimeout,
enabled: true,
data: { minutes: 14 },
},
{
id: "99",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
},
]);
});
it("replace", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
]),
);
await policyService.replace(
{
"2": policyData("2", "test-organization", PolicyType.DisableSend, true),
},
userId,
);
expect(await firstValueFrom(policyService.policies$)).toEqual([
{
id: "2",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
},
]);
});
describe("masterPasswordPolicyOptions", () => {
it("returns default policy options", async () => {
const data: any = {
minComplexity: 5,
minLength: 20,
requireUpper: true,
};
const model = [
new Policy(policyData("1", "test-organization-3", PolicyType.MasterPassword, true, data)),
];
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
expect(result).toEqual({
minComplexity: 5,
minLength: 20,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
requireUpper: true,
enforceOnLogin: false,
});
});
it("returns null", async () => {
const data: any = {};
const model = [
new Policy(
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
),
new Policy(
policyData("4", "test-organization-3", PolicyType.MaximumVaultTimeout, true, data),
),
];
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
expect(result).toEqual(null);
});
it("returns specified policy options", async () => {
const data: any = {
minLength: 14,
};
const model = [
new Policy(
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
),
new Policy(policyData("4", "test-organization-3", PolicyType.MasterPassword, true, data)),
];
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
expect(result).toEqual({
minComplexity: 0,
minLength: 14,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
requireUpper: false,
enforceOnLogin: false,
});
});
});
describe("evaluateMasterPassword", () => {
it("false", async () => {
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
enforcedPolicyOptions.minLength = 14;
const result = policyService.evaluateMasterPassword(10, "password", enforcedPolicyOptions);
expect(result).toEqual(false);
});
it("true", async () => {
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
const result = policyService.evaluateMasterPassword(0, "password", enforcedPolicyOptions);
expect(result).toEqual(true);
});
});
describe("getResetPasswordPolicyOptions", () => {
it("default", async () => {
const result = policyService.getResetPasswordPolicyOptions([], "");
expect(result).toEqual([new ResetPasswordPolicyOptions(), false]);
});
it("returns autoEnrollEnabled true", async () => {
const data: any = {
autoEnrollEnabled: true,
};
const policies = [
new Policy(policyData("5", "test-organization-3", PolicyType.ResetPassword, true, data)),
];
const result = policyService.getResetPasswordPolicyOptions(policies, "test-organization-3");
expect(result).toEqual([{ autoEnrollEnabled: true }, true]);
});
});
describe("get$", () => {
it("returns the specified PolicyType", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
policyData("policy3", "org1", PolicyType.RemoveUnlockWithPin, true),
]),
);
await expect(
firstValueFrom(policyService.get$(PolicyType.ActivateAutofill)),
).resolves.toMatchObject({
id: "policy1",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
});
await expect(
firstValueFrom(policyService.get$(PolicyType.DisablePersonalVaultExport)),
).resolves.toMatchObject({
id: "policy2",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
});
await expect(
firstValueFrom(policyService.get$(PolicyType.RemoveUnlockWithPin)),
).resolves.toMatchObject({
id: "policy3",
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: true,
});
});
it("does not return disabled policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it.each([
["owners", "org2"],
["administrators", "org6"],
])("returns the password generator policy for %s", async (_, organization) => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, false),
policyData("policy2", organization, PolicyType.PasswordGenerator, true),
]),
);
const result = await firstValueFrom(policyService.get$(PolicyType.PasswordGenerator));
expect(result).toBeTruthy();
});
it("does not return policies for organizations that do not use policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org3", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(policyService.get$(PolicyType.ActivateAutofill));
expect(result).toBeNull();
});
});
describe("getAll$", () => {
it("returns the specified PolicyTypes", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return disabled policies", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies for organizations that do not use policies", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
});
describe("policyAppliesToActiveUser$", () => {
it("returns true when the policyType applies to the user", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBe(true);
});
it("returns false when policyType is disabled", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBe(false);
});
it("returns false when the policyType does not apply to the user because the user's role is exempt", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBe(false);
});
it("returns false for organizations that do not use policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBe(false);
});
});
function policyData(
id: string,
organizationId: string,
type: PolicyType,
enabled: boolean,
data?: any,
) {
const policyData = new PolicyData({} as any);
policyData.id = id as PolicyId;
policyData.organizationId = organizationId;
policyData.type = type;
policyData.enabled = enabled;
policyData.data = data;
return policyData;
}
function organizationData(
id: string,
enabled: boolean,
usePolicies: boolean,
status: OrganizationUserStatusType,
managePolicies: boolean,
type: OrganizationUserType = OrganizationUserType.User,
) {
const organizationData = new OrganizationData({} as any, {} as any);
organizationData.id = id;
organizationData.enabled = enabled;
organizationData.usePolicies = usePolicies;
organizationData.status = status;
organizationData.permissions = new PermissionsApi({ managePolicies: managePolicies } as any);
organizationData.type = type;
return organizationData;
}
function organization(
id: string,
enabled: boolean,
usePolicies: boolean,
status: OrganizationUserStatusType,
managePolicies: boolean,
type: OrganizationUserType = OrganizationUserType.User,
) {
return new Organization(
organizationData(id, enabled, usePolicies, status, managePolicies, type),
);
}
function arrayToRecord(input: PolicyData[]): Record<PolicyId, PolicyData> {
return Object.fromEntries(input.map((i) => [i.id, i]));
}
});

View File

@@ -1,257 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { UserKeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction";
import { OrganizationUserStatusType, PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Organization } from "../../models/domain/organization";
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
const policyRecordToArray = (policiesMap: { [id: string]: PolicyData }) =>
Object.values(policiesMap || {}).map((f) => new Policy(f));
export const POLICIES = UserKeyDefinition.record<PolicyData, PolicyId>(POLICIES_DISK, "policies", {
deserializer: (policyData) => policyData,
clearOn: ["logout"],
});
export class PolicyService implements InternalPolicyServiceAbstraction {
private activeUserPolicyState = this.stateProvider.getActive(POLICIES);
private activeUserPolicies$ = this.activeUserPolicyState.state$.pipe(
map((policyData) => policyRecordToArray(policyData)),
);
policies$ = this.activeUserPolicies$;
constructor(
private stateProvider: StateProvider,
private organizationService: OrganizationService,
) {}
get$(policyType: PolicyType): Observable<Policy> {
const filteredPolicies$ = this.activeUserPolicies$.pipe(
map((policies) => policies.filter((p) => p.type === policyType)),
);
const organizations$ = this.stateProvider.activeUserId$.pipe(
switchMap((userId) => this.organizationService.organizations$(userId)),
);
return combineLatest([filteredPolicies$, organizations$]).pipe(
map(
([policies, organizations]) =>
this.enforcedPolicyFilter(policies, organizations)?.at(0) ?? null,
),
);
}
getAll$(policyType: PolicyType, userId: UserId) {
const filteredPolicies$ = this.stateProvider.getUserState$(POLICIES, userId).pipe(
map((policyData) => policyRecordToArray(policyData)),
map((policies) => policies.filter((p) => p.type === policyType)),
);
return combineLatest([filteredPolicies$, this.organizationService.organizations$(userId)]).pipe(
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
);
}
async getAll(policyType: PolicyType) {
return await firstValueFrom(
this.policies$.pipe(map((policies) => policies.filter((p) => p.type === policyType))),
);
}
policyAppliesToActiveUser$(policyType: PolicyType) {
return this.get$(policyType).pipe(map((policy) => policy != null));
}
async policyAppliesToUser(policyType: PolicyType) {
return await firstValueFrom(this.policyAppliesToActiveUser$(policyType));
}
private enforcedPolicyFilter(policies: Policy[], organizations: Organization[]) {
const orgDict = Object.fromEntries(organizations.map((o) => [o.id, o]));
return policies.filter((policy) => {
const organization = orgDict[policy.organizationId];
// This shouldn't happen, i.e. the user should only have policies for orgs they are a member of
// But if it does, err on the side of enforcing the policy
if (organization == null) {
return true;
}
return (
policy.enabled &&
organization.status >= OrganizationUserStatusType.Accepted &&
organization.usePolicies &&
!this.isExemptFromPolicy(policy.type, organization)
);
});
}
masterPasswordPolicyOptions$(policies?: Policy[]): Observable<MasterPasswordPolicyOptions> {
const observable = policies ? of(policies) : this.policies$;
return observable.pipe(
map((obsPolicies) => {
let enforcedOptions: MasterPasswordPolicyOptions = null;
const filteredPolicies = obsPolicies.filter((p) => p.type === PolicyType.MasterPassword);
if (filteredPolicies == null || filteredPolicies.length === 0) {
return enforcedOptions;
}
filteredPolicies.forEach((currentPolicy) => {
if (!currentPolicy.enabled || currentPolicy.data == null) {
return;
}
if (enforcedOptions == null) {
enforcedOptions = new MasterPasswordPolicyOptions();
}
if (
currentPolicy.data.minComplexity != null &&
currentPolicy.data.minComplexity > enforcedOptions.minComplexity
) {
enforcedOptions.minComplexity = currentPolicy.data.minComplexity;
}
if (
currentPolicy.data.minLength != null &&
currentPolicy.data.minLength > enforcedOptions.minLength
) {
enforcedOptions.minLength = currentPolicy.data.minLength;
}
if (currentPolicy.data.requireUpper) {
enforcedOptions.requireUpper = true;
}
if (currentPolicy.data.requireLower) {
enforcedOptions.requireLower = true;
}
if (currentPolicy.data.requireNumbers) {
enforcedOptions.requireNumbers = true;
}
if (currentPolicy.data.requireSpecial) {
enforcedOptions.requireSpecial = true;
}
if (currentPolicy.data.enforceOnLogin) {
enforcedOptions.enforceOnLogin = true;
}
});
return enforcedOptions;
}),
);
}
evaluateMasterPassword(
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions: MasterPasswordPolicyOptions,
): boolean {
if (enforcedPolicyOptions == null) {
return true;
}
if (
enforcedPolicyOptions.minComplexity > 0 &&
enforcedPolicyOptions.minComplexity > passwordStrength
) {
return false;
}
if (
enforcedPolicyOptions.minLength > 0 &&
enforcedPolicyOptions.minLength > newPassword.length
) {
return false;
}
if (enforcedPolicyOptions.requireUpper && newPassword.toLocaleLowerCase() === newPassword) {
return false;
}
if (enforcedPolicyOptions.requireLower && newPassword.toLocaleUpperCase() === newPassword) {
return false;
}
if (enforcedPolicyOptions.requireNumbers && !/[0-9]/.test(newPassword)) {
return false;
}
// eslint-disable-next-line
if (enforcedPolicyOptions.requireSpecial && !/[!@#$%\^&*]/g.test(newPassword)) {
return false;
}
return true;
}
getResetPasswordPolicyOptions(
policies: Policy[],
orgId: string,
): [ResetPasswordPolicyOptions, boolean] {
const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions();
if (policies == null || orgId == null) {
return [resetPasswordPolicyOptions, false];
}
const policy = policies.find(
(p) => p.organizationId === orgId && p.type === PolicyType.ResetPassword && p.enabled,
);
resetPasswordPolicyOptions.autoEnrollEnabled = policy?.data?.autoEnrollEnabled ?? false;
return [resetPasswordPolicyOptions, policy?.enabled ?? false];
}
async upsert(policy: PolicyData): Promise<void> {
await this.activeUserPolicyState.update((policies) => {
policies ??= {};
policies[policy.id] = policy;
return policies;
});
}
async replace(policies: { [id: string]: PolicyData }, userId: UserId): Promise<void> {
await this.stateProvider.setUserState(POLICIES, policies, userId);
}
/**
* 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) {
switch (policyType) {
case PolicyType.MaximumVaultTimeout:
// Max Vault Timeout applies to everyone except owners
return organization.isOwner;
case PolicyType.PasswordGenerator:
// password generation policy applies to everyone
return false;
case PolicyType.PersonalOwnership:
// individual vault policy applies to everyone except admins and owners
return organization.isAdmin;
case PolicyType.FreeFamiliesSponsorshipPolicy:
// free Bitwarden families policy applies to everyone
return false;
case PolicyType.RemoveUnlockWithPin:
// free Remove Unlock with PIN policy applies to everyone
return false;
default:
return organization.canManagePolicies;
}
}
}

View File

@@ -2,7 +2,6 @@
// @ts-strict-ignore
import { ListResponse } from "../../models/response/list.response";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
@@ -25,10 +24,7 @@ export abstract class DevicesApiServiceAbstraction {
deviceIdentifier: string,
) => Promise<void>;
getDeviceKeys: (
deviceIdentifier: string,
secretVerificationRequest: SecretVerificationRequest,
) => Promise<ProtectedDeviceResponse>;
getDeviceKeys: (deviceIdentifier: string) => Promise<ProtectedDeviceResponse>;
/**
* Notifies the server that the device has a device key, but didn't receive any associated decryption keys.

View File

@@ -13,5 +13,5 @@ export class DeviceKeysUpdateRequest {
}
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {
id: string;
deviceId: string;
}

View File

@@ -2,6 +2,8 @@
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { RotateableKeySet } from "@bitwarden/auth/common";
import { DeviceType } from "../../../enums";
import { BaseResponse } from "../../../models/response/base.response";
import { EncString } from "../../../platform/models/domain/enc-string";
@@ -38,4 +40,12 @@ export class ProtectedDeviceResponse extends BaseResponse {
* This enabled a user to rotate the keys for all of their devices.
*/
encryptedPublicKey: EncString;
getRotateableKeyset(): RotateableKeySet {
return new RotateableKeySet(this.encryptedUserKey, this.encryptedPublicKey);
}
isTrusted(): boolean {
return this.encryptedUserKey != null && this.encryptedPublicKey != null;
}
}

View File

@@ -5,7 +5,6 @@ import { ListResponse } from "../../models/response/list.response";
import { Utils } from "../../platform/misc/utils";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
@@ -90,14 +89,11 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac
);
}
async getDeviceKeys(
deviceIdentifier: string,
secretVerificationRequest: SecretVerificationRequest,
): Promise<ProtectedDeviceResponse> {
async getDeviceKeys(deviceIdentifier: string): Promise<ProtectedDeviceResponse> {
const result = await this.apiService.send(
"POST",
`/devices/${deviceIdentifier}/retrieve-keys`,
secretVerificationRequest,
null,
true,
true,
);

View File

@@ -224,6 +224,8 @@ describe("UserVerificationService", () => {
expect(result).toEqual({
policyOptions: null,
masterKey: "masterKey",
kdfConfig: "kdfConfig",
email: "email",
});
});
@@ -282,6 +284,8 @@ describe("UserVerificationService", () => {
expect(result).toEqual({
policyOptions: "MasterPasswordPolicyOptions",
masterKey: "masterKey",
kdfConfig: "kdfConfig",
email: "email",
});
});

View File

@@ -237,7 +237,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
);
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
await this.masterPasswordService.setMasterKey(masterKey, userId);
return { policyOptions, masterKey };
return { policyOptions, masterKey, kdfConfig, email };
}
private async verifyUserByPIN(verification: PinVerification, userId: UserId): Promise<boolean> {

View File

@@ -1,3 +1,5 @@
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";
@@ -22,5 +24,7 @@ export type ServerSideVerification = OtpVerification | MasterPasswordVerificatio
export type MasterPasswordVerificationResponse = {
masterKey: MasterKey;
kdfConfig: KdfConfig;
email: string;
policyOptions: MasterPasswordPolicyResponse | null;
};

View File

@@ -1,9 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { map, Observable } from "rxjs";
import { map, Observable, switchMap } from "rxjs";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../admin-console/enums";
import { AccountService } from "../../auth/abstractions/account.service";
import { getUserId } from "../../auth/services/account.service";
import {
AUTOFILL_SETTINGS_DISK,
AUTOFILL_SETTINGS_DISK_LOCAL,
@@ -152,6 +154,7 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
constructor(
private stateProvider: StateProvider,
private policyService: PolicyService,
private accountService: AccountService,
) {
this.autofillOnPageLoadState = this.stateProvider.getActive(AUTOFILL_ON_PAGE_LOAD);
this.autofillOnPageLoad$ = this.autofillOnPageLoadState.state$.pipe(map((x) => x ?? false));
@@ -169,8 +172,11 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
this.autofillOnPageLoadCalloutIsDismissed$ =
this.autofillOnPageLoadCalloutIsDismissedState.state$.pipe(map((x) => x ?? false));
this.activateAutofillOnPageLoadFromPolicy$ = this.policyService.policyAppliesToActiveUser$(
PolicyType.ActivateAutofill,
this.activateAutofillOnPageLoadFromPolicy$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.ActivateAutofill, userId),
),
);
this.autofillOnPageLoadPolicyToastHasDisplayedState = this.stateProvider.getActive(

View File

@@ -2,12 +2,19 @@
* Feature flags.
*
* Flags MUST be short lived and SHALL be removed once enabled.
*
* Flags should be grouped by team to have visibility of ownership and cleanup.
*/
export enum FeatureFlag {
/* Admin Console Team */
AccountDeprovisioning = "pm-10308-account-deprovisioning",
VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint",
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility",
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
/* Auth */
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
/* Autofill */
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
@@ -15,11 +22,22 @@ export enum FeatureFlag {
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
IdpAutoSubmitLogin = "idp-auto-submit-login",
InlineMenuFieldQualification = "inline-menu-field-qualification",
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
MacOsNativeCredentialSync = "macos-native-credential-sync",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
UserKeyRotationV2 = "userkey-rotation-v2",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
/* Tools */
ItemShare = "item-share",
@@ -35,21 +53,7 @@ export enum FeatureFlag {
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
VaultBulkManagementAction = "vault-bulk-management-action",
SecurityTasks = "security-tasks",
/* Auth */
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
CipherKeyEncryption = "cipher-key-encryption",
TrialPaymentOptional = "PM-8163-trial-payment",
MacOsNativeCredentialSync = "macos-native-credential-sync",
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -62,12 +66,16 @@ const FALSE = false as boolean;
*
* DO NOT enable previously disabled flags, REMOVE them instead.
* We support true as a value as we prefer flags to "enable" not "disable".
*
* Flags should be grouped by team to have visibility of ownership and cleanup.
*/
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
[FeatureFlag.LimitItemDeletion]: FALSE,
[FeatureFlag.SsoExternalIdVisibility]: FALSE,
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
/* Autofill */
[FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE,
@@ -75,11 +83,11 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
/* Tools */
[FeatureFlag.ItemShare]: FALSE,
@@ -95,21 +103,21 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
/* Auth */
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
[FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.UserKeyRotationV2]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -24,4 +24,6 @@ export enum NotificationType {
SyncOrganizations = 17,
SyncOrganizationStatusChanged = 18,
SyncOrganizationCollectionSettingChanged = 19,
Notification = 20,
NotificationStatus = 21,
}

View File

@@ -2,6 +2,8 @@
// @ts-strict-ignore
import { Observable } from "rxjs";
import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
import { EncString } from "../../../platform/models/domain/enc-string";
import { UserId } from "../../../types/guid";
@@ -55,4 +57,9 @@ export abstract class DeviceTrustServiceAbstraction {
* Note: For debugging purposes only.
*/
recordDeviceTrustLoss: () => Promise<void>;
getRotatedData: (
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
) => Promise<DeviceKeysUpdateRequest[]>;
}

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { firstValueFrom, map, Observable, Subject } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { RotateableKeySet, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
@@ -10,6 +10,7 @@ import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
import {
DeviceKeysUpdateRequest,
OtherDeviceKeysUpdateRequest,
UpdateDevicesTrustRequest,
} from "../../../auth/models/request/update-devices-trust.request";
import { AppIdService } from "../../../platform/abstractions/app-id.service";
@@ -187,6 +188,51 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
return deviceResponse;
}
async getRotatedData(
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<DeviceKeysUpdateRequest[]> {
if (!userId) {
throw new Error("UserId is required. Cannot get rotated data.");
}
if (!oldUserKey) {
throw new Error("Old user key is required. Cannot get rotated data.");
}
if (!newUserKey) {
throw new Error("New user key is required. Cannot get rotated data.");
}
const devices = await this.devicesApiService.getDevices();
return await Promise.all(
devices.data
.filter((device) => device.isTrusted)
.map(async (device) => {
const deviceWithKeys = await this.devicesApiService.getDeviceKeys(device.identifier);
const publicKey = await this.encryptService.decryptToBytes(
deviceWithKeys.encryptedPublicKey,
oldUserKey,
);
const newEncryptedPublicKey = await this.encryptService.encrypt(publicKey, newUserKey);
const newEncryptedUserKey = await this.encryptService.rsaEncrypt(
newUserKey.key,
publicKey,
);
const newRotateableKeySet = new RotateableKeySet(
newEncryptedUserKey,
newEncryptedPublicKey,
);
const request = new OtherDeviceKeysUpdateRequest();
request.encryptedPublicKey = newRotateableKeySet.encryptedPublicKey.encryptedString;
request.encryptedUserKey = newRotateableKeySet.encryptedUserKey.encryptedString;
request.deviceId = device.id;
return request;
}),
);
}
async rotateDevicesTrust(
userId: UserId,
newUserKey: UserKey,
@@ -216,10 +262,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
secretVerificationRequest.masterPasswordHash = masterPasswordHash;
// Get the keys that are used in rotating a devices keys from the server
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(
deviceIdentifier,
secretVerificationRequest,
);
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(deviceIdentifier);
// Decrypt the existing device public key with the old user key
const decryptedDevicePublicKey = await this.encryptService.decryptToBytes(

View File

@@ -7,6 +7,7 @@ import {
UserDecryptionOptionsServiceAbstraction,
UserDecryptionOptions,
} from "@bitwarden/auth/common";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { KeyService } from "@bitwarden/key-management";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service";
@@ -655,6 +656,86 @@ describe("deviceTrustService", () => {
});
});
describe("getRotatedData", () => {
let fakeNewUserKey: UserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
let fakeOldUserKey: UserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const userId: UserId = Utils.newGuid() as UserId;
it("throws an error when a null user id is passed in", async () => {
await expect(
deviceTrustService.getRotatedData(fakeOldUserKey, fakeNewUserKey, null),
).rejects.toThrow("UserId is required. Cannot get rotated data.");
});
it("throws an error when a null old user key is passed in", async () => {
await expect(
deviceTrustService.getRotatedData(null, fakeNewUserKey, userId),
).rejects.toThrow("Old user key is required. Cannot get rotated data.");
});
it("throws an error when a null new user key is passed in", async () => {
await expect(
deviceTrustService.getRotatedData(fakeOldUserKey, null, userId),
).rejects.toThrow("New user key is required. Cannot get rotated data.");
});
it("returns the expected data when all required parameters are provided", async () => {
const deviceResponse = {
id: "",
userId: "",
name: "",
identifier: "",
type: DeviceType.Android,
creationDate: "",
revisionDate: "",
isTrusted: true,
};
devicesApiService.getDevices.mockResolvedValue(
new ListResponse(
{
data: [deviceResponse],
},
DeviceResponse,
),
);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(64));
encryptService.encrypt.mockResolvedValue(new EncString("test_encrypted_data"));
encryptService.rsaEncrypt.mockResolvedValue(new EncString("test_encrypted_data"));
const protectedDeviceResponse = new ProtectedDeviceResponse({
id: "",
creationDate: "",
identifier: "test_device_identifier",
name: "Firefox",
type: DeviceType.FirefoxBrowser,
encryptedPublicKey: "",
encryptedUserKey: "",
});
devicesApiService.getDeviceKeys.mockResolvedValue(protectedDeviceResponse);
const fakeOldUserKeyData = new Uint8Array(64);
fakeOldUserKeyData.fill(5, 0, 1);
fakeOldUserKey = new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey;
const fakeNewUserKeyData = new Uint8Array(64);
fakeNewUserKeyData.fill(1, 0, 1);
fakeNewUserKey = new SymmetricCryptoKey(fakeNewUserKeyData) as UserKey;
const result = await deviceTrustService.getRotatedData(
fakeOldUserKey,
fakeNewUserKey,
userId,
);
expect(result).toEqual([
{
deviceId: "",
encryptedUserKey: "test_encrypted_data",
encryptedPublicKey: "test_encrypted_data",
},
]);
});
});
describe("rotateDevicesTrust", () => {
let fakeNewUserKey: UserKey = null;
@@ -708,11 +789,8 @@ describe("deviceTrustService", () => {
appIdService.getAppId.mockResolvedValue("test_device_identifier");
devicesApiService.getDeviceKeys.mockImplementation((deviceIdentifier, secretRequest) => {
if (
deviceIdentifier !== "test_device_identifier" ||
secretRequest.masterPasswordHash !== "my_password_hash"
) {
devicesApiService.getDeviceKeys.mockImplementation((deviceIdentifier) => {
if (deviceIdentifier !== "test_device_identifier") {
return Promise.resolve(null);
}

View File

@@ -163,19 +163,6 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
throw new Error("No master key found.");
}
// Try one more way to get the user key if it still wasn't found.
if (userKey == null) {
const deprecatedKey = await this.stateService.getEncryptedCryptoSymmetricKey({
userId: userId,
});
if (deprecatedKey == null) {
throw new Error("No encrypted user key found.");
}
userKey = new EncString(deprecatedKey);
}
let decUserKey: Uint8Array;
if (userKey.encryptionType === EncryptionType.AesCbc256_B64) {

View File

@@ -175,7 +175,7 @@ describe("VaultTimeoutSettingsService", () => {
"returns $expected when policy is $policy, and user preference is $userPreference",
async ({ policy, userPreference, expected }) => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.getAll$.mockReturnValue(
policyService.policiesByType$.mockReturnValue(
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
);
@@ -213,7 +213,7 @@ describe("VaultTimeoutSettingsService", () => {
userDecryptionOptionsSubject.next(
new UserDecryptionOptions({ hasMasterPassword: false }),
);
policyService.getAll$.mockReturnValue(
policyService.policiesByType$.mockReturnValue(
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
);
@@ -257,7 +257,7 @@ describe("VaultTimeoutSettingsService", () => {
"when policy is %s, and vault timeout is %s, returns %s",
async (policy, vaultTimeout, expected) => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.getAll$.mockReturnValue(
policyService.policiesByType$.mockReturnValue(
of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])),
);

View File

@@ -9,7 +9,6 @@ import {
distinctUntilChanged,
firstValueFrom,
from,
map,
shareReplay,
switchMap,
tap,
@@ -24,6 +23,7 @@ import { BiometricStateService, KeyService } from "@bitwarden/key-management";
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { TokenService } from "../../../auth/abstractions/token.service";
import { LogService } from "../../../platform/abstractions/log.service";
@@ -266,8 +266,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
return this.policyService
.getAll$(PolicyType.MaximumVaultTimeout, userId)
.pipe(map((policies) => policies[0] ?? null));
.policiesByType$(PolicyType.MaximumVaultTimeout, userId)
.pipe(getFirstPolicy);
}
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {

View File

@@ -147,7 +147,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.masterPasswordService.clearMasterKey(lockingUserId);
await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
await this.stateService.setCryptoMasterKeyAuto(null, { userId: lockingUserId });
await this.cipherService.clearCache(lockingUserId);

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { import_ssh_key } from "@bitwarden/sdk-internal";
import { EncString } from "../../platform/models/domain/enc-string";
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
@@ -18,18 +17,16 @@ export class SshKeyExport {
}
static toView(req: SshKeyExport, view = new SshKeyView()) {
const parsedKey = import_ssh_key(req.privateKey);
view.privateKey = parsedKey.privateKey;
view.publicKey = parsedKey.publicKey;
view.keyFingerprint = parsedKey.fingerprint;
view.privateKey = req.privateKey;
view.publicKey = req.publicKey;
view.keyFingerprint = req.keyFingerprint;
return view;
}
static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) {
const parsedKey = import_ssh_key(req.privateKey);
domain.privateKey = new EncString(parsedKey.privateKey);
domain.publicKey = new EncString(parsedKey.publicKey);
domain.keyFingerprint = new EncString(parsedKey.fingerprint);
domain.privateKey = new EncString(req.privateKey);
domain.publicKey = new EncString(req.publicKey);
domain.keyFingerprint = new EncString(req.keyFingerprint);
return domain;
}

View File

@@ -50,14 +50,6 @@ export abstract class StateService<T extends Account = Account> {
value: boolean,
options?: StorageOptions,
) => Promise<void>;
/**
* @deprecated For migration purposes only, use getUserKeyMasterKey instead
*/
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>;
/**
* @deprecated For migration purposes only, use setUserKeyAuto instead
*/
setCryptoMasterKeyAuto: (value: string | null, options?: StorageOptions) => Promise<void>;
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;

View File

@@ -1,139 +0,0 @@
import { clearCaches, sequentialize } from "./sequentialize";
describe("sequentialize decorator", () => {
it("should call the function once", async () => {
const foo = new Foo();
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(foo.bar(1));
}
await Promise.all(promises);
expect(foo.calls).toBe(1);
});
it("should call the function once for each instance of the object", async () => {
const foo = new Foo();
const foo2 = new Foo();
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(foo.bar(1));
promises.push(foo2.bar(1));
}
await Promise.all(promises);
expect(foo.calls).toBe(1);
expect(foo2.calls).toBe(1);
});
it("should call the function once with key function", async () => {
const foo = new Foo();
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(foo.baz(1));
}
await Promise.all(promises);
expect(foo.calls).toBe(1);
});
it("should call the function again when already resolved", async () => {
const foo = new Foo();
await foo.bar(1);
expect(foo.calls).toBe(1);
await foo.bar(1);
expect(foo.calls).toBe(2);
});
it("should call the function again when already resolved with a key function", async () => {
const foo = new Foo();
await foo.baz(1);
expect(foo.calls).toBe(1);
await foo.baz(1);
expect(foo.calls).toBe(2);
});
it("should call the function for each argument", async () => {
const foo = new Foo();
await Promise.all([foo.bar(1), foo.bar(1), foo.bar(2), foo.bar(2), foo.bar(3), foo.bar(3)]);
expect(foo.calls).toBe(3);
});
it("should call the function for each argument with key function", async () => {
const foo = new Foo();
await Promise.all([foo.baz(1), foo.baz(1), foo.baz(2), foo.baz(2), foo.baz(3), foo.baz(3)]);
expect(foo.calls).toBe(3);
});
it("should return correct result for each call", async () => {
const foo = new Foo();
const allRes: number[] = [];
await Promise.all([
foo.bar(1).then((res) => allRes.push(res)),
foo.bar(1).then((res) => allRes.push(res)),
foo.bar(2).then((res) => allRes.push(res)),
foo.bar(2).then((res) => allRes.push(res)),
foo.bar(3).then((res) => allRes.push(res)),
foo.bar(3).then((res) => allRes.push(res)),
]);
expect(foo.calls).toBe(3);
expect(allRes.length).toBe(6);
allRes.sort();
expect(allRes).toEqual([2, 2, 4, 4, 6, 6]);
});
it("should return correct result for each call with key function", async () => {
const foo = new Foo();
const allRes: number[] = [];
await Promise.all([
foo.baz(1).then((res) => allRes.push(res)),
foo.baz(1).then((res) => allRes.push(res)),
foo.baz(2).then((res) => allRes.push(res)),
foo.baz(2).then((res) => allRes.push(res)),
foo.baz(3).then((res) => allRes.push(res)),
foo.baz(3).then((res) => allRes.push(res)),
]);
expect(foo.calls).toBe(3);
expect(allRes.length).toBe(6);
allRes.sort();
expect(allRes).toEqual([3, 3, 6, 6, 9, 9]);
});
describe("clearCaches", () => {
it("should clear all caches", async () => {
const foo = new Foo();
const promise = Promise.all([foo.bar(1), foo.bar(1)]);
clearCaches();
await foo.bar(1);
await promise;
// one call for the first two, one for the third after the cache was cleared
expect(foo.calls).toBe(2);
});
});
});
class Foo {
calls = 0;
@sequentialize((args) => "bar" + args[0])
bar(a: number): Promise<number> {
this.calls++;
return new Promise((res) => {
setTimeout(() => {
res(a * 2);
}, Math.random() * 100);
});
}
@sequentialize((args) => "baz" + args[0])
baz(a: number): Promise<number> {
this.calls++;
return new Promise((res) => {
setTimeout(() => {
res(a * 3);
}, Math.random() * 100);
});
}
}

View File

@@ -1,64 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
const caches = new Map<any, Map<string, Promise<any>>>();
const getCache = (obj: any) => {
let cache = caches.get(obj);
if (cache != null) {
return cache;
}
cache = new Map<string, Promise<any>>();
caches.set(obj, cache);
return cache;
};
export function clearCaches() {
caches.clear();
}
/**
* Use as a Decorator on async functions, it will prevent multiple 'active' calls as the same time
*
* If a promise was returned from a previous call to this function, that hasn't yet resolved it will
* be returned, instead of calling the original function again
*
* Results are not cached, once the promise has returned, the next call will result in a fresh call
*
* Read more at https://github.com/bitwarden/jslib/pull/7
*/
export function sequentialize(cacheKey: (args: any[]) => string) {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod: () => Promise<any> = descriptor.value;
return {
value: function (...args: any[]) {
const cache = getCache(this);
const argsCacheKey = cacheKey(args);
let response = cache.get(argsCacheKey);
if (response != null) {
return response;
}
const onFinally = () => {
cache.delete(argsCacheKey);
if (cache.size === 0) {
caches.delete(this);
}
};
response = originalMethod
.apply(this, args)
.then((val: any) => {
onFinally();
return val;
})
.catch((err: any) => {
onFinally();
throw err;
});
cache.set(argsCacheKey, response);
return response;
},
};
};
}

View File

@@ -1,4 +1,3 @@
import { sequentialize } from "./sequentialize";
import { throttle } from "./throttle";
describe("throttle decorator", () => {
@@ -51,17 +50,6 @@ describe("throttle decorator", () => {
expect(foo.calls).toBe(10);
expect(foo2.calls).toBe(10);
});
it("should work together with sequentialize", async () => {
const foo = new Foo();
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(foo.qux(Math.floor(i / 2) * 2));
}
await Promise.all(promises);
expect(foo.calls).toBe(5);
});
});
class Foo {
@@ -94,7 +82,6 @@ class Foo {
});
}
@sequentialize((args) => "qux" + args[0])
@throttle(1, () => "qux")
qux(a: number) {
this.calls++;

View File

@@ -48,8 +48,6 @@ export class EncryptionPair<TEncrypted, TDecrypted> {
export class AccountKeys {
publicKey?: Uint8Array;
/** @deprecated July 2023, left for migration purposes*/
cryptoMasterKeyAuto?: string;
/** @deprecated July 2023, left for migration purposes*/
cryptoSymmetricKey?: EncryptionPair<string, SymmetricCryptoKey> = new EncryptionPair<
string,

View File

@@ -1,9 +1,14 @@
import { Subscription } from "rxjs";
import { Observable, Subject, Subscription } from "rxjs";
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "../../abstractions/log.service";
import { NotificationsService } from "../notifications.service";
export class NoopNotificationsService implements NotificationsService {
notifications$: Observable<readonly [NotificationResponse, UserId]> = new Subject();
constructor(private logService: LogService) {}
startListening(): Subscription {

View File

@@ -23,6 +23,11 @@ export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResp
export type SignalRNotification = Heartbeat | ReceiveMessage;
export type TimeoutManager = {
setTimeout: (handler: TimerHandler, timeout: number) => number;
clearTimeout: (timeoutId: number) => void;
};
class SignalRLogger implements ILogger {
constructor(private readonly logService: LogService) {}
@@ -51,11 +56,14 @@ export class SignalRConnectionService {
constructor(
private readonly apiService: ApiService,
private readonly logService: LogService,
private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
new HubConnectionBuilder(),
private readonly timeoutManager: TimeoutManager = globalThis,
) {}
connect$(userId: UserId, notificationsUrl: string) {
return new Observable<SignalRNotification>((subsciber) => {
const connection = new HubConnectionBuilder()
const connection = this.hubConnectionBuilderFactory()
.withUrl(notificationsUrl + "/hub", {
accessTokenFactory: () => this.apiService.getActiveBearerToken(),
skipNegotiation: true,
@@ -76,48 +84,60 @@ export class SignalRConnectionService {
let reconnectSubscription: Subscription | null = null;
// Create schedule reconnect function
const scheduleReconnect = (): Subscription => {
const scheduleReconnect = () => {
if (
connection == null ||
connection.state !== HubConnectionState.Disconnected ||
(reconnectSubscription != null && !reconnectSubscription.closed)
) {
return Subscription.EMPTY;
// Skip scheduling a new reconnect, either the connection isn't disconnected
// or an active reconnect is already scheduled.
return;
}
const randomTime = this.random();
const timeoutHandler = setTimeout(() => {
// If we've somehow gotten here while the subscriber is closed,
// we do not want to reconnect. So leave.
if (subsciber.closed) {
return;
}
const randomTime = this.randomReconnectTime();
const timeoutHandler = this.timeoutManager.setTimeout(() => {
connection
.start()
.then(() => (reconnectSubscription = null))
.then(() => {
reconnectSubscription = null;
})
.catch(() => {
reconnectSubscription = scheduleReconnect();
scheduleReconnect();
});
}, randomTime);
return new Subscription(() => clearTimeout(timeoutHandler));
reconnectSubscription = new Subscription(() =>
this.timeoutManager.clearTimeout(timeoutHandler),
);
};
connection.onclose((error) => {
reconnectSubscription = scheduleReconnect();
scheduleReconnect();
});
// Start connection
connection.start().catch(() => {
reconnectSubscription = scheduleReconnect();
scheduleReconnect();
});
return () => {
// Cancel any possible scheduled reconnects
reconnectSubscription?.unsubscribe();
connection?.stop().catch((error) => {
this.logService.error("Error while stopping SignalR connection", error);
// TODO: Does calling stop call `onclose`?
reconnectSubscription?.unsubscribe();
});
};
});
}
private random() {
private randomReconnectTime() {
return (
Math.floor(Math.random() * (MAX_RECONNECT_TIME - MIN_RECONNECT_TIME + 1)) + MIN_RECONNECT_TIME
);

View File

@@ -1,9 +1,13 @@
import { Subscription } from "rxjs";
import { Observable, Subscription } from "rxjs";
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
import { UserId } from "@bitwarden/common/types/guid";
/**
* A service offering abilities to interact with push notifications from the server.
*/
export abstract class NotificationsService {
abstract notifications$: Observable<readonly [NotificationResponse, UserId]>;
/**
* Starts automatic listening and processing of notifications, should only be called once per application,
* or you will risk notifications being processed multiple times.

View File

@@ -11,7 +11,7 @@ import { ScheduledTaskName } from "./scheduled-task-name.enum";
* in the future but the task that is ran is NOT the remainder of your RXJS pipeline. The
* task you want ran must instead be registered in a location reachable on a service worker
* startup (on browser). An example of an acceptible location is the constructor of a service
* you know is created in `MainBackground`. Uses of this API is other clients _can_ have the
* you know is created in `MainBackground`. Uses of this API in other clients _can_ have the
* `registerTaskHandler` call in more places, but in order to have it work across clients
* it is recommended to register it according to the rules of browser.
*

View File

@@ -251,7 +251,8 @@ export class Fido2ClientService<ParentWindowReference>
clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes),
publicKey: Fido2Utils.bufferToString(makeCredentialResult.publicKey),
publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm,
transports: params.rp.id === "google.com" ? ["internal", "usb"] : ["internal"],
transports:
params.rp.id === "google.com" ? ["internal", "usb", "hybrid"] : ["internal", "hybrid"],
extensions: { credProps },
};
}

View File

@@ -222,45 +222,6 @@ export class StateService<
await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options);
}
/**
* @deprecated Use UserKeyAuto instead
*/
async setCryptoMasterKeyAuto(value: string | null, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(
this.reconcileOptions(options, { keySuffix: "auto" }),
await this.defaultSecureStorageOptions(),
);
if (options?.userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.autoKey, value, options);
}
/**
* @deprecated I don't see where this is even used
*/
async getCryptoMasterKeyB64(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return null;
}
return await this.secureStorageService.get<string>(
`${options?.userId}${partialKeys.masterKey}`,
options,
);
}
/**
* @deprecated I don't see where this is even used
*/
async setCryptoMasterKeyB64(value: string, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.masterKey, value, options);
}
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
@@ -619,8 +580,6 @@ export class StateService<
await this.setUserKeyAutoUnlock(null, { userId: userId });
await this.setUserKeyBiometric(null, { userId: userId });
await this.setCryptoMasterKeyAuto(null, { userId: userId });
await this.setCryptoMasterKeyB64(null, { userId: userId });
}
protected async removeAccountFromMemory(userId: string = null): Promise<void> {

View File

@@ -206,3 +206,4 @@ export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk");

View File

@@ -51,7 +51,6 @@ import { FolderResponse } from "../../vault/models/response/folder.response";
import { LogService } from "../abstractions/log.service";
import { StateService } from "../abstractions/state.service";
import { MessageSender } from "../messaging";
import { sequentialize } from "../misc/sequentialize";
import { StateProvider } from "../state";
import { CoreSyncService } from "./core-sync.service";
@@ -103,7 +102,6 @@ export class DefaultSyncService extends CoreSyncService {
);
}
@sequentialize(() => "fullSync")
override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise<boolean> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
this.syncStarted();

View File

@@ -31,7 +31,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
abstract failedToDecryptCiphers$(userId: UserId): Observable<CipherView[]>;
abstract failedToDecryptCiphers$(userId: UserId): Observable<CipherView[] | null>;
abstract clearCache(userId: UserId): Promise<void>;
abstract encrypt(
model: CipherView,

View File

@@ -305,51 +305,86 @@ describe("Cipher Service", () => {
});
describe("cipher.key", () => {
it("is null when feature flag is false", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
beforeEach(() => {
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
});
it("is null when feature flag is false", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
const cipher = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeNull();
});
it("is defined when feature flag flag is true", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
describe("when feature flag is true", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(true);
});
const cipher = await cipherService.encrypt(cipherView, userId);
it("is null when the cipher is not viewPassword", async () => {
cipherView.viewPassword = false;
expect(cipher.key).toBeDefined();
const cipher = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeNull();
});
it("is defined when the cipher is viewPassword", async () => {
cipherView.viewPassword = true;
const cipher = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeDefined();
});
});
});
describe("encryptWithCipherKey", () => {
beforeEach(() => {
jest.spyOn<any, string>(cipherService, "encryptCipherWithCipherKey");
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
});
it("is not called when feature flag is false", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
await cipherService.encrypt(cipherView, userId);
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
});
it("is called when feature flag is true", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
describe("when feature flag is true", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(true);
});
await cipherService.encrypt(cipherView, userId);
it("is called when cipher viewPassword is true", async () => {
cipherView.viewPassword = true;
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
await cipherService.encrypt(cipherView, userId);
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
});
it("is not called when cipher viewPassword is false and original cipher has no key", async () => {
cipherView.viewPassword = false;
await cipherService.encrypt(cipherView, userId, undefined, undefined, new Cipher());
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
});
it("is called when cipher viewPassword is false and original cipher has a key", async () => {
cipherView.viewPassword = false;
await cipherService.encrypt(cipherView, userId, undefined, undefined, cipherObj);
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
});
});
});
});

View File

@@ -31,7 +31,6 @@ import { View } from "../../models/view/view";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateService } from "../../platform/abstractions/state.service";
import { sequentialize } from "../../platform/misc/sequentialize";
import { Utils } from "../../platform/misc/utils";
import Domain from "../../platform/models/domain/domain-base";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
@@ -223,7 +222,11 @@ export class CipherService implements CipherServiceAbstraction {
cipher.reprompt = model.reprompt;
cipher.edit = model.edit;
if (await this.getCipherKeyEncryptionEnabled()) {
if (
// prevent unprivileged users from migrating to cipher key encryption
(model.viewPassword || originalCipher?.key) &&
(await this.getCipherKeyEncryptionEnabled())
) {
cipher.key = originalCipher?.key ?? null;
const userOrOrgKey = await this.getKeyForCipherKeyDecryption(cipher, userId);
// The keyForEncryption is only used for encrypting the cipher key, not the cipher itself, since cipher key encryption is enabled.
@@ -390,7 +393,6 @@ export class CipherService implements CipherServiceAbstraction {
* cached, the cached ciphers are returned.
* @deprecated Use `cipherViews$` observable instead
*/
@sequentialize(() => "getAllDecrypted")
async getAllDecrypted(userId: UserId): Promise<CipherView[]> {
const decCiphers = await this.getDecryptedCiphers(userId);
if (decCiphers != null && decCiphers.length !== 0) {

View File

@@ -1,4 +1,4 @@
import { Observable, map } from "rxjs";
import { Observable, map, shareReplay } from "rxjs";
import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service";
@@ -46,7 +46,10 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
* {@link VaultSettingsServiceAbstraction.clickItemsToAutofillVaultView$$}
*/
readonly clickItemsToAutofillVaultView$: Observable<boolean> =
this.clickItemsToAutofillVaultViewState.state$.pipe(map((x) => x ?? false));
this.clickItemsToAutofillVaultViewState.state$.pipe(
map((x) => x ?? false),
shareReplay({ bufferSize: 1, refCount: false }),
);
constructor(private stateProvider: StateProvider) {}

View File

@@ -0,0 +1,46 @@
import { Observable } from "rxjs";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { SecurityTask } from "../models";
export abstract class TaskService {
/**
* Observable indicating if tasks are enabled for a given user.
*
* @remarks Internally, this checks the user's organization details to determine if tasks are enabled.
* @param userId
*/
abstract tasksEnabled$(userId: UserId): Observable<boolean>;
/**
* Observable of all tasks for a given user.
* @param userId
*/
abstract tasks$(userId: UserId): Observable<SecurityTask[]>;
/**
* Observable of pending tasks for a given user.
* @param userId
*/
abstract pendingTasks$(userId: UserId): Observable<SecurityTask[]>;
/**
* Retrieves tasks from the API for a given user and updates the local state.
* @param userId
*/
abstract refreshTasks(userId: UserId): Promise<void>;
/**
* Clears all the tasks from state for the given user.
* @param userId
*/
abstract clear(userId: UserId): Promise<void>;
/**
* Marks a task as complete in local state and updates the server.
* @param taskId - The ID of the task to mark as complete.
* @param userId - The user who is completing the task.
*/
abstract markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise<void>;
}

View File

@@ -0,0 +1,2 @@
export * from "./security-task-status.enum";
export * from "./security-task-type.enum";

View File

@@ -0,0 +1,11 @@
export enum SecurityTaskStatus {
/**
* Default status for newly created tasks that have not been completed.
*/
Pending = 0,
/**
* Status when a task is considered complete and has no remaining actions
*/
Completed = 1,
}

View File

@@ -0,0 +1,6 @@
export enum SecurityTaskType {
/**
* Task to update a cipher's password that was found to be at-risk by an administrator
*/
UpdateAtRiskCredential = 0,
}

View File

@@ -0,0 +1,5 @@
export * from "./enums";
export * from "./models";
export * from "./abstractions/task.service";
export * from "./services/default-task.service";

View File

@@ -0,0 +1,3 @@
export * from "./security-task";
export * from "./security-task.data";
export * from "./security-task.response";

View File

@@ -0,0 +1,34 @@
import { Jsonify } from "type-fest";
import { CipherId, OrganizationId, SecurityTaskId } from "@bitwarden/common/types/guid";
import { SecurityTaskStatus, SecurityTaskType } from "../enums";
import { SecurityTaskResponse } from "./security-task.response";
export class SecurityTaskData {
id: SecurityTaskId;
organizationId: OrganizationId;
cipherId: CipherId | undefined;
type: SecurityTaskType;
status: SecurityTaskStatus;
creationDate: Date;
revisionDate: Date;
constructor(response: SecurityTaskResponse) {
this.id = response.id;
this.organizationId = response.organizationId;
this.cipherId = response.cipherId;
this.type = response.type;
this.status = response.status;
this.creationDate = response.creationDate;
this.revisionDate = response.revisionDate;
}
static fromJSON(obj: Jsonify<SecurityTaskData>) {
return Object.assign(new SecurityTaskData({} as SecurityTaskResponse), obj, {
creationDate: new Date(obj.creationDate),
revisionDate: new Date(obj.revisionDate),
});
}
}

View File

@@ -0,0 +1,28 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { CipherId, OrganizationId, SecurityTaskId } from "@bitwarden/common/types/guid";
import { SecurityTaskStatus, SecurityTaskType } from "../enums";
export class SecurityTaskResponse extends BaseResponse {
id: SecurityTaskId;
organizationId: OrganizationId;
/**
* Optional cipherId for tasks that are related to a cipher.
*/
cipherId: CipherId | undefined;
type: SecurityTaskType;
status: SecurityTaskStatus;
creationDate: Date;
revisionDate: Date;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.organizationId = this.getResponseProperty("OrganizationId");
this.cipherId = this.getResponseProperty("CipherId") || undefined;
this.type = this.getResponseProperty("Type");
this.status = this.getResponseProperty("Status");
this.creationDate = this.getResponseProperty("CreationDate");
this.revisionDate = this.getResponseProperty("RevisionDate");
}
}

View File

@@ -0,0 +1,28 @@
import { CipherId, OrganizationId, SecurityTaskId } from "@bitwarden/common/types/guid";
import { SecurityTaskStatus, SecurityTaskType } from "../enums";
import { SecurityTaskData } from "./security-task.data";
export class SecurityTask {
id: SecurityTaskId;
organizationId: OrganizationId;
/**
* Optional cipherId for tasks that are related to a cipher.
*/
cipherId: CipherId | undefined;
type: SecurityTaskType;
status: SecurityTaskStatus;
creationDate: Date;
revisionDate: Date;
constructor(obj: SecurityTaskData) {
this.id = obj.id;
this.organizationId = obj.organizationId;
this.cipherId = obj.cipherId;
this.type = obj.type;
this.status = obj.status;
this.creationDate = obj.creationDate;
this.revisionDate = obj.revisionDate;
}
}

View File

@@ -0,0 +1,260 @@
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { SecurityTaskStatus } from "../enums";
import { SecurityTaskData, SecurityTaskResponse } from "../models";
import { SECURITY_TASKS } from "../state/security-task.state";
import { DefaultTaskService } from "./default-task.service";
describe("Default task service", () => {
let fakeStateProvider: FakeStateProvider;
const mockApiSend = jest.fn();
const mockGetAllOrgs$ = jest.fn();
const mockGetFeatureFlag$ = jest.fn();
let service: DefaultTaskService;
beforeEach(async () => {
mockApiSend.mockClear();
mockGetAllOrgs$.mockClear();
mockGetFeatureFlag$.mockClear();
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
service = new DefaultTaskService(
fakeStateProvider,
{ send: mockApiSend } as unknown as ApiService,
{ organizations$: mockGetAllOrgs$ } as unknown as OrganizationService,
{ getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService,
);
});
describe("tasksEnabled$", () => {
it("should emit true if any organization uses risk insights", async () => {
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: false,
},
{
useRiskInsights: true,
},
] as Organization[]),
);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
expect(result).toBe(true);
});
it("should emit false if no organization uses risk insights", async () => {
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: false,
},
{
useRiskInsights: false,
},
] as Organization[]),
);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
expect(result).toBe(false);
});
it("should emit false if the feature flag is off", async () => {
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(false));
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: true,
},
] as Organization[]),
);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
expect(result).toBe(false);
});
});
describe("tasks$", () => {
it("should fetch tasks from the API when the state is null", async () => {
mockApiSend.mockResolvedValue({
data: [
{
id: "task-id",
},
] as SecurityTaskResponse[],
});
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null as any);
const { tasks$ } = service;
const result = await firstValueFrom(tasks$("user-id" as UserId));
expect(result.length).toBe(1);
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);
});
it("should use the tasks from state when not null", async () => {
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [
{
id: "task-id" as SecurityTaskId,
} as SecurityTaskData,
]);
const { tasks$ } = service;
const result = await firstValueFrom(tasks$("user-id" as UserId));
expect(result.length).toBe(1);
expect(mockApiSend).not.toHaveBeenCalled();
});
it("should share the same observable for the same user", async () => {
const { tasks$ } = service;
const first = tasks$("user-id" as UserId);
const second = tasks$("user-id" as UserId);
expect(first).toBe(second);
});
});
describe("pendingTasks$", () => {
it("should filter tasks to only pending tasks", async () => {
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [
{
id: "completed-task-id" as SecurityTaskId,
status: SecurityTaskStatus.Completed,
},
{
id: "pending-task-id" as SecurityTaskId,
status: SecurityTaskStatus.Pending,
},
] as SecurityTaskData[]);
const { pendingTasks$ } = service;
const result = await firstValueFrom(pendingTasks$("user-id" as UserId));
expect(result.length).toBe(1);
expect(result[0].id).toBe("pending-task-id" as SecurityTaskId);
});
});
describe("refreshTasks()", () => {
it("should fetch tasks from the API", async () => {
mockApiSend.mockResolvedValue({
data: [
{
id: "task-id",
},
] as SecurityTaskResponse[],
});
await service.refreshTasks("user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);
});
it("should update the local state with refreshed tasks", async () => {
mockApiSend.mockResolvedValue({
data: [
{
id: "task-id",
},
] as SecurityTaskResponse[],
});
const mock = fakeStateProvider.singleUser.mockFor(
"user-id" as UserId,
SECURITY_TASKS,
null as any,
);
await service.refreshTasks("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([
{
id: "task-id" as SecurityTaskId,
} as SecurityTaskData,
]);
});
});
describe("clear()", () => {
it("should clear the local state for the user", async () => {
const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [
{
id: "task-id" as SecurityTaskId,
} as SecurityTaskData,
]);
await service.clear("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([]);
});
});
describe("markAsComplete()", () => {
it("should send an API request to mark the task as complete", async () => {
await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith(
"PATCH",
"/tasks/task-id/complete",
null,
true,
false,
);
});
it("should refresh all tasks for the user after marking the task as complete", async () => {
mockApiSend
.mockResolvedValueOnce(null) // Mark as complete
.mockResolvedValueOnce({
// Refresh tasks
data: [
{
id: "new-task-id",
},
] as SecurityTaskResponse[],
});
const mockState = fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [
{
id: "old-task-id" as SecurityTaskId,
} as SecurityTaskData,
]);
await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);
expect(mockState.nextMock).toHaveBeenCalledWith([
{
id: "new-task-id",
} as SecurityTaskData,
]);
});
});
});

View File

@@ -0,0 +1,100 @@
import { combineLatest, map, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
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 { StateProvider } from "@bitwarden/common/platform/state";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities";
import { TaskService } from "../abstractions/task.service";
import { SecurityTaskStatus } from "../enums";
import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models";
import { SECURITY_TASKS } from "../state/security-task.state";
export class DefaultTaskService implements TaskService {
constructor(
private stateProvider: StateProvider,
private apiService: ApiService,
private organizationService: OrganizationService,
private configService: ConfigService,
) {}
tasksEnabled$ = perUserCache$((userId) => {
return combineLatest([
this.organizationService
.organizations$(userId)
.pipe(map((orgs) => orgs.some((o) => o.useRiskInsights))),
this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks),
]).pipe(map(([atLeastOneOrgEnabled, flagEnabled]) => atLeastOneOrgEnabled && flagEnabled));
});
tasks$ = perUserCache$((userId) => {
return this.taskState(userId).state$.pipe(
switchMap(async (tasks) => {
if (tasks == null) {
await this.fetchTasksFromApi(userId);
}
return tasks;
}),
filterOutNullish(),
map((tasks) => tasks.map((t) => new SecurityTask(t))),
);
});
pendingTasks$ = perUserCache$((userId) => {
return this.tasks$(userId).pipe(
map((tasks) => tasks.filter((t) => t.status === SecurityTaskStatus.Pending)),
);
});
async refreshTasks(userId: UserId): Promise<void> {
await this.fetchTasksFromApi(userId);
}
async clear(userId: UserId): Promise<void> {
await this.updateTaskState(userId, []);
}
async markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise<void> {
await this.apiService.send("PATCH", `/tasks/${taskId}/complete`, null, true, false);
await this.refreshTasks(userId);
}
/**
* Fetches the tasks from the API and updates the local state
* @param userId
* @private
*/
private async fetchTasksFromApi(userId: UserId): Promise<void> {
const r = await this.apiService.send("GET", "/tasks", null, true, true);
const response = new ListResponse(r, SecurityTaskResponse);
const taskData = response.data.map((t) => new SecurityTaskData(t));
await this.updateTaskState(userId, taskData);
}
/**
* Returns the local state for the tasks
* @param userId
* @private
*/
private taskState(userId: UserId) {
return this.stateProvider.getUser(userId, SECURITY_TASKS);
}
/**
* Updates the local state with the provided tasks and returns the updated state
* @param userId
* @param tasks
* @private
*/
private updateTaskState(
userId: UserId,
tasks: SecurityTaskData[],
): Promise<SecurityTaskData[] | null> {
return this.taskState(userId).update(() => tasks);
}
}

View File

@@ -0,0 +1,14 @@
import { Jsonify } from "type-fest";
import { SECURITY_TASKS_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { SecurityTaskData } from "../models/security-task.data";
export const SECURITY_TASKS = UserKeyDefinition.array<SecurityTaskData>(
SECURITY_TASKS_DISK,
"securityTasks",
{
deserializer: (task: Jsonify<SecurityTaskData>) => SecurityTaskData.fromJSON(task),
clearOn: ["logout", "lock"],
},
);

View File

@@ -0,0 +1,37 @@
import { filter, Observable, OperatorFunction, shareReplay } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
/**
* Builds an observable once per userId and caches it for future requests.
* The built observables are shared among subscribers with a replay buffer size of 1.
* @param create - A function that creates an observable for a given userId.
*/
export function perUserCache$<TValue>(
create: (userId: UserId) => Observable<TValue>,
): (userId: UserId) => Observable<TValue> {
const cache = new Map<UserId, Observable<TValue>>();
return (userId: UserId) => {
let observable = cache.get(userId);
if (!observable) {
observable = create(userId).pipe(shareReplay({ bufferSize: 1, refCount: false }));
cache.set(userId, observable);
}
return observable;
};
}
/**
* Strongly typed observable operator that filters out null/undefined values and adjusts the return type to
* be non-nullable.
*
* @example
* ```ts
* const source$ = of(1, null, 2, undefined, 3);
* source$.pipe(filterOutNullish()).subscribe(console.log);
* // Output: 1, 2, 3
* ```
*/
export function filterOutNullish<T>(): OperatorFunction<T | undefined | null, T> {
return filter((v): v is T => v != null);
}