1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 13:40:06 +00:00

Merge branch 'main' into passkey-window-working

This commit is contained in:
Anders Åberg
2025-03-11 09:25:32 +01:00
2686 changed files with 159985 additions and 85589 deletions

View File

@@ -1,9 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
CollectionRequest,
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionRequest,
CollectionResponse,
} from "@bitwarden/admin-console/common";
@@ -70,6 +70,7 @@ import { ApiKeyResponse } from "../auth/models/response/api-key.response";
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response";
import { IdentityCaptchaResponse } from "../auth/models/response/identity-captcha.response";
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
@@ -95,7 +96,6 @@ import { PaymentResponse } from "../billing/models/response/payment.response";
import { PlanResponse } from "../billing/models/response/plan.response";
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
import { TaxInfoResponse } from "../billing/models/response/tax-info.response";
import { TaxRateResponse } from "../billing/models/response/tax-rate.response";
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
import { EventRequest } from "../models/request/event.request";
import { KdfRequest } from "../models/request/kdf.request";
@@ -137,12 +137,12 @@ import { OptionalCipherResponse } from "../vault/models/response/optional-cipher
*/
export abstract class ApiService {
send: (
method: "GET" | "POST" | "PUT" | "DELETE",
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
path: string,
body: any,
authed: boolean,
hasResponse: boolean,
apiUrl?: string,
apiUrl?: string | null,
alterHeaders?: (headers: Headers) => void,
) => Promise<any>;
@@ -152,7 +152,12 @@ export abstract class ApiService {
| SsoTokenRequest
| UserApiTokenRequest
| WebAuthnLoginTokenRequest,
) => Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse>;
) => Promise<
| IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityCaptchaResponse
| IdentityDeviceVerificationResponse
>;
refreshIdentityToken: () => Promise<any>;
getProfile: () => Promise<ProfileResponse>;
@@ -376,7 +381,6 @@ export abstract class ApiService {
): Promise<OrganizationConnectionResponse<TConfig>>;
deleteOrganizationConnection: (id: string) => Promise<void>;
getPlans: () => Promise<ListResponse<PlanResponse>>;
getTaxRates: () => Promise<ListResponse<TaxRateResponse>>;
getProviderUsers: (providerId: string) => Promise<ListResponse<ProviderUserUserDetailsResponse>>;
getProviderUser: (providerId: string, id: string) => Promise<ProviderUserResponse>;

View File

@@ -1,8 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export abstract class NotificationsService {
init: () => Promise<void>;
updateConnection: (sync?: boolean) => Promise<void>;
reconnectFromActivity: () => Promise<void>;
disconnectFromInactivity: () => Promise<void>;
}

View File

@@ -3,16 +3,21 @@
import { Observable } from "rxjs";
import { SendView } from "../tools/send/models/view/send.view";
import { IndexedEntityId } from "../types/guid";
import { IndexedEntityId, UserId } from "../types/guid";
import { CipherView } from "../vault/models/view/cipher.view";
export abstract class SearchService {
indexedEntityId$: Observable<IndexedEntityId | null>;
indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>;
clearIndex: () => Promise<void>;
isSearchable: (query: string) => Promise<boolean>;
indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => Promise<void>;
clearIndex: (userId: UserId) => Promise<void>;
isSearchable: (userId: UserId, query: string) => Promise<boolean>;
indexCiphers: (
userId: UserId,
ciphersToIndex: CipherView[],
indexedEntityGuid?: string,
) => Promise<void>;
searchCiphers: (
userId: UserId,
query: string,
filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[],
ciphers?: CipherView[],

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ListResponse } from "../../../models/response/list.response";
import { OrganizationDomainRequest } from "../../services/organization-domain/requests/organization-domain.request";
import { OrganizationDomainSsoDetailsResponse } from "./responses/organization-domain-sso-details.response";

View File

@@ -1,4 +1,4 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { BaseResponse } from "../../../../models/response/base.response";
export class VerifiedOrganizationDomainSsoDetailsResponse extends BaseResponse {
organizationName: string;

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BillingHistoryResponse } from "@bitwarden/common/billing/models/response/billing-history.response";
import { OrganizationApiKeyRequest } from "../../../admin-console/models/request/organization-api-key.request";
import { OrganizationSsoRequest } from "../../../auth/models/request/organization-sso.request";
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
@@ -13,6 +11,7 @@ import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response";
import { BillingResponse } from "../../../billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
import { PaymentResponse } from "../../../billing/models/response/payment.response";
@@ -53,11 +52,11 @@ export class OrganizationApiServiceAbstraction {
updatePasswordManagerSeats: (
id: string,
request: OrganizationSubscriptionUpdateRequest,
) => Promise<void>;
) => Promise<ProfileOrganizationResponse>;
updateSecretsManagerSubscription: (
id: string,
request: OrganizationSmSubscriptionUpdateRequest,
) => Promise<void>;
) => Promise<ProfileOrganizationResponse>;
updateSeats: (id: string, request: SeatRequest) => Promise<PaymentResponse>;
updateStorage: (id: string, request: StorageRequest) => Promise<PaymentResponse>;
verifyBank: (id: string, request: VerifyBankRequest) => Promise<void>;

View File

@@ -17,7 +17,7 @@ export function canAccessSettingsTab(org: Organization): boolean {
org.canManageSso ||
org.canManageScim ||
org.canAccessImport ||
org.canAccessExport(false) || // Feature flag value doesn't matter here, providers will have access to this group anyway
org.canAccessExport ||
org.canManageDeviceApprovals
);
}
@@ -57,14 +57,6 @@ export function getOrganizationById(id: string) {
return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id));
}
/**
* Returns `true` if a user is a member of an organization (rather than only being a ProviderUser)
* @deprecated Use organizationService.organizations$ with a filter instead
*/
export function isMember(org: Organization): boolean {
return org.isMember;
}
/**
* Publishes an observable stream of organizations. This service is meant to
* be used widely across Bitwarden as the primary way of fetching organizations.
@@ -73,41 +65,23 @@ export function isMember(org: Organization): boolean {
*/
export abstract class OrganizationService {
/**
* Publishes state for all organizations under the active user.
* Publishes state for all organizations under the specified user.
* @returns An observable list of organizations
*/
organizations$: Observable<Organization[]>;
organizations$: (userId: UserId) => Observable<Organization[]>;
// @todo Clean these up. Continuing to expand them is not recommended.
// @see https://bitwarden.atlassian.net/browse/AC-2252
memberOrganizations$: Observable<Organization[]>;
/**
* @deprecated This is currently only used in the CLI, and should not be
* used in any new calls. Use get$ instead for the time being, and we'll be
* removing this method soon. See Jira for details:
* https://bitwarden.atlassian.net/browse/AC-2252.
*/
getFromState: (id: string) => Promise<Organization>;
memberOrganizations$: (userId: UserId) => Observable<Organization[]>;
/**
* Emits true if the user can create or manage a Free Bitwarden Families sponsorship.
*/
canManageSponsorships$: Observable<boolean>;
canManageSponsorships$: (userId: UserId) => Observable<boolean>;
/**
* Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available.
*/
familySponsorshipAvailable$: Observable<boolean>;
hasOrganizations: () => Promise<boolean>;
get$: (id: string) => Observable<Organization | undefined>;
get: (id: string) => Promise<Organization>;
/**
* @deprecated This method is only used in key connector and will be removed soon as part of https://bitwarden.atlassian.net/browse/AC-2252.
*/
getAll: (userId?: string) => Promise<Organization[]>;
/**
* Publishes state for all organizations for the given user id or the active user.
*/
getAll$: (userId?: UserId) => Observable<Organization[]>;
familySponsorshipAvailable$: (userId: UserId) => Observable<boolean>;
hasOrganizations: (userId: UserId) => Observable<boolean>;
}
/**
@@ -120,20 +94,18 @@ export abstract class InternalOrganizationServiceAbstraction extends Organizatio
/**
* Replaces state for the provided organization, or creates it if not found.
* @param organization The organization state being saved.
* @param userId The userId to replace state for. Defaults to the active
* user.
* @param userId The userId to replace state for.
*/
upsert: (OrganizationData: OrganizationData) => Promise<void>;
upsert: (OrganizationData: OrganizationData, userId: UserId) => Promise<void>;
/**
* Replaces state for the entire registered organization list for the active user.
* Replaces state for the entire registered organization list for the specified user.
* You probably don't want this unless you're calling from a full sync
* operation or a logout. See `upsert` for creating & updating a single
* organization in the state.
* @param organizations A complete list of all organization state for the active
* user.
* @param userId The userId to replace state for. Defaults to the active
* @param organizations A complete list of all organization state for the provided
* user.
* @param userId The userId to replace state for.
*/
replace: (organizations: { [id: string]: OrganizationData }, userId?: UserId) => Promise<void>;
replace: (organizations: { [id: string]: OrganizationData }, userId: UserId) => Promise<void>;
}

View File

@@ -1,111 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { map, Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
export function canAccessVaultTab(org: Organization): boolean {
return org.canViewAllCollections;
}
export function canAccessSettingsTab(org: Organization): boolean {
return (
org.isOwner ||
org.canManagePolicies ||
org.canManageSso ||
org.canManageScim ||
org.canAccessImport ||
org.canAccessExport(false) || // Feature flag value doesn't matter here, providers will have access to this group anyway
org.canManageDeviceApprovals
);
}
export function canAccessMembersTab(org: Organization): boolean {
return org.canManageUsers || org.canManageUsersPassword;
}
export function canAccessGroupsTab(org: Organization): boolean {
return org.canManageGroups;
}
export function canAccessReportingTab(org: Organization): boolean {
return org.canAccessReports || org.canAccessEventLogs;
}
export function canAccessBillingTab(org: Organization): boolean {
return org.isOwner;
}
export function canAccessOrgAdmin(org: Organization): boolean {
// Admin console can only be accessed by Owners for disabled organizations
if (!org.enabled && !org.isOwner) {
return false;
}
return (
canAccessMembersTab(org) ||
canAccessGroupsTab(org) ||
canAccessReportingTab(org) ||
canAccessBillingTab(org) ||
canAccessSettingsTab(org) ||
canAccessVaultTab(org)
);
}
export function getOrganizationById(id: string) {
return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id));
}
/**
* Publishes an observable stream of organizations. This service is meant to
* be used widely across Bitwarden as the primary way of fetching organizations.
* Risky operations like updates are isolated to the
* internal extension `InternalOrganizationServiceAbstraction`.
*/
export abstract class vNextOrganizationService {
/**
* Publishes state for all organizations under the specified user.
* @returns An observable list of organizations
*/
organizations$: (userId: UserId) => Observable<Organization[]>;
// @todo Clean these up. Continuing to expand them is not recommended.
// @see https://bitwarden.atlassian.net/browse/AC-2252
memberOrganizations$: (userId: UserId) => Observable<Organization[]>;
/**
* Emits true if the user can create or manage a Free Bitwarden Families sponsorship.
*/
canManageSponsorships$: (userId: UserId) => Observable<boolean>;
/**
* Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available.
*/
familySponsorshipAvailable$: (userId: UserId) => Observable<boolean>;
hasOrganizations: (userId: UserId) => Observable<boolean>;
}
/**
* Big scary buttons that **update** organization state. These should only be
* called from within admin-console scoped code. Extends the base
* `OrganizationService` for easy access to `get` calls.
* @internal
*/
export abstract class vNextInternalOrganizationServiceAbstraction extends vNextOrganizationService {
/**
* Replaces state for the provided organization, or creates it if not found.
* @param organization The organization state being saved.
* @param userId The userId to replace state for.
*/
upsert: (OrganizationData: OrganizationData, userId: UserId) => Promise<void>;
/**
* Replaces state for the entire registered organization list for the specified user.
* You probably don't want this unless you're calling from a full sync
* operation or a logout. See `upsert` for creating & updating a single
* organization in the state.
* @param organizations A complete list of all organization state for the provided
* user.
* @param userId The userId to replace state for.
*/
replace: (organizations: { [id: string]: OrganizationData }, userId: UserId) => Promise<void>;
}

View File

@@ -30,7 +30,7 @@ export abstract class PolicyService {
* 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[]>;
getAll$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
/**
* All {@link Policy} objects for the specified user (from sync data).

View File

@@ -0,0 +1,68 @@
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

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request";
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
@@ -14,4 +16,12 @@ export class ProviderApiServiceAbstraction {
request: ProviderVerifyRecoverDeleteRequest,
) => Promise<any>;
deleteProvider: (id: string) => Promise<void>;
getProviderAddableOrganizations: (providerId: string) => Promise<AddableOrganizationResponse[]>;
addOrganizationToProvider: (
providerId: string,
request: {
key: string;
organizationId: string;
},
) => Promise<void>;
}

View File

@@ -0,0 +1,7 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type.enum";
describe("PolicyType", () => {
it("RemoveUnlockWithPin should be 14", () => {
expect(PolicyType.RemoveUnlockWithPin).toBe(14);
});
});

View File

@@ -13,4 +13,5 @@ export enum PolicyType {
ActivateAutofill = 11, // Activates autofill with page load on the browser extension
AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN.
}

View File

@@ -1,6 +1,6 @@
import { ProductTierType } from "../../../billing/enums/product-tier-type.enum";
import { OrganizationUserStatusType, OrganizationUserType } from "../../enums";
import { ORGANIZATIONS } from "../../services/organization/organization.service";
import { ORGANIZATIONS } from "../../services/organization/organization.state";
import { OrganizationData } from "./organization.data";
@@ -53,6 +53,7 @@ describe("ORGANIZATIONS state", () => {
accessSecretsManager: false,
limitCollectionCreation: false,
limitCollectionDeletion: false,
limitItemDeletion: false,
allowAdminAccessToAllCollectionItems: false,
familySponsorshipLastSyncDate: new Date(),
userIsManagedByOrganization: false,

View File

@@ -56,6 +56,7 @@ export class OrganizationData {
accessSecretsManager: boolean;
limitCollectionCreation: boolean;
limitCollectionDeletion: boolean;
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
@@ -117,6 +118,7 @@ export class OrganizationData {
this.accessSecretsManager = response.accessSecretsManager;
this.limitCollectionCreation = response.limitCollectionCreation;
this.limitCollectionDeletion = response.limitCollectionDeletion;
this.limitItemDeletion = response.limitItemDeletion;
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
this.useRiskInsights = response.useRiskInsights;

View File

@@ -1,4 +1,4 @@
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { OrgKey, UserPrivateKey } from "../../../types/key";
@@ -58,6 +58,9 @@ export class ProviderEncryptedOrganizationKey implements BaseEncryptedOrganizati
new EncString(this.key),
providerKeys[this.providerId],
);
if (decValue == null) {
throw new Error("Failed to decrypt organization key");
}
return new SymmetricCryptoKey(decValue) as OrgKey;
}

View File

@@ -76,6 +76,12 @@ export class Organization {
/**
* Refers to the ability for an owner/admin to access all collection items, regardless of assigned collections
*/
limitItemDeletion: boolean;
/**
* Refers to the ability to limit delete permission of collection items.
* If set to true, members can only delete items when they have a Can Manage permission over the collection.
* If set to false, members can delete items when they have a Can Manage OR Can Edit permission over the collection.
*/
allowAdminAccessToAllCollectionItems: boolean;
/**
* Indicates if this organization manages the user.
@@ -138,6 +144,7 @@ export class Organization {
this.accessSecretsManager = obj.accessSecretsManager;
this.limitCollectionCreation = obj.limitCollectionCreation;
this.limitCollectionDeletion = obj.limitCollectionDeletion;
this.limitItemDeletion = obj.limitItemDeletion;
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
this.useRiskInsights = obj.useRiskInsights;
@@ -182,11 +189,7 @@ export class Organization {
);
}
canAccessExport(removeProviderExport: boolean) {
if (!removeProviderExport && this.isProviderUser) {
return true;
}
get canAccessExport() {
return (
this.isMember &&
(this.type === OrganizationUserType.Owner ||

View File

@@ -3,5 +3,6 @@
export class OrganizationCollectionManagementUpdateRequest {
limitCollectionCreation: boolean;
limitCollectionDeletion: boolean;
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
}

View File

@@ -0,0 +1,18 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class AddableOrganizationResponse extends BaseResponse {
id: string;
plan: string;
name: string;
seats: number;
disabled: boolean;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("id");
this.plan = this.getResponseProperty("plan");
this.name = this.getResponseProperty("name");
this.seats = this.getResponseProperty("seats");
this.disabled = this.getResponseProperty("disabled");
}
}

View File

@@ -36,6 +36,7 @@ export class OrganizationResponse extends BaseResponse {
maxAutoscaleSmServiceAccounts?: number;
limitCollectionCreation: boolean;
limitCollectionDeletion: boolean;
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
useRiskInsights: boolean;
@@ -75,6 +76,7 @@ export class OrganizationResponse extends BaseResponse {
this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts");
this.limitCollectionCreation = this.getResponseProperty("LimitCollectionCreation");
this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion");
this.limitItemDeletion = this.getResponseProperty("LimitItemDeletion");
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
"AllowAdminAccessToAllCollectionItems",
);

View File

@@ -51,6 +51,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
accessSecretsManager: boolean;
limitCollectionCreation: boolean;
limitCollectionDeletion: boolean;
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
@@ -114,6 +115,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager");
this.limitCollectionCreation = this.getResponseProperty("LimitCollectionCreation");
this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion");
this.limitItemDeletion = this.getResponseProperty("LimitItemDeletion");
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
"AllowAdminAccessToAllCollectionItems",
);

View File

@@ -1,14 +1,13 @@
import { mock } from "jest-mock-extended";
import { lastValueFrom } from "rxjs";
import { VerifiedOrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/verified-organization-domain-sso-details.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ApiService } from "../../../abstractions/api.service";
import { ListResponse } from "../../../models/response/list.response";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { OrganizationDomainSsoDetailsResponse } from "../../abstractions/organization-domain/responses/organization-domain-sso-details.response";
import { OrganizationDomainResponse } from "../../abstractions/organization-domain/responses/organization-domain.response";
import { VerifiedOrganizationDomainSsoDetailsResponse } from "../../abstractions/organization-domain/responses/verified-organization-domain-sso-details.response";
import { OrgDomainApiService } from "./org-domain-api.service";
import { OrgDomainService } from "./org-domain.service";

View File

@@ -6,11 +6,11 @@ import { OrganizationId, UserId } from "../../../types/guid";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
import { DefaultvNextOrganizationService } from "./default-vnext-organization.service";
import { ORGANIZATIONS } from "./vnext-organization.state";
import { DefaultOrganizationService } from "./default-organization.service";
import { ORGANIZATIONS } from "./organization.state";
describe("OrganizationService", () => {
let organizationService: DefaultvNextOrganizationService;
let organizationService: DefaultOrganizationService;
const fakeUserId = Utils.newGuid() as UserId;
let fakeStateProvider: FakeStateProvider;
@@ -86,7 +86,7 @@ describe("OrganizationService", () => {
beforeEach(async () => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(fakeUserId));
organizationService = new DefaultvNextOrganizationService(fakeStateProvider);
organizationService = new DefaultOrganizationService(fakeStateProvider);
});
describe("canManageSponsorships", () => {

View File

@@ -4,11 +4,11 @@ import { map, Observable } from "rxjs";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { vNextInternalOrganizationServiceAbstraction } from "../../abstractions/organization/vnext.organization.service";
import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
import { ORGANIZATIONS } from "./vnext-organization.state";
import { ORGANIZATIONS } from "./organization.state";
/**
* Filter out organizations from an observable that __do not__ offer a
@@ -41,9 +41,7 @@ function mapToBooleanHasAnyOrganizations() {
return map<Organization[], boolean>((orgs) => orgs.length > 0);
}
export class DefaultvNextOrganizationService
implements vNextInternalOrganizationServiceAbstraction
{
export class DefaultOrganizationService implements InternalOrganizationServiceAbstraction {
memberOrganizations$(userId: UserId): Observable<Organization[]> {
return this.organizations$(userId).pipe(mapToExcludeProviderOrganizations());
}

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BillingHistoryResponse } from "@bitwarden/common/billing/models/response/billing-history.response";
import { ApiService } from "../../../abstractions/api.service";
import { OrganizationApiKeyRequest } from "../../../admin-console/models/request/organization-api-key.request";
@@ -14,6 +13,7 @@ import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response";
import { BillingResponse } from "../../../billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
import { PaymentResponse } from "../../../billing/models/response/payment.response";
@@ -161,27 +161,29 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
async updatePasswordManagerSeats(
id: string,
request: OrganizationSubscriptionUpdateRequest,
): Promise<void> {
return this.apiService.send(
): Promise<ProfileOrganizationResponse> {
const r = await this.apiService.send(
"POST",
"/organizations/" + id + "/subscription",
request,
true,
false,
true,
);
return new ProfileOrganizationResponse(r);
}
async updateSecretsManagerSubscription(
id: string,
request: OrganizationSmSubscriptionUpdateRequest,
): Promise<void> {
return this.apiService.send(
): Promise<ProfileOrganizationResponse> {
const r = await this.apiService.send(
"POST",
"/organizations/" + id + "/sm-subscription",
request,
true,
false,
true,
);
return new ProfileOrganizationResponse(r);
}
async updateSeats(id: string, request: SeatRequest): Promise<PaymentResponse> {

View File

@@ -1,324 +0,0 @@
import { firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState } from "../../../../spec/fake-state";
import { Utils } from "../../../platform/misc/utils";
import { OrganizationId, UserId } from "../../../types/guid";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
import { OrganizationService, ORGANIZATIONS } from "./organization.service";
describe("OrganizationService", () => {
let organizationService: OrganizationService;
const fakeUserId = Utils.newGuid() as UserId;
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeActiveUserState: FakeActiveUserState<Record<string, OrganizationData>>;
/**
* It is easier to read arrays than records in code, but we store a record
* in state. This helper methods lets us build organization arrays in tests
* and easily map them to records before storing them in state.
*/
function arrayToRecord(input: OrganizationData[]): Record<OrganizationId, OrganizationData> {
if (input == null) {
return undefined;
}
return Object.fromEntries(input?.map((i) => [i.id, i]));
}
/**
* There are a few assertions in this spec that check for array equality
* but want to ignore a specific index that _should_ be different. This
* function takes two arrays, and an index. It checks for equality of the
* arrays, but splices out the specified index from both arrays first.
*/
function expectIsEqualExceptForIndex(x: any[], y: any[], indexToExclude: number) {
// Clone the arrays to avoid modifying the reference values
const a = [...x];
const b = [...y];
delete a[indexToExclude];
delete b[indexToExclude];
expect(a).toEqual(b);
}
/**
* Builds a simple mock `OrganizationData[]` array that can be used in tests
* to populate state.
* @param count The number of organizations to populate the list with. The
* function returns undefined if this is less than 1. The default value is 1.
* @param suffix A string to append to data fields on each organization.
* This defaults to the index of the organization in the list.
* @returns an `OrganizationData[]` array that can be used to populate
* stateProvider.
*/
function buildMockOrganizations(count = 1, suffix?: string): OrganizationData[] {
if (count < 1) {
return undefined;
}
function buildMockOrganization(id: OrganizationId, name: string, identifier: string) {
const data = new OrganizationData({} as any, {} as any);
data.id = id;
data.name = name;
data.identifier = identifier;
return data;
}
const mockOrganizations = [];
for (let i = 0; i < count; i++) {
const s = suffix ? suffix + i.toString() : i.toString();
mockOrganizations.push(
buildMockOrganization(("org" + s) as OrganizationId, "org" + s, "orgIdentifier" + s),
);
}
return mockOrganizations;
}
/**
* `OrganizationService` deals with multiple accounts at times. This helper
* function can be used to add a new non-active account to the test data.
* This function is **not** needed to handle creation of the first account,
* as that is handled by the `FakeAccountService` in `mockAccountServiceWith()`
* @returns The `UserId` of the newly created state account and the mock data
* created for them as an `Organization[]`.
*/
async function addNonActiveAccountToStateProvider(): Promise<[UserId, OrganizationData[]]> {
const nonActiveUserId = Utils.newGuid() as UserId;
const mockOrganizations = buildMockOrganizations(10);
const fakeNonActiveUserState = fakeStateProvider.singleUser.getFake(
nonActiveUserId,
ORGANIZATIONS,
);
fakeNonActiveUserState.nextState(arrayToRecord(mockOrganizations));
return [nonActiveUserId, mockOrganizations];
}
beforeEach(async () => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeActiveUserState = fakeStateProvider.activeUser.getFake(ORGANIZATIONS);
organizationService = new OrganizationService(fakeStateProvider);
});
it("getAll", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const orgs = await organizationService.getAll();
expect(orgs).toHaveLength(1);
const org = orgs[0];
expect(org).toEqual(new Organization(mockData[0]));
});
describe("canManageSponsorships", () => {
it("can because one is available", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipAvailable = true;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.canManageSponsorships$);
expect(result).toBe(true);
});
it("can because one is used", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipFriendlyName = "Something";
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.canManageSponsorships$);
expect(result).toBe(true);
});
it("can not because one isn't available or taken", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipFriendlyName = null;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.canManageSponsorships$);
expect(result).toBe(false);
});
});
describe("get", () => {
it("exists", async () => {
const mockData = buildMockOrganizations(1);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.get(mockData[0].id);
expect(result).toEqual(new Organization(mockData[0]));
});
it("does not exist", async () => {
const result = await organizationService.get("this-org-does-not-exist");
expect(result).toBe(undefined);
});
});
describe("organizations$", () => {
describe("null checking behavior", () => {
it("publishes an empty array if organizations in state = undefined", async () => {
const mockData: OrganizationData[] = undefined;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
it("publishes an empty array if organizations in state = null", async () => {
const mockData: OrganizationData[] = null;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
it("publishes an empty array if organizations in state = []", async () => {
const mockData: OrganizationData[] = [];
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
});
describe("parameter handling & returns", () => {
it("publishes all organizations for the active user by default", async () => {
const mockData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(mockData);
});
it("can be used to publish the organizations of a non active user if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserState");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, nonActiveUserMockOrganizations] =
await addNonActiveAccountToStateProvider();
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result).toEqual(nonActiveUserMockOrganizations);
expect(result).not.toEqual(await firstValueFrom(organizationService.organizations$));
});
});
});
describe("upsert()", () => {
it("can create the organization list if necassary", async () => {
// Notice that no default state is provided in this test, so the list in
// `stateProvider` will be null when the `upsert` method is called.
const mockData = buildMockOrganizations();
await organizationService.upsert(mockData[0]);
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(mockData.map((x) => new Organization(x)));
});
it("updates an organization that already exists in state, defaulting to the active user", async () => {
const mockData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const indexToUpdate = 5;
const anUpdatedOrganization = {
...buildMockOrganizations(1, "UPDATED").pop(),
id: mockData[indexToUpdate].id,
};
await organizationService.upsert(anUpdatedOrganization);
const result = await firstValueFrom(organizationService.organizations$);
expect(result[indexToUpdate]).not.toEqual(new Organization(mockData[indexToUpdate]));
expect(result[indexToUpdate].id).toEqual(new Organization(mockData[indexToUpdate]).id);
expectIsEqualExceptForIndex(
result,
mockData.map((x) => new Organization(x)),
indexToUpdate,
);
});
it("can also update an organization in state for a non-active user, if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, nonActiveUserMockOrganizations] =
await addNonActiveAccountToStateProvider();
const indexToUpdate = 5;
const anUpdatedOrganization = {
...buildMockOrganizations(1, "UPDATED").pop(),
id: nonActiveUserMockOrganizations[indexToUpdate].id,
};
await organizationService.upsert(anUpdatedOrganization, nonActiveUserId);
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result[indexToUpdate]).not.toEqual(
new Organization(nonActiveUserMockOrganizations[indexToUpdate]),
);
expect(result[indexToUpdate].id).toEqual(
new Organization(nonActiveUserMockOrganizations[indexToUpdate]).id,
);
expectIsEqualExceptForIndex(
result,
nonActiveUserMockOrganizations.map((x) => new Organization(x)),
indexToUpdate,
);
// Just to be safe, lets make sure the active user didn't get updated
// at all
const activeUserState = await firstValueFrom(organizationService.organizations$);
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
expect(activeUserState).not.toEqual(result);
});
});
describe("replace()", () => {
it("replaces the entire organization list in state, defaulting to the active user", async () => {
const originalData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(originalData));
const newData = buildMockOrganizations(10, "newData");
await organizationService.replace(arrayToRecord(newData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(newData);
expect(result).not.toEqual(originalData);
});
// This is more or less a test for logouts
it("can replace state with null", async () => {
const originalData = buildMockOrganizations(2);
fakeActiveUserState.nextState(arrayToRecord(originalData));
await organizationService.replace(null);
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
expect(result).not.toEqual(originalData);
});
it("can also replace state for a non-active user, if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, originalOrganizations] = await addNonActiveAccountToStateProvider();
const newData = buildMockOrganizations(10, "newData");
await organizationService.replace(arrayToRecord(newData), nonActiveUserId);
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result).toEqual(newData);
expect(result).not.toEqual(originalOrganizations);
// Just to be safe, lets make sure the active user didn't get updated
// at all
const activeUserState = await firstValueFrom(organizationService.organizations$);
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
expect(activeUserState).not.toEqual(result);
});
});
});

View File

@@ -1,160 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { map, Observable, firstValueFrom } from "rxjs";
import { Jsonify } from "type-fest";
import { ORGANIZATIONS_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
/**
* The `KeyDefinition` for accessing organization lists in application state.
* @todo Ideally this wouldn't require a `fromJSON()` call, but `OrganizationData`
* has some properties that contain functions. This should probably get
* cleaned up.
*/
export const ORGANIZATIONS = UserKeyDefinition.record<OrganizationData>(
ORGANIZATIONS_DISK,
"organizations",
{
deserializer: (obj: Jsonify<OrganizationData>) => OrganizationData.fromJSON(obj),
clearOn: ["logout"],
},
);
/**
* Filter out organizations from an observable that __do not__ offer a
* families-for-enterprise sponsorship to members.
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToExcludeOrganizationsWithoutFamilySponsorshipSupport() {
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.canManageSponsorships));
}
/**
* Filter out organizations from an observable that the organization user
* __is not__ a direct member of. This will exclude organizations only
* accessible as a provider.
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToExcludeProviderOrganizations() {
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.isMember));
}
/**
* Map an observable stream of organizations down to a boolean indicating
* if any organizations exist (`orgs.length > 0`).
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToBooleanHasAnyOrganizations() {
return map<Organization[], boolean>((orgs) => orgs.length > 0);
}
/**
* Map an observable stream of organizations down to a single organization.
* @param `organizationId` The ID of the organization you'd like to subscribe to
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToSingleOrganization(organizationId: string) {
return map<Organization[], Organization>((orgs) => orgs?.find((o) => o.id === organizationId));
}
export class OrganizationService implements InternalOrganizationServiceAbstraction {
organizations$: Observable<Organization[]> = this.getOrganizationsFromState$();
memberOrganizations$: Observable<Organization[]> = this.organizations$.pipe(
mapToExcludeProviderOrganizations(),
);
constructor(private stateProvider: StateProvider) {}
get$(id: string): Observable<Organization | undefined> {
return this.organizations$.pipe(mapToSingleOrganization(id));
}
getAll$(userId?: UserId): Observable<Organization[]> {
return this.getOrganizationsFromState$(userId);
}
async getAll(userId?: string): Promise<Organization[]> {
return await firstValueFrom(this.getOrganizationsFromState$(userId as UserId));
}
canManageSponsorships$ = this.organizations$.pipe(
mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(),
mapToBooleanHasAnyOrganizations(),
);
familySponsorshipAvailable$ = this.organizations$.pipe(
map((orgs) => orgs.some((o) => o.familySponsorshipAvailable)),
);
async hasOrganizations(): Promise<boolean> {
return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations()));
}
async upsert(organization: OrganizationData, userId?: UserId): Promise<void> {
await this.stateFor(userId).update((existingOrganizations) => {
const organizations = existingOrganizations ?? {};
organizations[organization.id] = organization;
return organizations;
});
}
async get(id: string): Promise<Organization> {
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
}
/**
* @deprecated For the CLI only
* @param id id of the organization
*/
async getFromState(id: string): Promise<Organization> {
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
}
async replace(organizations: { [id: string]: OrganizationData }, userId?: UserId): Promise<void> {
await this.stateFor(userId).update(() => organizations);
}
// Ideally this method would be renamed to organizations$() and the
// $organizations observable as it stands would be removed. This will
// require updates to callers, and so this method exists as a temporary
// workaround until we have time & a plan to update callers.
//
// It can be thought of as "organizations$ but with a userId option".
private getOrganizationsFromState$(userId?: UserId): Observable<Organization[] | undefined> {
return this.stateFor(userId).state$.pipe(this.mapOrganizationRecordToArray());
}
/**
* Accepts a record of `OrganizationData`, which is how we store the
* organization list as a JSON object on disk, to an array of
* `Organization`, which is how the data is published to callers of the
* service.
* @returns a function that can be used to pipe organization data from
* stored state to an exposed object easily consumable by others.
*/
private mapOrganizationRecordToArray() {
return map<Record<string, OrganizationData>, Organization[]>((orgs) =>
Object.values(orgs ?? {})?.map((o) => new Organization(o)),
);
}
/**
* Fetches the organization list from on disk state for the specified user.
* @param userId the user ID to fetch the organization list for. Defaults to
* the currently active user.
* @returns an observable of organization state as it is stored on disk.
*/
private stateFor(userId?: UserId) {
return userId
? this.stateProvider.getUser(userId, ORGANIZATIONS)
: this.stateProvider.getActive(ORGANIZATIONS);
}
}

View File

@@ -1,7 +1,6 @@
import { Jsonify } from "type-fest";
import { ORGANIZATIONS_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { ORGANIZATIONS_DISK, UserKeyDefinition } from "../../../platform/state";
import { OrganizationData } from "../../models/data/organization.data";
/**

View File

@@ -0,0 +1,590 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { 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 } 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";
describe("PolicyService", () => {
const userId = "userId" as UserId;
let stateProvider: FakeStateProvider;
let organizationService: MockProxy<OrganizationService>;
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
let policyService: DefaultvNextPolicyService;
beforeEach(() => {
const accountService = mockAccountServiceWith(userId);
stateProvider = new FakeStateProvider(accountService);
organizationService = mock<OrganizationService>();
singleUserState = stateProvider.singleUser.getFake(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$.calledWith(userId).mockReturnValue(organizations$);
policyService = new DefaultvNextPolicyService(stateProvider, organizationService);
});
it("upsert", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
]),
);
await policyService.upsert(
policyData("99", "test-organization", PolicyType.DisableSend, true),
userId,
);
expect(await firstValueFrom(policyService.policies$(userId))).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 () => {
singleUserState.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$(userId))).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)),
];
jest.spyOn(policyService as any, "policies$").mockReturnValue(of(model));
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(userId));
expect(result).toEqual({
minComplexity: 5,
minLength: 20,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
requireUpper: true,
enforceOnLogin: false,
});
});
it("returns undefined", 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),
),
];
jest.spyOn(policyService as any, "policies$").mockReturnValue(of(model));
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(userId));
expect(result).toBeUndefined();
});
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)),
];
jest.spyOn(policyService as any, "policies$").mockReturnValue(of(model));
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(userId));
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("policiesByType$", () => {
it("returns the specified PolicyType", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService
.policiesByType$(PolicyType.DisablePersonalVaultExport, userId)
.pipe(getFirstPolicy),
);
expect(result).toEqual({
id: "policy2",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
});
});
it("does not return disabled policies", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService
.policiesByType$(PolicyType.DisablePersonalVaultExport, userId)
.pipe(getFirstPolicy),
);
expect(result).toBeUndefined();
});
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", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService
.policiesByType$(PolicyType.DisablePersonalVaultExport, userId)
.pipe(getFirstPolicy),
);
expect(result).toBeUndefined();
});
it.each([
["owners", "org2"],
["administrators", "org6"],
])("returns the password generator policy for %s", async (_, organization) => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, false),
policyData("policy2", organization, PolicyType.PasswordGenerator, true),
]),
);
const result = await firstValueFrom(
policyService.policiesByType$(PolicyType.PasswordGenerator, userId).pipe(getFirstPolicy),
);
expect(result).toBeTruthy();
});
it("does not return policies for organizations that do not use policies", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org3", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.policiesByType$(PolicyType.ActivateAutofill, userId).pipe(getFirstPolicy),
);
expect(result).toBeUndefined();
});
});
describe("policies$", () => {
it("returns all policies when none are disabled", 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.policies$(userId));
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("returns all policies when some are disabled", 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.policies$(userId));
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: false,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("returns 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.policies$(userId));
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org2",
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.policies$(userId));
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
},
{
id: "policy3",
organizationId: "org3",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
});
describe("policyAppliesToUser$", () => {
it("returns true when the policyType applies to the user", 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.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId),
);
expect(result).toBe(true);
});
it("returns false when policyType is disabled", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId),
);
expect(result).toBe(false);
});
it("returns false when the policyType does not apply to the user because the user's role is exempt", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId),
);
expect(result).toBe(false);
});
it("returns false for organizations that do not use policies", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId),
);
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

@@ -0,0 +1,240 @@
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 { 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";
import { POLICIES } from "./vnext-policy-state";
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) {
return Object.values(policiesMap || {}).map((f) => new Policy(f));
}
export const getFirstPolicy = map<Policy[], Policy | undefined>((policies) => {
return policies.at(0) ?? undefined;
});
export class DefaultvNextPolicyService implements vNextPolicyService {
constructor(
private stateProvider: StateProvider,
private organizationService: OrganizationService,
) {}
private policyState(userId: UserId) {
return this.stateProvider.getUser(userId, POLICIES);
}
private policyData$(userId: UserId) {
return this.policyState(userId).state$.pipe(map((policyData) => policyData ?? {}));
}
policies$(userId: UserId) {
return this.policyData$(userId).pipe(map((policyData) => policyRecordToArray(policyData)));
}
policiesByType$(policyType: PolicyType, userId: UserId) {
const filteredPolicies$ = this.policies$(userId).pipe(
map((policies) => policies.filter((p) => p.type === policyType)),
);
if (!userId) {
throw new Error("No userId provided");
}
const organizations$ = this.organizationService.organizations$(userId);
return combineLatest([filteredPolicies$, organizations$]).pipe(
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
);
}
policyAppliesToUser$(policyType: PolicyType, userId: UserId) {
return this.policiesByType$(policyType, userId).pipe(
getFirstPolicy,
map((policy) => !!policy),
);
}
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) {
return true;
}
return (
policy.enabled &&
organization.status >= OrganizationUserStatusType.Accepted &&
organization.usePolicies &&
!this.isExemptFromPolicy(policy.type, organization)
);
});
}
masterPasswordPolicyOptions$(
userId: UserId,
policies?: Policy[],
): Observable<MasterPasswordPolicyOptions | undefined> {
const policies$ = policies ? of(policies) : this.policies$(userId);
return policies$.pipe(
map((obsPolicies) => {
const enforcedOptions: MasterPasswordPolicyOptions = new MasterPasswordPolicyOptions();
const filteredPolicies =
obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? [];
if (filteredPolicies.length === 0) {
return;
}
filteredPolicies.forEach((currentPolicy) => {
if (!currentPolicy.enabled || !currentPolicy.data) {
return;
}
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) {
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 || !orgId) {
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, userId: UserId): Promise<void> {
await this.policyState(userId).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;
default:
return organization.canManagePolicies;
}
}
}

View File

@@ -2,8 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState } from "../../../../spec/fake-state";
import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
import { FakeActiveUserState, FakeSingleUserState } from "../../../../spec/fake-state";
import {
OrganizationUserStatusType,
OrganizationUserType,
@@ -18,12 +17,14 @@ 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;
@@ -33,6 +34,7 @@ describe("PolicyService", () => {
organizationService = mock<OrganizationService>();
activeUserState = stateProvider.activeUser.getFake(POLICIES);
singleUserState = stateProvider.singleUser.getFake(activeUserState.userId, POLICIES);
const organizations$ = of([
// User
@@ -56,9 +58,7 @@ describe("PolicyService", () => {
organization("org6", true, true, OrganizationUserStatusType.Confirmed, true),
]);
organizationService.organizations$ = organizations$;
organizationService.getAll$.mockReturnValue(organizations$);
organizationService.organizations$.mockReturnValue(organizations$);
policyService = new PolicyService(stateProvider, organizationService);
});
@@ -196,7 +196,7 @@ describe("PolicyService", () => {
describe("getResetPasswordPolicyOptions", () => {
it("default", async () => {
const result = policyService.getResetPasswordPolicyOptions(null, null);
const result = policyService.getResetPasswordPolicyOptions([], "");
expect(result).toEqual([new ResetPasswordPolicyOptions(), false]);
});
@@ -220,19 +220,34 @@ describe("PolicyService", () => {
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
policyData("policy3", "org1", PolicyType.RemoveUnlockWithPin, true),
]),
);
const result = await firstValueFrom(
policyService.get$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual({
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 () => {
@@ -297,7 +312,7 @@ describe("PolicyService", () => {
describe("getAll$", () => {
it("returns the specified PolicyTypes", async () => {
activeUserState.nextState(
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
@@ -307,7 +322,7 @@ describe("PolicyService", () => {
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
@@ -333,7 +348,7 @@ describe("PolicyService", () => {
});
it("does not return disabled policies", async () => {
activeUserState.nextState(
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
@@ -343,7 +358,7 @@ describe("PolicyService", () => {
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
@@ -363,7 +378,7 @@ describe("PolicyService", () => {
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
activeUserState.nextState(
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
@@ -373,7 +388,7 @@ describe("PolicyService", () => {
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
@@ -393,7 +408,7 @@ describe("PolicyService", () => {
});
it("does not return policies for organizations that do not use policies", async () => {
activeUserState.nextState(
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
@@ -403,7 +418,7 @@ describe("PolicyService", () => {
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, firstValueFrom, map, Observable, of } from "rxjs";
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { UserKeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
import { PolicyId, UserId } from "../../../types/guid";
@@ -39,7 +39,11 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
map((policies) => policies.filter((p) => p.type === policyType)),
);
return combineLatest([filteredPolicies$, this.organizationService.organizations$]).pipe(
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,
@@ -47,13 +51,13 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
);
}
getAll$(policyType: PolicyType, userId?: UserId) {
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.getAll$(userId)]).pipe(
return combineLatest([filteredPolicies$, this.organizationService.organizations$(userId)]).pipe(
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
);
}
@@ -243,6 +247,9 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
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

@@ -0,0 +1,8 @@
import { POLICIES_DISK, UserKeyDefinition } from "../../../platform/state";
import { PolicyId } from "../../../types/guid";
import { PolicyData } from "../../models/data/policy.data";
export const POLICIES = UserKeyDefinition.record<PolicyData, PolicyId>(POLICIES_DISK, "policies", {
deserializer: (policyData) => policyData,
clearOn: ["logout"],
});

View File

@@ -1,3 +1,5 @@
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { ApiService } from "../../../abstractions/api.service";
import { ProviderApiServiceAbstraction } from "../../abstractions/provider/provider-api.service.abstraction";
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
@@ -44,4 +46,34 @@ export class ProviderApiService implements ProviderApiServiceAbstraction {
async deleteProvider(id: string): Promise<void> {
await this.apiService.send("DELETE", "/providers/" + id, null, true, false);
}
async getProviderAddableOrganizations(
providerId: string,
): Promise<AddableOrganizationResponse[]> {
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/clients/addable",
null,
true,
true,
);
return response.map((data: any) => new AddableOrganizationResponse(data));
}
addOrganizationToProvider(
providerId: string,
request: {
key: string;
organizationId: string;
},
): Promise<void> {
return this.apiService.send(
"POST",
"/providers/" + providerId + "/clients/existing",
request,
true,
false,
);
}
}

View File

@@ -1,6 +1,7 @@
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
import { SetVerifyDevicesRequest } from "../models/request/set-verify-devices.request";
import { Verification } from "../types/verification";
export abstract class AccountApiService {
@@ -18,7 +19,7 @@ export abstract class AccountApiService {
*
* @param request - The request object containing
* information needed to send the verification email, such as the user's email address.
* @returns A promise that resolves to a string tokencontaining the user's encrypted
* @returns A promise that resolves to a string token containing the user's encrypted
* information which must be submitted to complete registration or `null` if
* email verification is enabled (users must get the token by clicking a
* link in the email that will be sent to them).
@@ -33,7 +34,7 @@ export abstract class AccountApiService {
*
* @param request - The request object containing the email verification token and the
* user's email address (which is required to validate the token)
* @returns A promise that resolves when the event is logged on the server succcessfully or a bad
* @returns A promise that resolves when the event is logged on the server successfully or a bad
* request if the token is invalid for any reason.
*/
abstract registerVerificationEmailClicked(
@@ -50,4 +51,15 @@ export abstract class AccountApiService {
* registration process is successfully completed.
*/
abstract registerFinish(request: RegisterFinishRequest): Promise<string>;
/**
* Sets the [dbo].[User].[VerifyDevices] flag to true or false.
*
* @param request - The request object is a SecretVerificationRequest extension
* that also contains the boolean value that the VerifyDevices property is being
* set to.
* @returns A promise that resolves when the process is successfully completed or
* a bad request if secret verification fails.
*/
abstract setVerifyDevices(request: SetVerifyDevicesRequest): Promise<string>;
}

View File

@@ -43,6 +43,8 @@ export abstract class AccountService {
* Observable of the last activity time for each account.
*/
accountActivity$: Observable<Record<UserId, Date>>;
/** Observable of the new device login verification property for the account. */
accountVerifyNewDeviceLogin$: Observable<boolean>;
/** Account list in order of descending recency */
sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */
@@ -73,6 +75,15 @@ export abstract class AccountService {
* @param emailVerified
*/
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
/**
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
* @param userId
* @param VerifyNewDeviceLogin
*/
abstract setAccountVerifyNewDeviceLogin(
userId: UserId,
verifyNewDeviceLogin: boolean,
): Promise<void>;
/**
* Updates the `activeAccount$` observable with the new active account.
* @param userId

View File

@@ -9,7 +9,24 @@ import { DeviceKey, UserKey } from "../../types/key";
import { DeviceResponse } from "./devices/responses/device.response";
export abstract class DeviceTrustServiceAbstraction {
/**
* @deprecated - use supportsDeviceTrustByUserId instead as active user state is being deprecated
* by Platform
* @description Checks if the device trust feature is supported for the active user.
*/
supportsDeviceTrust$: Observable<boolean>;
/**
* Emits when a device has been trusted. This emission is specifically for the purpose of notifying
* the consuming component to display a toast informing the user the device has been trusted.
*/
deviceTrusted$: Observable<void>;
/**
* @description Checks if the device trust feature is supported for the given user.
*/
supportsDeviceTrustByUserId$: (userId: UserId) => Observable<boolean>;
/**
* @description Retrieves the users choice to trust the device which can only happen after decryption
* Note: this value should only be used once and then reset

View File

@@ -36,4 +36,10 @@ export abstract class DevicesApiServiceAbstraction {
* @param deviceIdentifier - current device identifier
*/
postDeviceTrustLoss: (deviceIdentifier: string) => Promise<void>;
/**
* Deactivates a device
* @param deviceId - The device ID
*/
deactivateDevice: (deviceId: string) => Promise<void>;
}

View File

@@ -1,17 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { DeviceResponse } from "./responses/device.response";
import { DeviceView } from "./views/device.view";
export abstract class DevicesServiceAbstraction {
getDevices$: () => Observable<Array<DeviceView>>;
getDeviceByIdentifier$: (deviceIdentifier: string) => Observable<DeviceView>;
isDeviceKnownForUser$: (email: string, deviceIdentifier: string) => Observable<boolean>;
updateTrustedDeviceKeys$: (
abstract getDevices$(): Observable<Array<DeviceView>>;
abstract getDeviceByIdentifier$(deviceIdentifier: string): Observable<DeviceView>;
abstract isDeviceKnownForUser$(email: string, deviceIdentifier: string): Observable<boolean>;
abstract updateTrustedDeviceKeys$(
deviceIdentifier: string,
devicePublicKeyEncryptedUserKey: string,
userKeyEncryptedDevicePublicKey: string,
deviceKeyEncryptedDevicePrivateKey: string,
) => Observable<DeviceView>;
): Observable<DeviceView>;
abstract deactivateDevice$(deviceId: string): Observable<void>;
abstract getCurrentDevice$(): Observable<DeviceResponse>;
}

View File

@@ -1,6 +1,11 @@
import { DeviceType } from "../../../../enums";
import { BaseResponse } from "../../../../models/response/base.response";
export interface DevicePendingAuthRequest {
id: string;
creationDate: string;
}
export class DeviceResponse extends BaseResponse {
id: string;
userId: string;
@@ -9,6 +14,9 @@ export class DeviceResponse extends BaseResponse {
type: DeviceType;
creationDate: string;
revisionDate: string;
isTrusted: boolean;
devicePendingAuthRequest: DevicePendingAuthRequest | null;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
@@ -18,5 +26,7 @@ export class DeviceResponse extends BaseResponse {
this.type = this.getResponseProperty("Type");
this.creationDate = this.getResponseProperty("CreationDate");
this.revisionDate = this.getResponseProperty("RevisionDate");
this.isTrusted = this.getResponseProperty("IsTrusted");
this.devicePendingAuthRequest = this.getResponseProperty("DevicePendingAuthRequest");
}
}

View File

@@ -1,19 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DeviceType } from "../../../../enums";
import { View } from "../../../../models/view/view";
import { DeviceResponse } from "../responses/device.response";
export class DeviceView implements View {
id: string;
userId: string;
name: string;
identifier: string;
type: DeviceType;
creationDate: string;
revisionDate: string;
id: string | undefined;
userId: string | undefined;
name: string | undefined;
identifier: string | undefined;
type: DeviceType | undefined;
creationDate: string | undefined;
revisionDate: string | undefined;
response: DeviceResponse | undefined;
constructor(deviceResponse: DeviceResponse) {
Object.assign(this, deviceResponse);
this.response = deviceResponse;
}
}

View File

@@ -1,5 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { UserId } from "@bitwarden/common/types/guid";
export abstract class SsoLoginServiceAbstraction {
/**
* Gets the code verifier used for SSO.
@@ -11,7 +11,7 @@ export abstract class SsoLoginServiceAbstraction {
* @see https://datatracker.ietf.org/doc/html/rfc7636
* @returns The code verifier used for SSO.
*/
getCodeVerifier: () => Promise<string>;
abstract getCodeVerifier: () => Promise<string | null>;
/**
* Sets the code verifier used for SSO.
*
@@ -21,7 +21,7 @@ export abstract class SsoLoginServiceAbstraction {
* and verify it matches the one sent in the request for the `authorization_code`.
* @see https://datatracker.ietf.org/doc/html/rfc7636
*/
setCodeVerifier: (codeVerifier: string) => Promise<void>;
abstract setCodeVerifier: (codeVerifier: string) => Promise<void>;
/**
* Gets the value of the SSO state.
*
@@ -31,7 +31,7 @@ export abstract class SsoLoginServiceAbstraction {
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1
* @returns The SSO state.
*/
getSsoState: () => Promise<string>;
abstract getSsoState: () => Promise<string | null>;
/**
* Sets the value of the SSO state.
*
@@ -40,7 +40,7 @@ export abstract class SsoLoginServiceAbstraction {
* returns the `state` in the callback and the client verifies that the value returned matches the value sent.
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1
*/
setSsoState: (ssoState: string) => Promise<void>;
abstract setSsoState: (ssoState: string) => Promise<void>;
/**
* Gets the value of the user's organization sso identifier.
*
@@ -48,20 +48,20 @@ export abstract class SsoLoginServiceAbstraction {
* Do not use this value outside of the SSO login flow.
* @returns The user's organization identifier.
*/
getOrganizationSsoIdentifier: () => Promise<string>;
abstract getOrganizationSsoIdentifier: () => Promise<string | null>;
/**
* Sets the value of the user's organization sso identifier.
*
* This should only be used during the SSO flow to identify the organization that the user is attempting to log in to.
* Do not use this value outside of the SSO login flow.
*/
setOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise<void>;
abstract setOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise<void>;
/**
* Gets the user's email.
* Note: This should only be used during the SSO flow to identify the user that is attempting to log in.
* @returns The user's email.
*/
getSsoEmail: () => Promise<string>;
abstract getSsoEmail: () => Promise<string | null>;
/**
* Sets the user's email.
* Note: This should only be used during the SSO flow to identify the user that is attempting to log in.
@@ -69,17 +69,21 @@ export abstract class SsoLoginServiceAbstraction {
* @returns A promise that resolves when the email has been set.
*
*/
setSsoEmail: (email: string) => Promise<void>;
abstract setSsoEmail: (email: string) => Promise<void>;
/**
* Gets the value of the active user's organization sso identifier.
*
* This should only be used post successful SSO login once the user is initialized.
* @param userId The user id for retrieving the org identifier state.
*/
getActiveUserOrganizationSsoIdentifier: () => Promise<string>;
abstract getActiveUserOrganizationSsoIdentifier: (userId: UserId) => Promise<string | null>;
/**
* Sets the value of the active user's organization sso identifier.
*
* This should only be used post successful SSO login once the user is initialized.
*/
setActiveUserOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise<void>;
abstract setActiveUserOrganizationSsoIdentifier: (
organizationIdentifier: string,
userId: UserId | undefined,
) => Promise<void>;
}

View File

@@ -2,9 +2,8 @@
// @ts-strict-ignore
import { Observable } from "rxjs";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { VaultTimeout, VaultTimeoutAction } from "../../key-management/vault-timeout";
import { UserId } from "../../types/guid";
import { VaultTimeout } from "../../types/vault-timeout.type";
import { SetTokensResult } from "../models/domain/set-tokens-result";
import { DecodedAccessToken } from "../services/token.service";

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
@@ -11,15 +9,52 @@ export interface TwoFactorProviderDetails {
sort: number;
premium: boolean;
}
export abstract class TwoFactorService {
init: () => void;
getSupportedProviders: (win: Window) => Promise<TwoFactorProviderDetails[]>;
getDefaultProvider: (webAuthnSupported: boolean) => Promise<TwoFactorProviderType>;
setSelectedProvider: (type: TwoFactorProviderType) => Promise<void>;
clearSelectedProvider: () => Promise<void>;
/**
* Initializes the client-side's TwoFactorProviders const with translations.
*/
abstract init(): void;
setProviders: (response: IdentityTwoFactorResponse) => Promise<void>;
clearProviders: () => Promise<void>;
getProviders: () => Promise<Map<TwoFactorProviderType, { [key: string]: string }>>;
/**
* Gets a list of two-factor providers from state that are supported on the current client.
* E.g., WebAuthn and Duo are not available on all clients.
* @returns A list of supported two-factor providers or an empty list if none are stored in state.
*/
abstract getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]>;
/**
* Gets the previously selected two-factor provider or the default two factor provider based on priority.
* @param webAuthnSupported - Whether or not WebAuthn is supported by the client. Prevents WebAuthn from being the default provider if false.
*/
abstract getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType>;
/**
* Sets the selected two-factor provider in state.
* @param type - The type of two-factor provider to set as the selected provider.
*/
abstract setSelectedProvider(type: TwoFactorProviderType): Promise<void>;
/**
* Clears the selected two-factor provider from state.
*/
abstract clearSelectedProvider(): Promise<void>;
/**
* Sets the list of available two-factor providers in state.
* @param response - the response from Identity for when 2FA is required. Includes the list of available 2FA providers.
*/
abstract setProviders(response: IdentityTwoFactorResponse): Promise<void>;
/**
* Clears the list of available two-factor providers from state.
*/
abstract clearProviders(): Promise<void>;
/**
* Gets the list of two-factor providers from state.
* Note: no filtering is done here, so this will return all providers, including potentially
* unsupported ones for the current client.
* @returns A list of two-factor providers or null if none are stored in state.
*/
abstract getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null>;
}

View File

@@ -7,4 +7,5 @@ export enum TwoFactorProviderType {
Remember = 5,
OrganizationDuo = 6,
WebAuthn = 7,
RecoveryCode = 8,
}

View File

@@ -22,6 +22,7 @@ export class AuthResult {
ssoEmail2FaSessionToken?: string;
email: string;
requiresEncryptionKeyMigration: boolean;
requiresDeviceVerification: boolean;
get requiresCaptcha() {
return !Utils.isNullOrWhitespace(this.captchaSiteKey);

View File

@@ -13,6 +13,7 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect
public captchaResponse: string,
protected twoFactor: TokenTwoFactorRequest,
device?: DeviceRequest,
public newDeviceOtp?: string,
) {
super(twoFactor, device);
}
@@ -28,6 +29,10 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect
obj.captchaResponse = this.captchaResponse;
}
if (this.newDeviceOtp) {
obj.newDeviceOtp = this.newDeviceOtp;
}
return obj;
}

View File

@@ -14,7 +14,6 @@ export abstract class TokenRequest {
this.device = device != null ? device : null;
}
// eslint-disable-next-line
alterIdentityTokenHeaders(headers: Headers) {
// Implemented in subclass if required
}

View File

@@ -0,0 +1,8 @@
import { SecretVerificationRequest } from "./secret-verification.request";
export class SetVerifyDevicesRequest extends SecretVerificationRequest {
/**
* This is the input for a user update that controls [dbo].[Users].[VerifyDevices]
*/
verifyDevices!: boolean;
}

View File

@@ -8,8 +8,8 @@ export class UpdateDevicesTrustRequest extends SecretVerificationRequest {
}
export class DeviceKeysUpdateRequest {
encryptedPublicKey: string;
encryptedUserKey: string;
encryptedPublicKey: string | undefined;
encryptedUserKey: string | undefined;
}
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {

View File

@@ -1,8 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { RotateableKeySet } from "../../../../../auth/src/common/models";
import { EncString } from "../../../platform/models/domain/enc-string";
export class WebauthnRotateCredentialRequest {
id: string;

View File

@@ -6,8 +6,11 @@ const RequestTimeOut = 60000 * 15; //15 Minutes
export class AuthRequestResponse extends BaseResponse {
id: string;
publicKey: string;
requestDeviceType: DeviceType;
requestDeviceType: string;
requestDeviceTypeValue: DeviceType;
requestDeviceIdentifier: string;
requestIpAddress: string;
requestCountryName: string;
key: string; // could be either an encrypted MasterKey or an encrypted UserKey
masterPasswordHash: string; // if hash is present, the `key` above is an encrypted MasterKey (else `key` is an encrypted UserKey)
creationDate: string;
@@ -21,7 +24,10 @@ export class AuthRequestResponse extends BaseResponse {
this.id = this.getResponseProperty("Id");
this.publicKey = this.getResponseProperty("PublicKey");
this.requestDeviceType = this.getResponseProperty("RequestDeviceType");
this.requestDeviceTypeValue = this.getResponseProperty("RequestDeviceTypeValue");
this.requestDeviceIdentifier = this.getResponseProperty("RequestDeviceIdentifier");
this.requestIpAddress = this.getResponseProperty("RequestIpAddress");
this.requestCountryName = this.getResponseProperty("RequestCountryName");
this.key = this.getResponseProperty("Key");
this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");
this.creationDate = this.getResponseProperty("CreationDate");

View File

@@ -0,0 +1,13 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class IdentityDeviceVerificationResponse extends BaseResponse {
deviceVerified: boolean;
captchaToken: string;
constructor(response: any) {
super(response);
this.deviceVerified = this.getResponseProperty("DeviceVerified") ?? false;
this.captchaToken = this.getResponseProperty("CaptchaBypassToken");
}
}

View File

@@ -0,0 +1,8 @@
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
export type IdentityResponse =
| IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse;

View File

@@ -10,6 +10,7 @@ import { UserVerificationService } from "../abstractions/user-verification/user-
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
import { SetVerifyDevicesRequest } from "../models/request/set-verify-devices.request";
import { Verification } from "../types/verification";
export class AccountApiServiceImplementation implements AccountApiService {
@@ -102,4 +103,21 @@ export class AccountApiServiceImplementation implements AccountApiService {
throw e;
}
}
async setVerifyDevices(request: SetVerifyDevicesRequest): Promise<string> {
try {
const response = await this.apiService.send(
"POST",
"/accounts/verify-devices",
request,
true,
true,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
}

View File

@@ -7,7 +7,10 @@ import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { FakeGlobalState } from "../../../spec/fake-state";
import { FakeGlobalStateProvider } from "../../../spec/fake-state-provider";
import {
FakeGlobalStateProvider,
FakeSingleUserStateProvider,
} from "../../../spec/fake-state-provider";
import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -19,6 +22,7 @@ import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY,
ACCOUNT_VERIFY_NEW_DEVICE_LOGIN,
AccountServiceImplementation,
} from "./account.service";
@@ -66,9 +70,11 @@ describe("accountService", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
let globalStateProvider: FakeGlobalStateProvider;
let singleUserStateProvider: FakeSingleUserStateProvider;
let sut: AccountServiceImplementation;
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
let activeAccountIdState: FakeGlobalState<UserId>;
let accountActivityState: FakeGlobalState<Record<UserId, Date>>;
const userId = Utils.newGuid() as UserId;
const userInfo = { email: "email", name: "name", emailVerified: true };
@@ -76,11 +82,18 @@ describe("accountService", () => {
messagingService = mock();
logService = mock();
globalStateProvider = new FakeGlobalStateProvider();
singleUserStateProvider = new FakeSingleUserStateProvider();
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
sut = new AccountServiceImplementation(
messagingService,
logService,
globalStateProvider,
singleUserStateProvider,
);
accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS);
activeAccountIdState = globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID);
accountActivityState = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
});
afterEach(() => {
@@ -126,6 +139,22 @@ describe("accountService", () => {
});
});
describe("accountsVerifyNewDeviceLogin$", () => {
it("returns expected value", async () => {
// Arrange
const expected = true;
// we need to set this state since it is how we initialize the VerifyNewDeviceLogin$
activeAccountIdState.stateSubject.next(userId);
singleUserStateProvider.getFake(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).nextState(expected);
// Act
const result = await firstValueFrom(sut.accountVerifyNewDeviceLogin$);
// Assert
expect(result).toEqual(expected);
});
});
describe("addAccount", () => {
it("should emit the new account", async () => {
await sut.addAccount(userId, userInfo);
@@ -224,6 +253,33 @@ describe("accountService", () => {
});
});
describe("setAccountVerifyNewDeviceLogin", () => {
const initialState = true;
beforeEach(() => {
activeAccountIdState.stateSubject.next(userId);
singleUserStateProvider
.getFake(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN)
.nextState(initialState);
});
it("should update the VerifyNewDeviceLogin", async () => {
const expected = false;
expect(await firstValueFrom(sut.accountVerifyNewDeviceLogin$)).toEqual(initialState);
await sut.setAccountVerifyNewDeviceLogin(userId, expected);
const currentState = await firstValueFrom(sut.accountVerifyNewDeviceLogin$);
expect(currentState).toEqual(expected);
});
it("should NOT update VerifyNewDeviceLogin when userId is null", async () => {
await sut.setAccountVerifyNewDeviceLogin(null, false);
const currentState = await firstValueFrom(sut.accountVerifyNewDeviceLogin$);
expect(currentState).toEqual(initialState);
});
});
describe("clean", () => {
beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo });
@@ -256,6 +312,7 @@ describe("accountService", () => {
beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo });
activeAccountIdState.stateSubject.next(userId);
accountActivityState.stateSubject.next({ [userId]: new Date(1) });
});
it("should emit null if no account is provided", async () => {
@@ -269,6 +326,34 @@ describe("accountService", () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
});
it("should change active account when switched to the new account", async () => {
const newUserId = Utils.newGuid() as UserId;
accountsState.stateSubject.next({ [newUserId]: userInfo });
await sut.switchAccount(newUserId);
await expect(firstValueFrom(sut.activeAccount$)).resolves.toEqual({
id: newUserId,
...userInfo,
});
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
[userId]: new Date(1),
[newUserId]: expect.toAlmostEqual(new Date(), 1000),
});
});
it("should not change active account when already switched to the same account", async () => {
await sut.switchAccount(userId);
await expect(firstValueFrom(sut.activeAccount$)).resolves.toEqual({
id: userId,
...userInfo,
});
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
[userId]: new Date(1),
});
});
});
describe("account activity", () => {

View File

@@ -7,6 +7,10 @@ import {
shareReplay,
combineLatest,
Observable,
switchMap,
filter,
timeout,
of,
} from "rxjs";
import {
@@ -23,6 +27,8 @@ import {
GlobalState,
GlobalStateProvider,
KeyDefinition,
SingleUserStateProvider,
UserKeyDefinition,
} from "../../platform/state";
import { UserId } from "../../types/guid";
@@ -42,6 +48,15 @@ export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK,
deserializer: (activity) => new Date(activity),
});
export const ACCOUNT_VERIFY_NEW_DEVICE_LOGIN = new UserKeyDefinition<boolean>(
ACCOUNT_DISK,
"verifyNewDeviceLogin",
{
deserializer: (verifyDevices) => verifyDevices,
clearOn: ["logout"],
},
);
const LOGGED_OUT_INFO: AccountInfo = {
email: "",
emailVerified: false,
@@ -73,6 +88,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<Account | null>;
accountActivity$: Observable<Record<UserId, Date>>;
accountVerifyNewDeviceLogin$: Observable<boolean>;
sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<Account>;
@@ -80,6 +96,7 @@ export class AccountServiceImplementation implements InternalAccountService {
private messagingService: MessagingService,
private logService: LogService,
private globalStateProvider: GlobalStateProvider,
private singleUserStateProvider: SingleUserStateProvider,
) {
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
@@ -114,6 +131,12 @@ export class AccountServiceImplementation implements InternalAccountService {
return nextId ? { id: nextId, ...accounts[nextId] } : null;
}),
);
this.accountVerifyNewDeviceLogin$ = this.activeAccountIdState.state$.pipe(
switchMap(
(userId) =>
this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).state$,
),
);
}
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
@@ -149,21 +172,28 @@ export class AccountServiceImplementation implements InternalAccountService {
async switchAccount(userId: UserId | null): Promise<void> {
let updateActivity = false;
await this.activeAccountIdState.update(
(_, accounts) => {
if (userId == null) {
// indicates no account is active
return null;
}
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
(_, __) => {
updateActivity = true;
return userId;
},
{
combineLatestWith: this.accounts$,
shouldUpdate: (id) => {
combineLatestWith: this.accountsState.state$.pipe(
filter((accounts) => {
if (userId == null) {
// Don't worry about accounts when we are about to set active user to null
return true;
}
return accounts?.[userId] != null;
}),
// If we don't get the desired account with enough time, just return empty as that will result in the same error
timeout({ first: 1000, with: () => of({} as Record<UserId, AccountInfo>) }),
),
shouldUpdate: (id, accounts) => {
if (userId != null && accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
// update only if userId changes
return id !== userId;
},
@@ -193,6 +223,20 @@ export class AccountServiceImplementation implements InternalAccountService {
);
}
async setAccountVerifyNewDeviceLogin(
userId: UserId,
setVerifyNewDeviceLogin: boolean,
): Promise<void> {
if (!Utils.isGuid(userId)) {
// only store for valid userIds
return;
}
await this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).update(() => {
return setVerifyNewDeviceLogin;
});
}
async removeAccountActivity(userId: UserId): Promise<void> {
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => {

View File

@@ -9,6 +9,8 @@ import {
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
import { firstValueFrom } from "rxjs";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { AuthRequestServiceAbstraction } from "../../../../auth/src/common/abstractions";
import { NotificationType } from "../../enums";
import {

View File

@@ -1,7 +1,8 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import {
FakeAccountService,
makeStaticByteArray,

View File

@@ -11,7 +11,8 @@ import {
switchMap,
} from "rxjs";
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { ApiService } from "../../abstractions/api.service";
import { StateService } from "../../platform/abstractions/state.service";
import { MessageSender } from "../../platform/messaging";

View File

@@ -1,14 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable } from "rxjs";
import { firstValueFrom, map, Observable, Subject } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { LogService } from "../../platform/abstractions/log.service";
@@ -63,6 +63,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
supportsDeviceTrust$: Observable<boolean>;
// Observable emission is used to trigger a toast in consuming components
private deviceTrustedSubject = new Subject<void>();
deviceTrusted$ = this.deviceTrustedSubject.asObservable();
constructor(
private keyGenerationService: KeyGenerationService,
private cryptoFunctionService: CryptoFunctionService,
@@ -79,7 +83,17 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
private configService: ConfigService,
) {
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
map((options) => options?.trustedDeviceOption != null ?? false),
map((options) => {
return options?.trustedDeviceOption != null ?? false;
}),
);
}
supportsDeviceTrustByUserId$(userId: UserId): Observable<boolean> {
return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe(
map((options) => {
return options?.trustedDeviceOption != null ?? false;
}),
);
}
@@ -167,7 +181,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
// store device key in local/secure storage if enc keys posted to server successfully
await this.setDeviceKey(userId, deviceKey);
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
// This emission will be picked up by consuming components to handle displaying a toast to the user
this.deviceTrustedSubject.next();
return deviceResponse;
}
@@ -335,6 +350,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
);
return new SymmetricCryptoKey(userKey) as UserKey;
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// If either decryption effort fails, we want to remove the device key
this.logService.error("Failed to decrypt using device key. Removing device key.");

View File

@@ -1,18 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { matches, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options";
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeActiveUserState } from "../../../spec/fake-state";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { DeviceType } from "../../enums";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { LogService } from "../../platform/abstractions/log.service";
@@ -70,17 +74,56 @@ describe("deviceTrustService", () => {
userId: mockUserId,
};
let userDecryptionOptions: UserDecryptionOptions;
beforeEach(() => {
jest.clearAllMocks();
const supportsSecureStorage = false; // default to false; tests will override as needed
// By default all the tests will have a mocked active user in state provider.
deviceTrustService = createDeviceTrustService(mockUserId, supportsSecureStorage);
userDecryptionOptions = new UserDecryptionOptions();
});
it("instantiates", () => {
expect(deviceTrustService).not.toBeFalsy();
});
describe("supportsDeviceTrustByUserId$", () => {
it("returns true when the user has a non-null trusted device decryption option", async () => {
// Arrange
userDecryptionOptions.trustedDeviceOption = {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: false,
isTdeOffboarding: false,
};
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
new BehaviorSubject<UserDecryptionOptions>(userDecryptionOptions),
);
const result = await firstValueFrom(
deviceTrustService.supportsDeviceTrustByUserId$(mockUserId),
);
expect(result).toBe(true);
});
it("returns false when the user has a null trusted device decryption option", async () => {
// Arrange
userDecryptionOptions.trustedDeviceOption = null;
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
new BehaviorSubject<UserDecryptionOptions>(userDecryptionOptions),
);
const result = await firstValueFrom(
deviceTrustService.supportsDeviceTrustByUserId$(mockUserId),
);
expect(result).toBe(false);
});
});
describe("User Trust Device Choice For Decryption", () => {
describe("getShouldTrustDevice", () => {
it("gets the user trust device choice for decryption", async () => {

View File

@@ -0,0 +1,100 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "../../abstractions/api.service";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
import { DevicesApiServiceImplementation } from "./devices-api.service.implementation";
describe("DevicesApiServiceImplementation", () => {
let devicesApiService: DevicesApiServiceImplementation;
let apiService: MockProxy<ApiService>;
beforeEach(() => {
apiService = mock<ApiService>();
devicesApiService = new DevicesApiServiceImplementation(apiService);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("getKnownDevice", () => {
it("calls api with correct parameters", async () => {
const email = "test@example.com";
const deviceIdentifier = "device123";
apiService.send.mockResolvedValue(true);
const result = await devicesApiService.getKnownDevice(email, deviceIdentifier);
expect(result).toBe(true);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/devices/knowndevice",
null,
false,
true,
null,
expect.any(Function),
);
});
});
describe("getDeviceByIdentifier", () => {
it("returns device response", async () => {
const deviceIdentifier = "device123";
const mockResponse = { id: "123", name: "Test Device" };
apiService.send.mockResolvedValue(mockResponse);
const result = await devicesApiService.getDeviceByIdentifier(deviceIdentifier);
expect(result).toBeInstanceOf(DeviceResponse);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
`/devices/identifier/${deviceIdentifier}`,
null,
true,
true,
);
});
});
describe("updateTrustedDeviceKeys", () => {
it("updates device keys and returns device response", async () => {
const deviceIdentifier = "device123";
const publicKeyEncrypted = "encryptedPublicKey";
const userKeyEncrypted = "encryptedUserKey";
const deviceKeyEncrypted = "encryptedDeviceKey";
const mockResponse = { id: "123", name: "Test Device" };
apiService.send.mockResolvedValue(mockResponse);
const result = await devicesApiService.updateTrustedDeviceKeys(
deviceIdentifier,
publicKeyEncrypted,
userKeyEncrypted,
deviceKeyEncrypted,
);
expect(result).toBeInstanceOf(DeviceResponse);
expect(apiService.send).toHaveBeenCalledWith(
"PUT",
`/devices/${deviceIdentifier}/keys`,
{
encryptedPrivateKey: deviceKeyEncrypted,
encryptedPublicKey: userKeyEncrypted,
encryptedUserKey: publicKeyEncrypted,
},
true,
true,
);
});
});
describe("error handling", () => {
it("propagates api errors", async () => {
const error = new Error("API Error");
apiService.send.mockRejectedValue(error);
await expect(devicesApiService.getDevices()).rejects.toThrow("API Error");
});
});
});

View File

@@ -117,4 +117,8 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac
},
);
}
async deactivateDevice(deviceId: string): Promise<void> {
await this.apiService.send("POST", `/devices/${deviceId}/deactivate`, null, true, false);
}
}

View File

@@ -1,6 +1,7 @@
import { Observable, defer, map } from "rxjs";
import { ListResponse } from "../../../models/response/list.response";
import { AppIdService } from "../../../platform/abstractions/app-id.service";
import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction";
import { DeviceResponse } from "../../abstractions/devices/responses/device.response";
import { DeviceView } from "../../abstractions/devices/views/device.view";
@@ -15,7 +16,10 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser
* (i.e., promsise --> observables are cold until subscribed to)
*/
export class DevicesServiceImplementation implements DevicesServiceAbstraction {
constructor(private devicesApiService: DevicesApiServiceAbstraction) {}
constructor(
private devicesApiService: DevicesApiServiceAbstraction,
private appIdService: AppIdService,
) {}
/**
* @description Gets the list of all devices.
@@ -65,4 +69,21 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction {
),
).pipe(map((deviceResponse: DeviceResponse) => new DeviceView(deviceResponse)));
}
/**
* @description Deactivates a device
*/
deactivateDevice$(deviceId: string): Observable<void> {
return defer(() => this.devicesApiService.deactivateDevice(deviceId));
}
/**
* @description Gets the current device.
*/
getCurrentDevice$(): Observable<DeviceResponse> {
return defer(async () => {
const deviceIdentifier = await this.appIdService.getAppId();
return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier);
});
}
}

View File

@@ -1,9 +1,11 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { ApiService } from "../../abstractions/api.service";
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { Organization } from "../../admin-console/models/domain/organization";
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
@@ -93,7 +95,7 @@ describe("KeyConnectorService", () => {
organizationData(true, false, "https://key-connector-url.com", 2, false),
organizationData(true, true, "https://other-url.com", 2, false),
];
organizationService.getAll.mockResolvedValue(orgs);
organizationService.organizations$.mockReturnValue(of(orgs));
// Act
const result = await keyConnectorService.getManagingOrganization();
@@ -108,7 +110,7 @@ describe("KeyConnectorService", () => {
organizationData(true, false, "https://key-connector-url.com", 2, false),
organizationData(false, false, "https://key-connector-url.com", 2, false),
];
organizationService.getAll.mockResolvedValue(orgs);
organizationService.organizations$.mockReturnValue(of(orgs));
// Act
const result = await keyConnectorService.getManagingOrganization();
@@ -123,7 +125,7 @@ describe("KeyConnectorService", () => {
organizationData(true, true, "https://key-connector-url.com", 0, false),
organizationData(true, true, "https://key-connector-url.com", 1, false),
];
organizationService.getAll.mockResolvedValue(orgs);
organizationService.organizations$.mockReturnValue(of(orgs));
// Act
const result = await keyConnectorService.getManagingOrganization();
@@ -138,7 +140,7 @@ describe("KeyConnectorService", () => {
organizationData(true, true, "https://key-connector-url.com", 2, true),
organizationData(false, true, "https://key-connector-url.com", 2, true),
];
organizationService.getAll.mockResolvedValue(orgs);
organizationService.organizations$.mockReturnValue(of(orgs));
// Act
const result = await keyConnectorService.getManagingOrganization();
@@ -179,7 +181,7 @@ describe("KeyConnectorService", () => {
// create organization object
const data = organizationData(true, true, "https://key-connector-url.com", 2, false);
organizationService.getAll.mockResolvedValue([data]);
organizationService.organizations$.mockReturnValue(of([data]));
// uses KeyConnector
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
@@ -193,7 +195,7 @@ describe("KeyConnectorService", () => {
it("should return false if the user does not need migration", async () => {
tokenService.getIsExternal.mockResolvedValue(false);
const data = organizationData(false, false, "https://key-connector-url.com", 2, false);
organizationService.getAll.mockResolvedValue([data]);
organizationService.organizations$.mockReturnValue(of([data]));
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
state.nextState(true);
@@ -273,7 +275,7 @@ describe("KeyConnectorService", () => {
const masterKey = getMockMasterKey();
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
const error = new Error("Failed to post user key to key connector");
organizationService.getAll.mockResolvedValue([organization]);
organizationService.organizations$.mockReturnValue(of([organization]));
masterPasswordService.masterKeySubject.next(masterKey);
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
@@ -364,6 +366,7 @@ describe("KeyConnectorService", () => {
accessSecretsManager: false,
limitCollectionCreation: true,
limitCollectionDeletion: true,
limitItemDeletion: true,
allowAdminAccessToAllCollectionItems: true,
flexibleCollections: false,
object: "profileOrganization",

View File

@@ -3,6 +3,8 @@
import { firstValueFrom } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
Argon2KdfConfig,
KdfConfig,
@@ -12,7 +14,6 @@ import {
} from "@bitwarden/key-management";
import { ApiService } from "../../abstractions/api.service";
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserType } from "../../admin-console/enums";
import { Organization } from "../../admin-console/models/domain/organization";
import { KeysRequest } from "../../models/request/keys.request";
@@ -28,7 +29,6 @@ import {
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { MasterKey } from "../../types/key";
import { AccountService } from "../abstractions/account.service";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
import { TokenService } from "../abstractions/token.service";
@@ -122,7 +122,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
}
async getManagingOrganization(userId?: UserId): Promise<Organization> {
const orgs = await this.organizationService.getAll(userId);
const orgs = await firstValueFrom(this.organizationService.organizations$(userId));
return orgs.find(
(o) =>
o.keyConnectorEnabled &&

View File

@@ -13,9 +13,9 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
mock = mock<InternalMasterPasswordServiceAbstraction>();
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
masterKeySubject = new ReplaySubject<MasterKey>(1);
masterKeySubject = new ReplaySubject<MasterKey | null>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
masterKeyHashSubject = new ReplaySubject<string>(1);
masterKeyHashSubject = new ReplaySubject<string | null>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
forceSetPasswordReasonSubject = new ReplaySubject<ForceSetPasswordReason>(1);

View File

@@ -2,10 +2,9 @@
// @ts-strict-ignore
import { firstValueFrom, map, Observable } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { EncryptionType } from "../../../platform/enums";
import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string";
@@ -180,10 +179,18 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
let decUserKey: Uint8Array;
if (userKey.encryptionType === EncryptionType.AesCbc256_B64) {
decUserKey = await this.encryptService.decryptToBytes(userKey, masterKey);
decUserKey = await this.encryptService.decryptToBytes(
userKey,
masterKey,
"Content: User Key; Encrypting Key: Master Key",
);
} else if (userKey.encryptionType === EncryptionType.AesCbc256_HmacSha256_B64) {
const newKey = await this.keyGenerationService.stretchKey(masterKey);
decUserKey = await this.encryptService.decryptToBytes(userKey, newKey);
decUserKey = await this.encryptService.decryptToBytes(
userKey,
newKey,
"Content: User Key; Encrypting Key: Stretched Master Key",
);
} else {
throw new Error("Unsupported encryption type.");
}

View File

@@ -2,13 +2,13 @@ import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "../../../../common/src/types/guid";
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { UserId } from "../../types/guid";
import { Account, AccountInfo, AccountService } from "../abstractions/account.service";
import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation";

View File

@@ -6,10 +6,10 @@ import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KeyService } from "@bitwarden/key-management";
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { Utils } from "../../platform/misc/utils";
import { UserKey } from "../../types/key";

View File

@@ -0,0 +1,94 @@
import { mock, MockProxy } from "jest-mock-extended";
import {
CODE_VERIFIER,
GLOBAL_ORGANIZATION_SSO_IDENTIFIER,
SSO_EMAIL,
SSO_STATE,
SsoLoginService,
USER_ORGANIZATION_SSO_IDENTIFIER,
} from "@bitwarden/common/auth/services/sso-login.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
describe("SSOLoginService ", () => {
let sut: SsoLoginService;
let accountService: FakeAccountService;
let mockSingleUserStateProvider: FakeStateProvider;
let mockLogService: MockProxy<LogService>;
let userId: UserId;
beforeEach(() => {
jest.clearAllMocks();
userId = Utils.newGuid() as UserId;
accountService = mockAccountServiceWith(userId);
mockSingleUserStateProvider = new FakeStateProvider(accountService);
mockLogService = mock<LogService>();
sut = new SsoLoginService(mockSingleUserStateProvider, mockLogService);
});
it("instantiates", () => {
expect(sut).not.toBeFalsy();
});
it("gets and sets code verifier", async () => {
const codeVerifier = "test-code-verifier";
await sut.setCodeVerifier(codeVerifier);
mockSingleUserStateProvider.getGlobal(CODE_VERIFIER);
const result = await sut.getCodeVerifier();
expect(result).toBe(codeVerifier);
});
it("gets and sets SSO state", async () => {
const ssoState = "test-sso-state";
await sut.setSsoState(ssoState);
mockSingleUserStateProvider.getGlobal(SSO_STATE);
const result = await sut.getSsoState();
expect(result).toBe(ssoState);
});
it("gets and sets organization SSO identifier", async () => {
const orgIdentifier = "test-org-identifier";
await sut.setOrganizationSsoIdentifier(orgIdentifier);
mockSingleUserStateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
const result = await sut.getOrganizationSsoIdentifier();
expect(result).toBe(orgIdentifier);
});
it("gets and sets SSO email", async () => {
const email = "test@example.com";
await sut.setSsoEmail(email);
mockSingleUserStateProvider.getGlobal(SSO_EMAIL);
const result = await sut.getSsoEmail();
expect(result).toBe(email);
});
it("gets and sets active user organization SSO identifier", async () => {
const userId = Utils.newGuid() as UserId;
const orgIdentifier = "test-active-org-identifier";
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, userId);
mockSingleUserStateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
const result = await sut.getActiveUserOrganizationSsoIdentifier(userId);
expect(result).toBe(orgIdentifier);
});
it("logs error when setting active user organization SSO identifier with undefined userId", async () => {
const orgIdentifier = "test-active-org-identifier";
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, undefined);
expect(mockLogService.error).toHaveBeenCalledWith(
"Tried to set a user organization sso identifier with an undefined user id.",
);
});
});

View File

@@ -1,11 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
ActiveUserState,
GlobalState,
KeyDefinition,
SingleUserState,
SSO_DISK,
StateProvider,
UserKeyDefinition,
@@ -15,21 +16,21 @@ import { SsoLoginServiceAbstraction } from "../abstractions/sso-login.service.ab
/**
* Uses disk storage so that the code verifier can be persisted across sso redirects.
*/
const CODE_VERIFIER = new KeyDefinition<string>(SSO_DISK, "ssoCodeVerifier", {
export const CODE_VERIFIER = new KeyDefinition<string>(SSO_DISK, "ssoCodeVerifier", {
deserializer: (codeVerifier) => codeVerifier,
});
/**
* Uses disk storage so that the sso state can be persisted across sso redirects.
*/
const SSO_STATE = new KeyDefinition<string>(SSO_DISK, "ssoState", {
export const SSO_STATE = new KeyDefinition<string>(SSO_DISK, "ssoState", {
deserializer: (state) => state,
});
/**
* Uses disk storage so that the organization sso identifier can be persisted across sso redirects.
*/
const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition<string>(
export const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition<string>(
SSO_DISK,
"organizationSsoIdentifier",
{
@@ -41,7 +42,7 @@ const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition<string>(
/**
* Uses disk storage so that the organization sso identifier can be persisted across sso redirects.
*/
const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
export const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
SSO_DISK,
"organizationSsoIdentifier",
{
@@ -52,7 +53,7 @@ const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
/**
* Uses disk storage so that the user's email can be persisted across sso redirects.
*/
const SSO_EMAIL = new KeyDefinition<string>(SSO_DISK, "ssoEmail", {
export const SSO_EMAIL = new KeyDefinition<string>(SSO_DISK, "ssoEmail", {
deserializer: (state) => state,
});
@@ -61,19 +62,18 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
private ssoState: GlobalState<string>;
private orgSsoIdentifierState: GlobalState<string>;
private ssoEmailState: GlobalState<string>;
private activeUserOrgSsoIdentifierState: ActiveUserState<string>;
constructor(private stateProvider: StateProvider) {
constructor(
private stateProvider: StateProvider,
private logService: LogService,
) {
this.codeVerifierState = this.stateProvider.getGlobal(CODE_VERIFIER);
this.ssoState = this.stateProvider.getGlobal(SSO_STATE);
this.orgSsoIdentifierState = this.stateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL);
this.activeUserOrgSsoIdentifierState = this.stateProvider.getActive(
USER_ORGANIZATION_SSO_IDENTIFIER,
);
}
getCodeVerifier(): Promise<string> {
getCodeVerifier(): Promise<string | null> {
return firstValueFrom(this.codeVerifierState.state$);
}
@@ -81,7 +81,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
await this.codeVerifierState.update((_) => codeVerifier);
}
getSsoState(): Promise<string> {
getSsoState(): Promise<string | null> {
return firstValueFrom(this.ssoState.state$);
}
@@ -89,7 +89,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
await this.ssoState.update((_) => ssoState);
}
getOrganizationSsoIdentifier(): Promise<string> {
getOrganizationSsoIdentifier(): Promise<string | null> {
return firstValueFrom(this.orgSsoIdentifierState.state$);
}
@@ -97,7 +97,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
await this.orgSsoIdentifierState.update((_) => organizationIdentifier);
}
getSsoEmail(): Promise<string> {
getSsoEmail(): Promise<string | null> {
return firstValueFrom(this.ssoEmailState.state$);
}
@@ -105,11 +105,24 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
await this.ssoEmailState.update((_) => email);
}
getActiveUserOrganizationSsoIdentifier(): Promise<string> {
return firstValueFrom(this.activeUserOrgSsoIdentifierState.state$);
getActiveUserOrganizationSsoIdentifier(userId: UserId): Promise<string | null> {
return firstValueFrom(this.userOrgSsoIdentifierState(userId).state$);
}
async setActiveUserOrganizationSsoIdentifier(organizationIdentifier: string): Promise<void> {
await this.activeUserOrgSsoIdentifierState.update((_) => organizationIdentifier);
async setActiveUserOrganizationSsoIdentifier(
organizationIdentifier: string,
userId: UserId | undefined,
): Promise<void> {
if (userId === undefined) {
this.logService.error(
"Tried to set a user organization sso identifier with an undefined user id.",
);
return;
}
await this.userOrgSsoIdentifierState(userId).update((_) => organizationIdentifier);
}
private userOrgSsoIdentifierState(userId: UserId): SingleUserState<string> {
return this.stateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
}
}

View File

@@ -1,11 +1,17 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutStringType,
} from "../../key-management/vault-timeout";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { LogService } from "../../platform/abstractions/log.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
@@ -14,7 +20,6 @@ import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../types/csprng";
import { UserId } from "../../types/guid";
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
import { SetTokensResult } from "../models/domain/set-tokens-result";
import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service";

View File

@@ -5,8 +5,12 @@ import { Opaque } from "type-fest";
import { LogoutReason, decodeJwtTokenToJson } from "@bitwarden/auth/common";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutStringType,
} from "../../key-management/vault-timeout";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { LogService } from "../../platform/abstractions/log.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
@@ -22,7 +26,6 @@ import {
UserKeyDefinition,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service";
import { SetTokensResult } from "../models/domain/set-tokens-result";

View File

@@ -206,7 +206,7 @@ export class TwoFactorService implements TwoFactorServiceAbstraction {
await this.providersState.update(() => null);
}
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }>> {
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null> {
return firstValueFrom(this.providers$);
}
}

View File

@@ -7,14 +7,17 @@ import {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { KdfConfig, KeyService } from "@bitwarden/key-management";
import {
BiometricsService,
BiometricsStatus,
KdfConfig,
KeyService,
KdfConfigService,
} from "@bitwarden/key-management";
import { KdfConfigService } from "../../../../../key-management/src/abstractions/kdf-config.service";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { VaultTimeoutSettingsService } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutSettingsService } from "../../../key-management/vault-timeout";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { HashPurpose } from "../../../platform/enums";
import { Utils } from "../../../platform/misc/utils";
import { UserId } from "../../../types/guid";
@@ -36,10 +39,9 @@ describe("UserVerificationService", () => {
const userVerificationApiService = mock<UserVerificationApiServiceAbstraction>();
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const pinService = mock<PinServiceAbstraction>();
const logService = mock<LogService>();
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
const platformUtilsService = mock<PlatformUtilsService>();
const kdfConfigService = mock<KdfConfigService>();
const biometricsService = mock<BiometricsService>();
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
@@ -56,10 +58,8 @@ describe("UserVerificationService", () => {
userVerificationApiService,
userDecryptionOptionsService,
pinService,
logService,
vaultTimeoutSettingsService,
platformUtilsService,
kdfConfigService,
biometricsService,
);
});
@@ -113,26 +113,15 @@ describe("UserVerificationService", () => {
);
test.each([
[true, true, true, true],
[true, true, true, false],
[true, true, false, false],
[false, true, false, true],
[false, false, false, false],
[false, false, true, false],
[false, false, false, true],
[true, BiometricsStatus.Available],
[false, BiometricsStatus.DesktopDisconnected],
[false, BiometricsStatus.HardwareUnavailable],
])(
"returns %s for biometrics availability when isBiometricLockSet is %s, hasUserKeyStored is %s, and supportsSecureStorage is %s",
async (
expectedReturn: boolean,
isBiometricsLockSet: boolean,
isBiometricsUserKeyStored: boolean,
platformSupportSecureStorage: boolean,
) => {
async (expectedReturn: boolean, biometricsStatus: BiometricsStatus) => {
setMasterPasswordAvailability(false);
setPinAvailability("DISABLED");
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(isBiometricsLockSet);
keyService.hasUserKeyStored.mockResolvedValue(isBiometricsUserKeyStored);
platformUtilsService.supportsSecureStorage.mockReturnValue(platformSupportSecureStorage);
biometricsService.getBiometricsStatus.mockResolvedValue(biometricsStatus);
const result = await sut.getAvailableVerificationOptions("client");

View File

@@ -3,17 +3,19 @@
import { firstValueFrom, map } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import {
BiometricsService,
BiometricsStatus,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "../../../../../auth/src/common/abstractions/pin.service.abstraction";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { HashPurpose } from "../../../platform/enums";
import { KeySuffixOptions } from "../../../platform/enums/key-suffix-options.enum";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { AccountService } from "../../abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction";
import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction";
@@ -47,10 +49,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
private userVerificationApiService: UserVerificationApiServiceAbstraction,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private pinService: PinServiceAbstraction,
private logService: LogService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private kdfConfigService: KdfConfigService,
private biometricsService: BiometricsService,
) {}
async getAvailableVerificationOptions(
@@ -58,17 +58,13 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
): Promise<UserVerificationOptions> {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (verificationType === "client") {
const [
userHasMasterPassword,
isPinDecryptionAvailable,
biometricsLockSet,
biometricsUserKeyStored,
] = await Promise.all([
this.hasMasterPasswordAndMasterKeyHash(userId),
this.pinService.isPinDecryptionAvailable(userId),
this.vaultTimeoutSettingsService.isBiometricLockSet(userId),
this.keyService.hasUserKeyStored(KeySuffixOptions.Biometric, userId),
]);
const [userHasMasterPassword, isPinDecryptionAvailable, biometricsStatus] = await Promise.all(
[
this.hasMasterPasswordAndMasterKeyHash(userId),
this.pinService.isPinDecryptionAvailable(userId),
this.biometricsService.getBiometricsStatus(),
],
);
// note: we do not need to check this.platformUtilsService.supportsBiometric() because
// we can just use the logic below which works for both desktop & the browser extension.
@@ -77,9 +73,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
client: {
masterPassword: userHasMasterPassword,
pin: isPinDecryptionAvailable,
biometrics:
biometricsLockSet &&
(biometricsUserKeyStored || !this.platformUtilsService.supportsSecureStorage()),
biometrics: biometricsStatus === BiometricsStatus.Available,
},
server: {
masterPassword: false,
@@ -169,6 +163,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
const request = new VerifyOTPRequest(verification.secret);
try {
await this.userVerificationApiService.postAccountVerifyOTP(request);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
throw new Error(this.i18nService.t("invalidVerificationCode"));
}
@@ -227,6 +223,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
request.masterPasswordHash = serverKeyHash;
try {
policyOptions = await this.userVerificationApiService.postAccountVerifyPassword(request);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
throw new Error(this.i18nService.t("invalidMasterPassword"));
}
@@ -253,17 +251,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
}
private async verifyUserByBiometrics(): Promise<boolean> {
let userKey: UserKey;
// Biometrics crashes and doesn't return a value if the user cancels the prompt
try {
userKey = await this.keyService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
} catch (e) {
this.logService.error(`Biometrics User Verification failed: ${e.message}`);
// So, any failures should be treated as a failed verification
return false;
}
return userKey != null;
return this.biometricsService.authenticateWithBiometrics();
}
async requestOTP() {

View File

@@ -22,5 +22,5 @@ export type ServerSideVerification = OtpVerification | MasterPasswordVerificatio
export type MasterPasswordVerificationResponse = {
masterKey: MasterKey;
policyOptions: MasterPasswordPolicyResponse;
policyOptions: MasterPasswordPolicyResponse | null;
};

View File

@@ -25,7 +25,10 @@ export class WebAuthnIFrame {
const params = new URLSearchParams({
data: this.base64Encode(JSON.stringify(data)),
parent: encodeURIComponent(this.win.document.location.href),
btnText: encodeURIComponent(this.i18nService.t("webAuthnAuthenticate")),
btnText: encodeURIComponent(this.i18nService.t("readSecurityKey")),
btnAwaitingInteractionText: encodeURIComponent(
this.i18nService.t("awaitingSecurityKeyInteraction"),
),
v: "1",
});

View File

@@ -1,6 +1,8 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, FakeAccountService, mockAccountServiceWith } from "../../../spec";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
@@ -8,6 +10,7 @@ import { DefaultDomainSettingsService, DomainSettingsService } from "./domain-se
describe("DefaultDomainSettingsService", () => {
let domainSettingsService: DomainSettingsService;
let configService: MockProxy<ConfigService>;
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService);
@@ -19,10 +22,13 @@ describe("DefaultDomainSettingsService", () => {
];
beforeEach(() => {
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
configService = mock<ConfigService>();
configService.getFeatureFlag$.mockImplementation(() => of(false));
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService);
jest.spyOn(domainSettingsService, "getUrlEquivalentDomains");
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
domainSettingsService.blockedInteractionsUris$ = of({});
});
describe("getUrlEquivalentDomains", () => {

View File

@@ -1,13 +1,15 @@
// 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, of } from "rxjs";
import { FeatureFlag } from "../../enums/feature-flag.enum";
import {
NeverDomains,
EquivalentDomains,
UriMatchStrategySetting,
UriMatchStrategy,
} from "../../models/domain/domain-service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { Utils } from "../../platform/misc/utils";
import {
DOMAIN_SETTINGS_DISK,
@@ -23,10 +25,20 @@ const SHOW_FAVICONS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "showFavicons", {
deserializer: (value: boolean) => value ?? true,
});
// Domain exclusion list for notifications
const NEVER_DOMAINS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "neverDomains", {
deserializer: (value: NeverDomains) => value ?? null,
});
// Domain exclusion list for content script injections
const BLOCKED_INTERACTIONS_URIS = new KeyDefinition(
DOMAIN_SETTINGS_DISK,
"blockedInteractionsUris",
{
deserializer: (value: NeverDomains) => value ?? {},
},
);
const EQUIVALENT_DOMAINS = new UserKeyDefinition(DOMAIN_SETTINGS_DISK, "equivalentDomains", {
deserializer: (value: EquivalentDomains) => value ?? null,
clearOn: ["logout"],
@@ -41,15 +53,45 @@ const DEFAULT_URI_MATCH_STRATEGY = new UserKeyDefinition(
},
);
/**
* The Domain Settings service; provides client settings state for "active client view" URI concerns
*/
export abstract class DomainSettingsService {
/**
* Indicates if the favicons for ciphers' URIs should be shown instead of a placeholder
*/
showFavicons$: Observable<boolean>;
setShowFavicons: (newValue: boolean) => Promise<void>;
/**
* User-specified URIs for which the client notifications should not appear
*/
neverDomains$: Observable<NeverDomains>;
setNeverDomains: (newValue: NeverDomains) => Promise<void>;
/**
* User-specified URIs for which client content script injections should not occur, and the state
* of banner/notice visibility for those domains within the client
*/
blockedInteractionsUris$: Observable<NeverDomains>;
setBlockedInteractionsUris: (newValue: NeverDomains) => Promise<void>;
/**
* URIs which should be treated as equivalent to each other for various concerns (autofill, etc)
*/
equivalentDomains$: Observable<EquivalentDomains>;
setEquivalentDomains: (newValue: EquivalentDomains, userId: UserId) => Promise<void>;
/**
* User-specified default for URI-matching strategies (for example, when determining relevant
* ciphers for an active browser tab). Can be overridden by cipher-specific settings.
*/
defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise<void>;
/**
* Helper function for the common resolution of a given URL against equivalent domains
*/
getUrlEquivalentDomains: (url: string) => Observable<Set<string>>;
}
@@ -60,19 +102,37 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
private neverDomainsState: GlobalState<NeverDomains>;
readonly neverDomains$: Observable<NeverDomains>;
private blockedInteractionsUrisState: GlobalState<NeverDomains>;
readonly blockedInteractionsUris$: Observable<NeverDomains>;
private equivalentDomainsState: ActiveUserState<EquivalentDomains>;
readonly equivalentDomains$: Observable<EquivalentDomains>;
private defaultUriMatchStrategyState: ActiveUserState<UriMatchStrategySetting>;
readonly defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
constructor(private stateProvider: StateProvider) {
constructor(
private stateProvider: StateProvider,
private configService: ConfigService,
) {
this.showFaviconsState = this.stateProvider.getGlobal(SHOW_FAVICONS);
this.showFavicons$ = this.showFaviconsState.state$.pipe(map((x) => x ?? true));
this.neverDomainsState = this.stateProvider.getGlobal(NEVER_DOMAINS);
this.neverDomains$ = this.neverDomainsState.state$.pipe(map((x) => x ?? null));
// Needs to be global to prevent pre-login injections
this.blockedInteractionsUrisState = this.stateProvider.getGlobal(BLOCKED_INTERACTIONS_URIS);
this.blockedInteractionsUris$ = this.configService
.getFeatureFlag$(FeatureFlag.BlockBrowserInjectionsByDomain)
.pipe(
switchMap((featureIsEnabled) =>
featureIsEnabled ? this.blockedInteractionsUrisState.state$ : of({} as NeverDomains),
),
map((disabledUris) => (Object.keys(disabledUris).length ? disabledUris : {})),
);
this.equivalentDomainsState = this.stateProvider.getActive(EQUIVALENT_DOMAINS);
this.equivalentDomains$ = this.equivalentDomainsState.state$.pipe(map((x) => x ?? null));
@@ -90,6 +150,10 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
await this.neverDomainsState.update(() => newValue);
}
async setBlockedInteractionsUris(newValue: NeverDomains): Promise<void> {
await this.blockedInteractionsUrisState.update(() => newValue);
}
async setEquivalentDomains(newValue: EquivalentDomains, userId: UserId): Promise<void> {
await this.stateProvider.getUser(userId, EQUIVALENT_DOMAINS).update(() => newValue);
}

View File

@@ -1,9 +1,13 @@
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { CardView } from "../vault/models/view/card.view";
import {
normalizeExpiryYearFormat,
isCardExpired,
isUrlInList,
normalizeExpiryYearFormat,
parseYearMonthExpiry,
} from "@bitwarden/common/autofill/utils";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
} from "./utils";
function getExpiryYearValueFormats(currentCentury: string) {
return [
@@ -86,12 +90,14 @@ function getCardExpiryDateValues() {
// `Date` months are zero-indexed, our expiry date month inputs are one-indexed
const currentMonth = currentDate.getMonth() + 1;
const currentDateLastMonth = new Date(currentDate.setMonth(-1));
return [
[null, null, false], // no month, no year
[undefined, undefined, false], // no month, no year, invalid values
["", "", false], // no month, no year, invalid values
["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values
["0", `${currentYear}`, true], // invalid month
["0", `${currentYear}`, false], // invalid month
["0", `${currentYear - 1}`, true], // invalid 0 month
["00", `${currentYear + 1}`, false], // invalid 0 month
[`${currentMonth}`, "0000", true], // current month, in the year 2000
@@ -103,7 +109,7 @@ function getCardExpiryDateValues() {
[`${currentMonth + 36}`, `${currentYear - 1}`, true], // even though the month value would put the date 3 years into the future when calculated with `Date`, an explicit year in the past indicates the card is expired
[`${currentMonth}`, `${currentYear}`, false], // this year, this month (not expired until the month is over)
[`${currentMonth}`, `${currentYear}`.slice(-2), false], // This month, this year (not expired until the month is over)
[`${currentMonth - 1}`, `${currentYear}`, true], // last month
[`${currentDateLastMonth.getMonth() + 1}`, `${currentDateLastMonth.getFullYear()}`, true], // last month
[`${currentMonth - 1}`, `${currentYear + 1}`, false], // 11 months from now
];
}
@@ -282,3 +288,73 @@ describe("parseYearMonthExpiry", () => {
});
});
});
describe("isUrlInList", () => {
let mockUrlList: NeverDomains;
it("returns false if the passed URL list is empty", () => {
const urlIsInList = isUrlInList("", mockUrlList);
expect(urlIsInList).toEqual(false);
});
it("returns true if the URL hostname is on the passed URL list", () => {
mockUrlList = {
["bitwarden.com"]: { bannerIsDismissed: true },
["duckduckgo.com"]: null,
[".lan"]: null,
[".net"]: null,
["localhost"]: null,
["extensions"]: null,
};
const testPages = [
"https://www.bitwarden.com/landing-page?some_query_string_key=1&another_one=1",
" https://duckduckgo.com/pro ", // Note: embedded whitespacing is intentional
"https://network-private-domain.lan/homelabs-dashboard",
"https://jsfiddle.net/",
"https://localhost:8443/#/login",
"chrome://extensions/",
];
for (const pageUrl of testPages) {
const urlIsInList = isUrlInList(pageUrl, mockUrlList);
expect(urlIsInList).toEqual(true);
}
});
it("returns false if no items on the passed URL list are a full match for the page hostname", () => {
const urlIsInList = isUrlInList("https://paypal.com/", {
["some.packed.subdomains.sandbox.paypal.com"]: null,
});
expect(urlIsInList).toEqual(false);
});
it("returns false if the URL hostname is not on the passed URL list", () => {
const testPages = ["https://archive.org/", "bitwarden.com.some.otherdomain.com"];
for (const pageUrl of testPages) {
const urlIsInList = isUrlInList(pageUrl, mockUrlList);
expect(urlIsInList).toEqual(false);
}
});
it("returns false if the passed URL is empty", () => {
const urlIsInList = isUrlInList("", mockUrlList);
expect(urlIsInList).toEqual(false);
});
it("returns false if the passed URL is not a valid URL", () => {
const testPages = ["twasbrillingandtheslithytoves", "/landing-page", undefined];
for (const pageUrl of testPages) {
const urlIsInList = isUrlInList(pageUrl, mockUrlList);
expect(urlIsInList).toEqual(false);
}
});
});

View File

@@ -1,13 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CardView } from "../vault/models/view/card.view";
import {
DelimiterPatternExpression,
ExpiryFullYearPattern,
ExpiryFullYearPatternExpression,
IrrelevantExpiryCharactersPatternExpression,
MonthPatternExpression,
} from "@bitwarden/common/autofill/constants";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
} from "./constants";
type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`;
@@ -25,11 +27,11 @@ export function normalizeExpiryYearFormat(yearInput: string | number): Year | nu
let expirationYear = yearInputIsEmpty ? null : `${yearInput}`;
// Exit early if year is already formatted correctly or empty
if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) {
if (yearInputIsEmpty || (expirationYear && /^[1-9]{1}\d{3}$/.test(expirationYear))) {
return expirationYear as Year;
}
expirationYear = expirationYear
expirationYear = (expirationYear || "")
// For safety, because even input[type="number"] will allow decimals
.replace(/[^\d]/g, "")
// remove any leading zero padding (leave the last leading zero if it ends the string)
@@ -53,7 +55,7 @@ export function normalizeExpiryYearFormat(yearInput: string | number): Year | nu
/**
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
* the card is expired.
* the card is expired. Uncertain cases return "false".
*
* @param {CardView} cipherCard
* @return {*} {boolean}
@@ -62,27 +64,38 @@ export function isCardExpired(cipherCard: CardView): boolean {
if (cipherCard) {
const { expMonth = null, expYear = null } = cipherCard;
if (!expYear) {
return false;
}
const now = new Date();
const normalizedYear = normalizeExpiryYearFormat(expYear);
const parsedYear = normalizedYear ? parseInt(normalizedYear, 10) : NaN;
// If the card year is before the current year, don't bother checking the month
if (normalizedYear && parseInt(normalizedYear, 10) < now.getFullYear()) {
const expiryYearIsBeforeCurrentYear = parsedYear < now.getFullYear();
const expiryYearIsAfterCurrentYear = parsedYear > now.getFullYear();
// If the expiry year is before the current year, skip checking the month, since it must be expired
if (normalizedYear && expiryYearIsBeforeCurrentYear) {
return true;
}
// If the expiry year is after the current year, skip checking the month, since it cannot be expired
if (normalizedYear && expiryYearIsAfterCurrentYear) {
return false;
}
if (normalizedYear && expMonth) {
const parsedMonthInteger = parseInt(expMonth, 10);
const parsedMonthIsValid = parsedMonthInteger && !isNaN(parsedMonthInteger);
const parsedMonth = isNaN(parsedMonthInteger)
? 0
: // Add a month floor of 0 to protect against an invalid low month value of "0" or negative integers
Math.max(
// `Date` months are zero-indexed
parsedMonthInteger - 1,
0,
);
// If the parsed month value is 0, we don't know when the expiry passes this year, so do not treat it as expired
if (!parsedMonthIsValid) {
return false;
}
const parsedYear = parseInt(normalizedYear, 10);
// `Date` months are zero-indexed
const parsedMonth = parsedMonthInteger - 1;
// First day of the next month
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 1);
@@ -250,13 +263,18 @@ function parseNonDelimitedYearMonthExpiry(dateInput: string): [string | null, st
parsedMonth = dateInput.slice(-1);
const currentYear = new Date().getFullYear();
const normalizedParsedYear = parseInt(normalizeExpiryYearFormat(parsedYear), 10);
const normalizedParsedYearAlternative = parseInt(
normalizeExpiryYearFormat(dateInput.slice(-2)),
10,
);
const normalizedYearFormat = normalizeExpiryYearFormat(parsedYear);
const normalizedParsedYear = normalizedYearFormat && parseInt(normalizedYearFormat, 10);
const normalizedExpiryYearFormat = normalizeExpiryYearFormat(dateInput.slice(-2));
const normalizedParsedYearAlternative =
normalizedExpiryYearFormat && parseInt(normalizedExpiryYearFormat, 10);
if (normalizedParsedYear < currentYear && normalizedParsedYearAlternative >= currentYear) {
if (
normalizedParsedYear &&
normalizedParsedYear < currentYear &&
normalizedParsedYearAlternative &&
normalizedParsedYearAlternative >= currentYear
) {
parsedYear = dateInput.slice(-2);
parsedMonth = dateInput.slice(0, 1);
}
@@ -288,17 +306,24 @@ export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null,
// If there is only one date part, no delimiter was found in the passed value
if (dateParts.length === 1) {
[parsedYear, parsedMonth] = parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
const [parsedNonDelimitedYear, parsedNonDelimitedMonth] =
parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
parsedYear = parsedNonDelimitedYear;
parsedMonth = parsedNonDelimitedMonth;
}
// There are multiple date parts
else {
[parsedYear, parsedMonth] = parseDelimitedYearMonthExpiry([
const [parsedDelimitedYear, parsedDelimitedMonth] = parseDelimitedYearMonthExpiry([
sanitizedFirstPart,
sanitizedSecondPart,
]);
parsedYear = parsedDelimitedYear;
parsedMonth = parsedDelimitedMonth;
}
const normalizedParsedYear = normalizeExpiryYearFormat(parsedYear);
const normalizedParsedYear = parsedYear ? normalizeExpiryYearFormat(parsedYear) : null;
const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);
// Set "empty" values to null
@@ -307,3 +332,29 @@ export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null,
return [parsedYear, parsedMonth];
}
/**
* Takes a URL string and a NeverDomains object and determines if the passed URL's hostname is in `urlList`
*
* @param {string} url - representation of URL to check
* @param {NeverDomains} urlList - object with hostname key names
*/
export function isUrlInList(url: string = "", urlList: NeverDomains = {}): boolean {
const urlListKeys = urlList && Object.keys(urlList);
if (urlListKeys.length && url?.length) {
let tabHostname;
try {
tabHostname = Utils.getHostname(url);
} catch {
// If the input was invalid, exit early and return false
return false;
}
if (tabHostname) {
return urlListKeys.some((blockedHostname) => tabHostname.endsWith(blockedHostname));
}
}
return false;
}

View File

@@ -3,7 +3,7 @@
import {
BillingInvoiceResponse,
BillingTransactionResponse,
} from "@bitwarden/common/billing/models/response/billing.response";
} from "../../models/response/billing.response";
export class AccountBillingApiServiceAbstraction {
getBillingInvoices: (status?: string, startAfter?: string) => Promise<BillingInvoiceResponse[]>;

View File

@@ -11,27 +11,32 @@ export type BillingAccountProfile = {
export abstract class BillingAccountProfileStateService {
/**
* Emits `true` when the active user's account has been granted premium from any of the
* Emits `true` when the user's account has been granted premium from any of the
* organizations it is a member of. Otherwise, emits `false`
*/
hasPremiumFromAnyOrganization$: Observable<boolean>;
abstract hasPremiumFromAnyOrganization$(userId: UserId): Observable<boolean>;
/**
* Emits `true` when the active user's account has an active premium subscription at the
* Emits `true` when the user's account has an active premium subscription at the
* individual user level
*/
hasPremiumPersonally$: Observable<boolean>;
abstract hasPremiumPersonally$(userId: UserId): Observable<boolean>;
/**
* Emits `true` when either `hasPremiumPersonally` or `hasPremiumFromAnyOrganization` is `true`
*/
hasPremiumFromAnySource$: Observable<boolean>;
abstract hasPremiumFromAnySource$(userId: UserId): Observable<boolean>;
/**
* Sets the active user's premium status fields upon every full sync, either from their personal
* Emits `true` when the subscription menu item should be shown in navigation.
* This is hidden for organizations that provide premium, except if the user has premium personally
* or has a billing history.
*/
abstract canViewSubscription$(userId: UserId): Observable<boolean>;
/**
* Sets the user's premium status fields upon every full sync, either from their personal
* subscription to premium, or an organization they're a part of that grants them premium.
* @param hasPremiumPersonally
* @param hasPremiumFromAnyOrganization
*/
abstract setHasPremium(
hasPremiumPersonally: boolean,

View File

@@ -1,19 +1,20 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
import { InvoicesResponse } from "@bitwarden/common/billing/models/response/invoices.response";
import { PaymentMethodResponse } from "@bitwarden/common/billing/models/response/payment-method.response";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
import { PlanResponse } from "../../billing/models/response/plan.response";
import { ListResponse } from "../../models/response/list.response";
import { PaymentMethodType } from "../enums";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
import { ExpandedTaxInfoUpdateRequest } from "../models/request/expanded-tax-info-update.request";
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
import { UpdatePaymentMethodRequest } from "../models/request/update-payment-method.request";
import { VerifyBankAccountRequest } from "../models/request/verify-bank-account.request";
import { InvoicesResponse } from "../models/response/invoices.response";
import { PaymentMethodResponse } from "../models/response/payment-method.response";
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
export abstract class BillingApiServiceAbstraction {
@@ -74,4 +75,9 @@ export abstract class BillingApiServiceAbstraction {
organizationId: string,
request: VerifyBankAccountRequest,
) => Promise<void>;
restartSubscription: (
organizationId: string,
request: OrganizationCreateRequest,
) => Promise<void>;
}

View File

@@ -1,11 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BillingSourceResponse } from "@bitwarden/common/billing/models/response/billing.response";
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { InitiationPath } from "../../models/request/reference-event.request";
import { PaymentMethodType, PlanType } from "../enums";
import { PaymentSourceResponse } from "../models/response/payment-source.response";
export type OrganizationInformation = {
name: string;
@@ -46,9 +45,7 @@ export type SubscriptionInformation = {
};
export abstract class OrganizationBillingServiceAbstraction {
getPaymentSource: (
organizationId: string,
) => Promise<BillingSourceResponse | PaymentSourceResponse>;
getPaymentSource: (organizationId: string) => Promise<PaymentSourceResponse>;
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
@@ -57,4 +54,9 @@ export abstract class OrganizationBillingServiceAbstraction {
) => Promise<OrganizationResponse>;
startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
restartSubscription: (
organizationId: string,
subscription: SubscriptionInformation,
) => Promise<void>;
}

View File

@@ -3,7 +3,7 @@
import {
BillingInvoiceResponse,
BillingTransactionResponse,
} from "@bitwarden/common/billing/models/response/billing.response";
} from "../../models/response/billing.response";
export class OrganizationBillingApiServiceAbstraction {
getBillingInvoices: (

View File

@@ -0,0 +1,18 @@
import { CountryListItem } from "../models/domain";
import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request";
import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request";
import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response";
export abstract class TaxServiceAbstraction {
abstract getCountries(): CountryListItem[];
abstract isCountrySupported(country: string): Promise<boolean>;
abstract previewIndividualInvoice(
request: PreviewIndividualInvoiceRequest,
): Promise<PreviewInvoiceResponse>;
abstract previewOrganizationInvoice(
request: PreviewOrganizationInvoiceRequest,
): Promise<PreviewInvoiceResponse>;
}

View File

@@ -0,0 +1,5 @@
export type CountryListItem = {
name: string;
value: string;
disabled: boolean;
};

View File

@@ -1,2 +1,3 @@
export * from "./bank-account";
export * from "./country-list-item";
export * from "./tax-information";

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
import { TaxInfoResponse } from "../response/tax-info.response";
export class TaxInformation {
country: string;

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information";
import { TaxInformation } from "../domain/tax-information";
import { TaxInfoUpdateRequest } from "./tax-info-update.request";
@@ -12,6 +12,10 @@ export class ExpandedTaxInfoUpdateRequest extends TaxInfoUpdateRequest {
state: string;
static From(taxInformation: TaxInformation): ExpandedTaxInfoUpdateRequest {
if (!taxInformation) {
return null;
}
const request = new ExpandedTaxInfoUpdateRequest();
request.country = taxInformation.country;
request.postalCode = taxInformation.postalCode;

View File

@@ -0,0 +1,28 @@
// @ts-strict-ignore
export class PreviewIndividualInvoiceRequest {
passwordManager: PasswordManager;
taxInformation: TaxInformation;
constructor(passwordManager: PasswordManager, taxInformation: TaxInformation) {
this.passwordManager = passwordManager;
this.taxInformation = taxInformation;
}
}
class PasswordManager {
additionalStorage: number;
constructor(additionalStorage: number) {
this.additionalStorage = additionalStorage;
}
}
class TaxInformation {
postalCode: string;
country: string;
constructor(postalCode: string, country: string) {
this.postalCode = postalCode;
this.country = country;
}
}

View File

@@ -0,0 +1,54 @@
import { PlanType } from "../../enums";
export class PreviewOrganizationInvoiceRequest {
organizationId?: string;
passwordManager: PasswordManager;
secretsManager?: SecretsManager;
taxInformation: TaxInformation;
constructor(
passwordManager: PasswordManager,
taxInformation: TaxInformation,
organizationId?: string,
secretsManager?: SecretsManager,
) {
this.organizationId = organizationId;
this.passwordManager = passwordManager;
this.secretsManager = secretsManager;
this.taxInformation = taxInformation;
}
}
class PasswordManager {
plan: PlanType;
seats: number;
additionalStorage: number;
constructor(plan: PlanType, seats: number, additionalStorage: number) {
this.plan = plan;
this.seats = seats;
this.additionalStorage = additionalStorage;
}
}
class SecretsManager {
seats: number;
additionalMachineAccounts: number;
constructor(seats: number, additionalMachineAccounts: number) {
this.seats = seats;
this.additionalMachineAccounts = additionalMachineAccounts;
}
}
class TaxInformation {
postalCode: string;
country: string;
taxId: string;
constructor(postalCode: string, country: string, taxId: string) {
this.postalCode = postalCode;
this.country = country;
this.taxId = taxId;
}
}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { PaymentMethodType } from "../../enums";
export class TokenizedPaymentSourceRequest {
type: PaymentMethodType;

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request";
import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request";
import { TokenizedPaymentSourceRequest } from "./tokenized-payment-source.request";
export class UpdatePaymentMethodRequest {
paymentSource: TokenizedPaymentSourceRequest;

View File

@@ -1,4 +1,4 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { BaseResponse } from "../../../models/response/base.response";
export class InvoicesResponse extends BaseResponse {
invoices: InvoiceResponse[] = [];

View File

@@ -6,6 +6,11 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
isOnSecretsManagerStandalone: boolean;
isSubscriptionUnpaid: boolean;
hasSubscription: boolean;
hasOpenInvoice: boolean;
invoiceDueDate: Date | null;
invoiceCreatedDate: Date | null;
subPeriodEndDate: Date | null;
isSubscriptionCanceled: boolean;
constructor(response: any) {
super(response);
@@ -14,5 +19,15 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
this.hasSubscription = this.getResponseProperty("HasSubscription");
this.hasOpenInvoice = this.getResponseProperty("HasOpenInvoice");
this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate"));
this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate"));
this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate"));
this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled");
}
private parseDate(dateString: any): Date | null {
return dateString ? new Date(dateString) : null;
}
}

Some files were not shown because too many files have changed in this diff Show More