1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-28 02:23:25 +00:00

Merge remote-tracking branch 'origin/main' into feature/passkey-provider

This commit is contained in:
Jeffrey Holland
2025-10-07 13:58:37 +02:00
1240 changed files with 76622 additions and 27783 deletions

View File

@@ -24,7 +24,6 @@ import {
OrganizationConnectionConfigApis,
OrganizationConnectionResponse,
} from "../admin-console/models/response/organization-connection.response";
import { OrganizationExportResponse } from "../admin-console/models/response/organization-export.response";
import { OrganizationSponsorshipSyncStatusResponse } from "../admin-console/models/response/organization-sponsorship-sync-status.response";
import { PreValidateSponsorshipResponse } from "../admin-console/models/response/pre-validate-sponsorship.response";
import {
@@ -78,14 +77,10 @@ import {
} from "../auth/models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response";
import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request";
import { PaymentRequest } from "../billing/models/request/payment.request";
import { TaxInfoUpdateRequest } from "../billing/models/request/tax-info-update.request";
import { BillingHistoryResponse } from "../billing/models/response/billing-history.response";
import { BillingPaymentResponse } from "../billing/models/response/billing-payment.response";
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 { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request";
import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request";
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
@@ -98,7 +93,6 @@ import { UpdateAvatarRequest } from "../models/request/update-avatar.request";
import { UpdateDomainsRequest } from "../models/request/update-domains.request";
import { VerifyDeleteRecoverRequest } from "../models/request/verify-delete-recover.request";
import { VerifyEmailRequest } from "../models/request/verify-email.request";
import { BreachAccountResponse } from "../models/response/breach-account.response";
import { DomainsResponse } from "../models/response/domains.response";
import { EventResponse } from "../models/response/event.response";
import { ListResponse } from "../models/response/list.response";
@@ -173,10 +167,8 @@ export abstract class ApiService {
abstract getProfile(): Promise<ProfileResponse>;
abstract getUserSubscription(): Promise<SubscriptionResponse>;
abstract getTaxInfo(): Promise<TaxInfoResponse>;
abstract putProfile(request: UpdateProfileRequest): Promise<ProfileResponse>;
abstract putAvatar(request: UpdateAvatarRequest): Promise<ProfileResponse>;
abstract putTaxInfo(request: TaxInfoUpdateRequest): Promise<any>;
abstract postPrelogin(request: PreloginRequest): Promise<PreloginResponse>;
abstract postEmailToken(request: EmailTokenRequest): Promise<any>;
abstract postEmail(request: EmailRequest): Promise<any>;
@@ -187,7 +179,6 @@ export abstract class ApiService {
abstract postPremium(data: FormData): Promise<PaymentResponse>;
abstract postReinstatePremium(): Promise<any>;
abstract postAccountStorage(request: StorageRequest): Promise<PaymentResponse>;
abstract postAccountPayment(request: PaymentRequest): Promise<void>;
abstract postAccountLicense(data: FormData): Promise<any>;
abstract postAccountKeys(request: KeysRequest): Promise<any>;
abstract postAccountVerifyEmail(): Promise<any>;
@@ -211,7 +202,6 @@ export abstract class ApiService {
abstract getLastAuthRequest(): Promise<AuthRequestResponse>;
abstract getUserBillingHistory(): Promise<BillingHistoryResponse>;
abstract getUserBillingPayment(): Promise<BillingPaymentResponse>;
abstract getCipher(id: string): Promise<CipherResponse>;
abstract getFullCipherDetails(id: string): Promise<CipherResponse>;
@@ -471,6 +461,13 @@ export abstract class ApiService {
end: string,
token: string,
): Promise<ListResponse<EventResponse>>;
abstract getEventsServiceAccount(
orgId: string,
id: string,
start: string,
end: string,
token: string,
): Promise<ListResponse<EventResponse>>;
abstract getEventsProject(
orgId: string,
id: string,
@@ -517,8 +514,6 @@ export abstract class ApiService {
abstract getUserPublicKey(id: string): Promise<UserKeyResponse>;
abstract getHibpBreach(username: string): Promise<BreachAccountResponse[]>;
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
abstract postSetupPayment(): Promise<string>;
@@ -552,5 +547,4 @@ export abstract class ApiService {
request: KeyConnectorUserKeyRequest,
): Promise<void>;
abstract getKeyConnectorAlive(keyConnectorUrl: string): Promise<void>;
abstract getOrganizationExport(organizationId: string): Promise<OrganizationExportResponse>;
}

View File

@@ -1,4 +1,4 @@
import { BreachAccountResponse } from "../models/response/breach-account.response";
import { BreachAccountResponse } from "../dirt/models/response/breach-account.response";
export abstract class AuditService {
/**
@@ -14,4 +14,10 @@ export abstract class AuditService {
* @returns A promise that resolves to an array of BreachAccountResponse objects.
*/
abstract breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
/**
* Checks if a domain is known for phishing.
* @param domain The domain to check.
* @returns A promise that resolves to a boolean indicating if the domain is known for phishing.
*/
abstract getKnownPhishingDomains: () => Promise<string[]>;
}

View File

@@ -3,21 +3,17 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
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";
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
import { ImportDirectoryRequest } from "../../../models/request/import-directory.request";
import { SeatRequest } from "../../../models/request/seat.request";
import { StorageRequest } from "../../../models/request/storage.request";
import { VerifyBankRequest } from "../../../models/request/verify-bank.request";
import { ListResponse } from "../../../models/response/list.response";
import { OrganizationApiKeyType } from "../../enums";
import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request";
@@ -45,7 +41,6 @@ export abstract class OrganizationApiServiceAbstraction {
): Promise<OrganizationResponse>;
abstract createLicense(data: FormData): Promise<OrganizationResponse>;
abstract save(id: string, request: OrganizationUpdateRequest): Promise<OrganizationResponse>;
abstract updatePayment(id: string, request: PaymentRequest): Promise<void>;
abstract upgrade(id: string, request: OrganizationUpgradeRequest): Promise<PaymentResponse>;
abstract updatePasswordManagerSeats(
id: string,
@@ -57,7 +52,6 @@ export abstract class OrganizationApiServiceAbstraction {
): Promise<ProfileOrganizationResponse>;
abstract updateSeats(id: string, request: SeatRequest): Promise<PaymentResponse>;
abstract updateStorage(id: string, request: StorageRequest): Promise<PaymentResponse>;
abstract verifyBank(id: string, request: VerifyBankRequest): Promise<void>;
abstract reinstate(id: string): Promise<void>;
abstract leave(id: string): Promise<void>;
abstract delete(id: string, request: SecretVerificationRequest): Promise<void>;
@@ -76,8 +70,6 @@ export abstract class OrganizationApiServiceAbstraction {
organizationApiKeyType?: OrganizationApiKeyType,
): Promise<ListResponse<OrganizationApiKeyInformationResponse>>;
abstract rotateApiKey(id: string, request: OrganizationApiKeyRequest): Promise<ApiKeyResponse>;
abstract getTaxInfo(id: string): Promise<TaxInfoResponse>;
abstract updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise<void>;
abstract getKeys(id: string): Promise<OrganizationKeysResponse>;
abstract updateKeys(
id: string,

View File

@@ -24,4 +24,5 @@ export abstract class PolicyApiServiceAbstraction {
type: PolicyType,
request: PolicyRequest,
) => Promise<any>;
abstract putPolicyVNext: (organizationId: string, type: PolicyType, request: any) => Promise<any>;
}

View File

@@ -5,8 +5,7 @@ import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider";
export abstract class ProviderService {
abstract get$(id: string): Observable<Provider>;
abstract get(id: string): Promise<Provider>;
abstract getAll(): Promise<Provider[]>;
abstract save(providers: { [id: string]: ProviderData }, userId?: UserId): Promise<any>;
abstract providers$(userId: UserId): Observable<Provider[]>;
abstract get$(id: string, userId: UserId): Observable<Provider | undefined>;
abstract save(providers: { [id: string]: ProviderData }, userId: UserId): Promise<any>;
}

View File

@@ -17,4 +17,5 @@ export enum PolicyType {
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN.
RestrictedItemTypes = 15, // Restricts item types that can be created within an organization
AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app
}

View File

@@ -111,28 +111,6 @@ describe("Organization", () => {
expect(organization.canManageDeviceApprovals).toBe(false);
});
it("should return false when ssoEnabled is false", () => {
data.type = OrganizationUserType.Admin;
data.useSso = true;
data.ssoEnabled = false;
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(false);
});
it("should return false when ssoMemberDecryptionType is not TrustedDeviceEncryption", () => {
data.type = OrganizationUserType.Admin;
data.useSso = true;
data.ssoEnabled = true;
data.ssoMemberDecryptionType = MemberDecryptionType.MasterPassword;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(false);
});
it("should return true when admin has all required SSO settings enabled", () => {
data.type = OrganizationUserType.Admin;
data.useSso = true;

View File

@@ -309,12 +309,7 @@ export class Organization {
}
get canManageDeviceApprovals() {
return (
(this.isAdmin || this.permissions.manageResetPassword) &&
this.useSso &&
this.ssoEnabled &&
this.ssoMemberDecryptionType === MemberDecryptionType.TrustedDeviceEncryption
);
return (this.isAdmin || this.permissions.manageResetPassword) && this.useSso;
}
get isExemptFromPolicies() {

View File

@@ -1,7 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request";
import { TokenizedPaymentSourceRequest } from "../../../../billing/models/request/tokenized-payment-source.request";
interface TokenizedPaymentMethod {
type: "bankAccount" | "card" | "payPal";
token: string;
}
interface BillingAddress {
country: string;
postalCode: string;
line1: string | null;
line2: string | null;
city: string | null;
state: string | null;
taxId: { code: string; value: string } | null;
}
export class ProviderSetupRequest {
name: string;
@@ -9,6 +21,6 @@ export class ProviderSetupRequest {
billingEmail: string;
token: string;
key: string;
taxInfo: ExpandedTaxInfoUpdateRequest;
paymentSource?: TokenizedPaymentSourceRequest;
paymentMethod: TokenizedPaymentMethod;
billingAddress: BillingAddress;
}

View File

@@ -7,21 +7,17 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
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";
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
import { ImportDirectoryRequest } from "../../../models/request/import-directory.request";
import { SeatRequest } from "../../../models/request/seat.request";
import { StorageRequest } from "../../../models/request/storage.request";
import { VerifyBankRequest } from "../../../models/request/verify-bank.request";
import { ListResponse } from "../../../models/response/list.response";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction";
@@ -143,10 +139,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return data;
}
async updatePayment(id: string, request: PaymentRequest): Promise<void> {
return this.apiService.send("POST", "/organizations/" + id + "/payment", request, true, false);
}
async upgrade(id: string, request: OrganizationUpgradeRequest): Promise<PaymentResponse> {
const r = await this.apiService.send(
"POST",
@@ -208,16 +200,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return new PaymentResponse(r);
}
async verifyBank(id: string, request: VerifyBankRequest): Promise<void> {
await this.apiService.send(
"POST",
"/organizations/" + id + "/verify-bank",
request,
true,
false,
);
}
async reinstate(id: string): Promise<void> {
return this.apiService.send("POST", "/organizations/" + id + "/reinstate", null, true, false);
}
@@ -299,16 +281,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return new ApiKeyResponse(r);
}
async getTaxInfo(id: string): Promise<TaxInfoResponse> {
const r = await this.apiService.send("GET", "/organizations/" + id + "/tax", null, true, true);
return new TaxInfoResponse(r);
}
async updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise<void> {
// Can't broadcast anything because the response doesn't have content
return this.apiService.send("PUT", "/organizations/" + id + "/tax", request, true, false);
}
async getKeys(id: string): Promise<OrganizationKeysResponse> {
const r = await this.apiService.send("GET", "/organizations/" + id + "/keys", null, true, true);
return new OrganizationKeysResponse(r);

View File

@@ -116,16 +116,32 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
}
async putPolicy(organizationId: string, type: PolicyType, request: PolicyRequest): Promise<any> {
const r = await this.apiService.send(
const response = await this.apiService.send(
"PUT",
"/organizations/" + organizationId + "/policies/" + type,
request,
true,
true,
);
await this.handleResponse(response);
}
async putPolicyVNext(organizationId: string, type: PolicyType, request: any): Promise<any> {
const response = await this.apiService.send(
"PUT",
`/organizations/${organizationId}/policies/${type}/vnext`,
request,
true,
true,
);
await this.handleResponse(response);
}
private async handleResponse(response: any): Promise<void> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const response = new PolicyResponse(r);
const data = new PolicyData(response);
const policyResponse = new PolicyResponse(response);
const data = new PolicyData(policyResponse);
await this.policyService.upsert(data, userId);
}
}

View File

@@ -1,7 +1,7 @@
import { firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
import { FakeSingleUserState } from "../../../spec/fake-state";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import {
@@ -20,11 +20,11 @@ import { PROVIDERS, ProviderService } from "./provider.service";
* in state. This helper methods lets us build provider arrays in tests
* and easily map them to records before storing them in state.
*/
function arrayToRecord(input: ProviderData[]): Record<string, ProviderData> {
if (input == null) {
return undefined;
function arrayToRecord(input: ProviderData[] | undefined): Record<string, ProviderData> | null {
if (input == null || input.length < 1) {
return null;
}
return Object.fromEntries(input?.map((i) => [i.id, i]));
return Object.fromEntries(input.map((i) => [i.id, i]));
}
/**
@@ -39,7 +39,7 @@ function arrayToRecord(input: ProviderData[]): Record<string, ProviderData> {
*/
function buildMockProviders(count = 1, suffix?: string): ProviderData[] {
if (count < 1) {
return undefined;
return [];
}
function buildMockProvider(id: string, name: string): ProviderData {
@@ -87,30 +87,28 @@ describe("ProviderService", () => {
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeUserState: FakeSingleUserState<Record<string, ProviderData>>;
let fakeActiveUserState: FakeActiveUserState<Record<string, ProviderData>>;
beforeEach(async () => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeUserState = fakeStateProvider.singleUser.getFake(fakeUserId, PROVIDERS);
fakeActiveUserState = fakeStateProvider.activeUser.getFake(PROVIDERS);
providerService = new ProviderService(fakeStateProvider);
});
describe("getAll()", () => {
describe("providers$()", () => {
it("Returns an array of all providers stored in state", async () => {
const mockData: ProviderData[] = buildMockProviders(5);
const mockData = buildMockProviders(5);
fakeUserState.nextState(arrayToRecord(mockData));
const providers = await providerService.getAll();
const providers = await firstValueFrom(providerService.providers$(fakeUserId));
expect(providers).toHaveLength(5);
expect(providers).toEqual(mockData.map((x) => new Provider(x)));
});
it("Returns an empty array if no providers are found in state", async () => {
const mockData: ProviderData[] = undefined;
let mockData;
fakeUserState.nextState(arrayToRecord(mockData));
const result = await providerService.getAll();
const result = await firstValueFrom(providerService.providers$(fakeUserId));
expect(result).toEqual([]);
});
});
@@ -119,50 +117,38 @@ describe("ProviderService", () => {
it("Returns an observable of a single provider from state that matches the specified id", async () => {
const mockData = buildMockProviders(5);
fakeUserState.nextState(arrayToRecord(mockData));
const result = providerService.get$(mockData[3].id);
const result = providerService.get$(mockData[3].id, fakeUserId);
const provider = await firstValueFrom(result);
expect(provider).toEqual(new Provider(mockData[3]));
});
it("Returns an observable of undefined if the specified provider is not found", async () => {
const result = providerService.get$("this-provider-does-not-exist");
const result = providerService.get$("this-provider-does-not-exist", fakeUserId);
const provider = await firstValueFrom(result);
expect(provider).toBe(undefined);
});
});
describe("get()", () => {
it("Returns a single provider from state that matches the specified id", async () => {
const mockData = buildMockProviders(5);
fakeUserState.nextState(arrayToRecord(mockData));
const result = await providerService.get(mockData[3].id);
expect(result).toEqual(new Provider(mockData[3]));
});
it("Returns undefined if the specified provider id is not found", async () => {
const result = await providerService.get("this-provider-does-not-exist");
expect(result).toBe(undefined);
});
});
describe("save()", () => {
it("replaces the entire provider list in state for the active user", async () => {
it("replaces the entire provider list in state for the specified user", async () => {
const originalData = buildMockProviders(10);
fakeUserState.nextState(arrayToRecord(originalData));
const newData = arrayToRecord(buildMockProviders(10, "newData"));
await providerService.save(newData);
if (newData) {
await providerService.save(newData, fakeUserId);
}
expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, newData]);
expect(fakeUserState.nextMock).toHaveBeenCalledWith(newData);
});
// This is more or less a test for logouts
it("can replace state with null", async () => {
const originalData = buildMockProviders(2);
fakeActiveUserState.nextState(arrayToRecord(originalData));
await providerService.save(null);
fakeUserState.nextState(arrayToRecord(originalData));
await providerService.save(null, fakeUserId);
expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, null]);
expect(fakeUserState.nextMock).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable, of, switchMap, take } from "rxjs";
import { map, Observable } from "rxjs";
import { getById } from "../../platform/misc";
import { PROVIDERS_DISK, StateProvider, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid";
import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service";
@@ -13,46 +12,26 @@ export const PROVIDERS = UserKeyDefinition.record<ProviderData>(PROVIDERS_DISK,
clearOn: ["logout"],
});
function mapToSingleProvider(providerId: string) {
return map<Provider[], Provider>((providers) => providers?.find((p) => p.id === providerId));
}
export class ProviderService implements ProviderServiceAbstraction {
constructor(private stateProvider: StateProvider) {}
private providers$(userId?: UserId): Observable<Provider[] | undefined> {
// FIXME: Can be replaced with `getUserStateOrDefault$` if we weren't trying to pick this.
return (
userId != null
? this.stateProvider.getUser(userId, PROVIDERS).state$
: this.stateProvider.activeUserId$.pipe(
take(1),
switchMap((userId) =>
userId != null ? this.stateProvider.getUser(userId, PROVIDERS).state$ : of(null),
),
)
).pipe(this.mapProviderRecordToArray());
providers$(userId: UserId): Observable<Provider[]> {
return this.stateProvider
.getUser(userId, PROVIDERS)
.state$.pipe(this.mapProviderRecordToArray());
}
private mapProviderRecordToArray() {
return map<Record<string, ProviderData>, Provider[]>((providers) =>
Object.values(providers ?? {})?.map((o) => new Provider(o)),
return map<Record<string, ProviderData> | null, Provider[]>((providers) =>
Object.values(providers ?? {}).map((o) => new Provider(o)),
);
}
get$(id: string): Observable<Provider> {
return this.providers$().pipe(mapToSingleProvider(id));
get$(id: string, userId: UserId): Observable<Provider | undefined> {
return this.providers$(userId).pipe(getById(id));
}
async get(id: string): Promise<Provider> {
return await firstValueFrom(this.providers$().pipe(mapToSingleProvider(id)));
}
async getAll(): Promise<Provider[]> {
return await firstValueFrom(this.providers$());
}
async save(providers: { [id: string]: ProviderData }, userId?: UserId) {
async save(providers: { [id: string]: ProviderData }, userId: UserId) {
await this.stateProvider.setUserState(PROVIDERS, providers, userId);
}
}

View File

@@ -1,3 +1,5 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
export abstract class SsoLoginServiceAbstraction {
@@ -70,6 +72,10 @@ export abstract class SsoLoginServiceAbstraction {
*
*/
abstract setSsoEmail: (email: string) => Promise<void>;
/**
* Clear the SSO email
*/
abstract clearSsoEmail: () => Promise<void>;
/**
* Gets the value of the active user's organization sso identifier.
*
@@ -86,4 +92,24 @@ export abstract class SsoLoginServiceAbstraction {
organizationIdentifier: string,
userId: UserId | undefined,
) => Promise<void>;
/**
* A cache list of user emails for whom the `PolicyType.RequireSso` policy is applied (that is, a list
* of users who are required to authenticate via SSO only). The cache lives on the current device only.
*/
abstract ssoRequiredCache$: Observable<Set<string> | null>;
/**
* Remove an email from the cached list of emails that must authenticate via SSO.
*/
abstract removeFromSsoRequiredCacheIfPresent: (email: string) => Promise<void>;
/**
* Check if the user is required to authenticate via SSO. If so, add their email to a cache list.
* We'll use this cache list to display ONLY the "Use single sign-on" button to the
* user the next time they are on the /login page.
*
* If the user is not required to authenticate via SSO, remove their email from the cache list if it is present.
*/
abstract updateSsoRequiredCache: (ssoLoginEmail: string, userId: UserId) => Promise<void>;
}

View File

@@ -1,9 +1,27 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { EmailTokenRequest } from "./email-token.request";
export class EmailRequest extends EmailTokenRequest {
newMasterPasswordHash: string;
token: string;
key: string;
// This will eventually be changed to be an actual constructor, once all callers are updated.
// The body of this request will be changed to carry the authentication data and unlock data.
// https://bitwarden.atlassian.net/browse/PM-23234
static newConstructor(
authenticationData: MasterPasswordAuthenticationData,
unlockData: MasterPasswordUnlockData,
): EmailRequest {
const request = new EmailRequest();
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
request.key = unlockData.masterKeyWrappedUserKey;
return request;
}
}

View File

@@ -1,9 +1,31 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { SecretVerificationRequest } from "./secret-verification.request";
export class PasswordRequest extends SecretVerificationRequest {
newMasterPasswordHash: string;
masterPasswordHint: string;
key: string;
authenticationData?: MasterPasswordAuthenticationData;
unlockData?: MasterPasswordUnlockData;
// This will eventually be changed to be an actual constructor, once all callers are updated.
// https://bitwarden.atlassian.net/browse/PM-23234
static newConstructor(
authenticationData: MasterPasswordAuthenticationData,
unlockData: MasterPasswordUnlockData,
): PasswordRequest {
const request = new PasswordRequest();
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
request.key = unlockData.masterKeyWrappedUserKey;
request.authenticationData = authenticationData;
request.unlockData = unlockData;
return request;
}
}

View File

@@ -1,7 +1,20 @@
// FIXME: Update this file to be type safe and remove this and next line
import { MasterPasswordAuthenticationData } from "@bitwarden/common/key-management/master-password/types/master-password.types";
// @ts-strict-ignore
export class SecretVerificationRequest {
masterPasswordHash: string;
otp: string;
authRequestAccessCode: string;
/**
* Mutates this request to include the master password authentication data, to authenticate the request.
*/
authenticateWith(
masterPasswordAuthenticationData: MasterPasswordAuthenticationData,
): SecretVerificationRequest {
this.masterPasswordHash = masterPasswordAuthenticationData.masterPasswordAuthenticationHash;
return this;
}
}

View File

@@ -1,6 +1,11 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import { KdfConfig, KdfType } from "@bitwarden/key-management";
import { KeysRequest } from "../../../models/request/keys.request";
@@ -21,19 +26,45 @@ export class SetPasswordRequest {
masterPasswordHint: string,
orgIdentifier: string,
keys: KeysRequest | null,
kdf: KdfType,
kdfIterations: number,
kdfMemory?: number,
kdfParallelism?: number,
kdf: KdfConfig,
) {
this.masterPasswordHash = masterPasswordHash;
this.key = key;
this.masterPasswordHint = masterPasswordHint;
this.kdf = kdf;
this.kdfIterations = kdfIterations;
this.kdfMemory = kdfMemory;
this.kdfParallelism = kdfParallelism;
this.orgIdentifier = orgIdentifier;
this.keys = keys;
if (kdf.kdfType === KdfType.PBKDF2_SHA256) {
this.kdf = KdfType.PBKDF2_SHA256;
this.kdfIterations = kdf.iterations;
} else if (kdf.kdfType === KdfType.Argon2id) {
this.kdf = KdfType.Argon2id;
this.kdfIterations = kdf.iterations;
this.kdfMemory = kdf.memory;
this.kdfParallelism = kdf.parallelism;
} else {
throw new Error(`Unsupported KDF type: ${kdf}`);
}
}
// This will eventually be changed to be an actual constructor, once all callers are updated.
// The body of this request will be changed to carry the authentication data and unlock data.
// https://bitwarden.atlassian.net/browse/PM-23234
static newConstructor(
authenticationData: MasterPasswordAuthenticationData,
unlockData: MasterPasswordUnlockData,
masterPasswordHint: string,
orgIdentifier: string,
keys: KeysRequest | null,
): SetPasswordRequest {
const request = new SetPasswordRequest(
authenticationData.masterPasswordAuthenticationHash,
unlockData.masterKeyWrappedUserKey,
masterPasswordHint,
orgIdentifier,
keys,
unlockData.kdf,
);
return request;
}
}

View File

@@ -1,14 +1,12 @@
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import { makeEncString } from "../../../../../spec";
import { UserDecryptionOptionsResponse } from "./user-decryption-options.response";
describe("UserDecryptionOptionsResponse", () => {
it("should create response when master password unlock is present", () => {
const salt = "test@example.com";
const encryptedUserKey = makeEncString("testUserKey");
const encryptedUserKey = "testUserKey";
const response = new UserDecryptionOptionsResponse({
HasMasterPassword: true,
@@ -18,7 +16,7 @@ describe("UserDecryptionOptionsResponse", () => {
KdfType: KdfType.PBKDF2_SHA256,
Iterations: 600_000,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
},
});

View File

@@ -0,0 +1 @@
export * from "./send-token.service";

View File

@@ -0,0 +1,57 @@
import { Observable } from "rxjs";
import { SendAccessToken } from "../models/send-access-token";
import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type";
import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type";
import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type";
import { TryGetSendAccessTokenError } from "../types/try-get-send-access-token-error.type";
/**
* Service to manage send access tokens.
*/
export abstract class SendTokenService {
/**
* Attempts to retrieve a {@link SendAccessToken} for the given sendId.
* If the access token is found in session storage and is not expired, then it returns the token.
* If the access token is expired, then it returns a {@link TryGetSendAccessTokenError} expired error.
* If an access token is not found in storage, then it attempts to retrieve it from the server (will succeed for sends that don't require any credentials to view).
* If the access token is successfully retrieved from the server, then it stores the token in session storage and returns it.
* If an access token cannot be granted b/c the send requires credentials, then it returns a {@link TryGetSendAccessTokenError} indicating which credentials are required.
* Any submissions of credentials will be handled by the getSendAccessToken$ method.
* @param sendId The ID of the send to retrieve the access token for.
* @returns An observable that emits a SendAccessToken if successful, or a TryGetSendAccessTokenError if not.
*/
abstract tryGetSendAccessToken$: (
sendId: string,
) => Observable<SendAccessToken | TryGetSendAccessTokenError>;
/**
* Retrieves a SendAccessToken for the given sendId using the provided credentials.
* If the access token is successfully retrieved from the server, it stores the token in session storage and returns it.
* If the access token cannot be granted due to invalid credentials, it returns a {@link GetSendAccessTokenError}.
* @param sendId The ID of the send to retrieve the access token for.
* @param sendAccessCredentials The credentials to use for accessing the send.
* @returns An observable that emits a SendAccessToken if successful, or a GetSendAccessTokenError if not.
*/
abstract getSendAccessToken$: (
sendId: string,
sendAccessCredentials: SendAccessDomainCredentials,
) => Observable<SendAccessToken | GetSendAccessTokenError>;
/**
* Hashes a password for send access which is required to create a {@link SendAccessTokenRequest}
* (more specifically, to create a {@link SendAccessDomainCredentials} for sends that require a password)
* @param password The raw password string to hash.
* @param keyMaterialUrlB64 The base64 URL encoded key material string.
* @returns A promise that resolves to the hashed password as a SendHashedPasswordB64.
*/
abstract hashSendPassword: (
password: string,
keyMaterialUrlB64: string,
) => Promise<SendHashedPasswordB64>;
/**
* Clears a send access token from storage.
*/
abstract invalidateSendAccessToken: (sendId: string) => Promise<void>;
}

View File

@@ -0,0 +1,4 @@
export * from "./abstractions";
export * from "./models";
export * from "./services";
export * from "./types";

View File

@@ -0,0 +1 @@
export * from "./send-access-token";

View File

@@ -0,0 +1,75 @@
import { SendAccessTokenResponse } from "@bitwarden/sdk-internal";
import { SendAccessToken } from "./send-access-token";
describe("SendAccessToken", () => {
const sendId = "sendId";
const NOW = 1_000_000; // fixed timestamp for predictable results
const expiresAt: number = NOW + 1000 * 60 * 5; // 5 minutes from now
const expiredExpiresAt: number = NOW - 1000 * 60 * 5; // 5 minutes ago
let nowSpy: jest.SpyInstance<number, []>;
beforeAll(() => {
nowSpy = jest.spyOn(Date, "now");
});
beforeEach(() => {
// Ensure every test starts from the same fixed time
nowSpy.mockReturnValue(NOW);
});
afterAll(() => {
jest.restoreAllMocks();
});
it("should create a valid, unexpired token", () => {
const token = new SendAccessToken(sendId, expiresAt);
expect(token).toBeTruthy();
expect(token.isExpired()).toBe(false);
});
it("should be expired after the expiration time", () => {
const token = new SendAccessToken(sendId, expiredExpiresAt);
expect(token.isExpired()).toBe(true);
});
it("should be considered expired if within 5 seconds of expiration", () => {
const token = new SendAccessToken(sendId, expiresAt);
nowSpy.mockReturnValue(expiresAt - 4_000); // 4 seconds before expiry
expect(token.isExpired()).toBe(true);
});
it("should return the correct time until expiry in seconds", () => {
const token = new SendAccessToken(sendId, expiresAt);
expect(token.timeUntilExpirySeconds()).toBe(300); // 5 minutes
});
it("should return 0 if the token is expired", () => {
const token = new SendAccessToken(sendId, expiredExpiresAt);
expect(token.timeUntilExpirySeconds()).toBe(0);
});
it("should create a token from JSON", () => {
const json = {
token: sendId,
expiresAt: expiresAt,
};
const token = SendAccessToken.fromJson(json);
expect(token).toBeTruthy();
expect(token.isExpired()).toBe(false);
});
it("should create a token from SendAccessTokenResponse", () => {
const response = {
token: sendId,
expiresAt: expiresAt,
} as SendAccessTokenResponse;
const token = SendAccessToken.fromSendAccessTokenResponse(response);
expect(token).toBeTruthy();
expect(token.isExpired()).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { Jsonify } from "type-fest";
import { SendAccessTokenResponse } from "@bitwarden/sdk-internal";
export class SendAccessToken {
constructor(
/**
* The access token string
*/
readonly token: string,
/**
* The time (in milliseconds since the epoch) when the token expires
*/
readonly expiresAt: number,
) {}
/** Returns whether the send access token is expired or not
* Has a 5 second threshold to avoid race conditions with the token
* expiring in flight
*/
isExpired(threshold: number = 5_000): boolean {
return Date.now() >= this.expiresAt - threshold;
}
/** Returns how many full seconds remain until expiry. Returns 0 if expired. */
timeUntilExpirySeconds(): number {
return Math.max(0, Math.floor((this.expiresAt - Date.now()) / 1_000));
}
static fromJson(parsedJson: Jsonify<SendAccessToken>): SendAccessToken {
return new SendAccessToken(parsedJson.token, parsedJson.expiresAt);
}
/**
* Creates a SendAccessToken from a SendAccessTokenResponse.
* @param sendAccessTokenResponse The SDK response object containing the token and expiry information.
* @returns A new instance of SendAccessToken.
* note: we need to convert from the SDK response type to our internal type so that we can
* be sure it will serialize/deserialize correctly in state provider.
*/
static fromSendAccessTokenResponse(
sendAccessTokenResponse: SendAccessTokenResponse,
): SendAccessToken {
return new SendAccessToken(sendAccessTokenResponse.token, sendAccessTokenResponse.expiresAt);
}
}

View File

@@ -0,0 +1,678 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import {
SendAccessTokenApiErrorResponse,
SendAccessTokenError,
SendAccessTokenInvalidGrantError,
SendAccessTokenInvalidRequestError,
SendAccessTokenResponse,
UnexpectedIdentityError,
} from "@bitwarden/sdk-internal";
import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/state-test-utils";
import {
SendHashedPassword,
SendPasswordKeyMaterial,
SendPasswordService,
} from "../../../key-management/sends";
import { Utils } from "../../../platform/misc/utils";
import { MockSdkService } from "../../../platform/spec/mock-sdk.service";
import { SendAccessToken } from "../models/send-access-token";
import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type";
import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type";
import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type";
import { SendOtp } from "../types/send-otp.type";
import { DefaultSendTokenService } from "./default-send-token.service";
import { SEND_ACCESS_TOKEN_DICT } from "./send-access-token-dict.state";
describe("SendTokenService", () => {
let service: DefaultSendTokenService;
// Deps
let sdkService: MockSdkService;
let globalStateProvider: FakeGlobalStateProvider;
let sendPasswordService: MockProxy<SendPasswordService>;
beforeEach(() => {
globalStateProvider = new FakeGlobalStateProvider();
sdkService = new MockSdkService();
sendPasswordService = mock<SendPasswordService>();
service = new DefaultSendTokenService(globalStateProvider, sdkService, sendPasswordService);
});
it("instantiates", () => {
expect(service).toBeTruthy();
});
describe("Send access token retrieval tests", () => {
let sendAccessTokenDictGlobalState: FakeGlobalState<Record<string, SendAccessToken>>;
let sendAccessTokenResponse: SendAccessTokenResponse;
let sendId: string;
let sendAccessToken: SendAccessToken;
let token: string;
let tokenExpiresAt: number;
const EXPECTED_SERVER_KIND: GetSendAccessTokenError["kind"] = "expected_server";
const UNEXPECTED_SERVER_KIND: GetSendAccessTokenError["kind"] = "unexpected_server";
const INVALID_REQUEST_CODES: SendAccessTokenInvalidRequestError[] = [
"send_id_required",
"password_hash_b64_required",
"email_required",
"email_and_otp_required_otp_sent",
"unknown",
];
const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [
"send_id_invalid",
"password_hash_b64_invalid",
"email_invalid",
"otp_invalid",
"otp_generation_failed",
"unknown",
];
const CREDS = [
{ kind: "password", passwordHashB64: "h4sh" as SendHashedPasswordB64 },
{ kind: "email", email: "u@example.com" },
{ kind: "email_otp", email: "u@example.com", otp: "123456" as SendOtp },
] as const satisfies readonly SendAccessDomainCredentials[];
type SendAccessTokenApiErrorResponseErrorCode = SendAccessTokenApiErrorResponse["error"];
type SimpleErrorType = Exclude<
SendAccessTokenApiErrorResponseErrorCode,
"invalid_request" | "invalid_grant"
>;
// Extract out simple error types which don't have complex send_access_error_types to handle.
const SIMPLE_ERROR_TYPES = [
"invalid_client",
"unauthorized_client",
"unsupported_grant_type",
"invalid_scope",
"invalid_target",
] as const satisfies readonly SimpleErrorType[];
beforeEach(() => {
sendId = "sendId";
token = "sendAccessToken";
tokenExpiresAt = Date.now() + 1000 * 60 * 5; // 5 minutes from now
sendAccessTokenResponse = {
token: token,
expiresAt: tokenExpiresAt,
};
sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(sendAccessTokenResponse);
sendAccessTokenDictGlobalState = globalStateProvider.getFake(SEND_ACCESS_TOKEN_DICT);
// Ensure the state is empty before each test
sendAccessTokenDictGlobalState.stateSubject.next({});
});
describe("tryGetSendAccessToken$", () => {
it("returns the send access token from session storage when token exists and isn't expired", async () => {
// Arrange
// Store the send access token in the global state
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken });
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual(sendAccessToken);
});
it("returns expired error and clears token from storage when token is expired", async () => {
// Arrange
const oldDate = new Date("2025-01-01");
const expiredSendAccessToken = new SendAccessToken(token, oldDate.getTime());
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: expiredSendAccessToken });
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).not.toBeInstanceOf(SendAccessToken);
expect(result).toStrictEqual({ kind: "expired" });
// assert that we removed the expired token from storage.
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).not.toHaveProperty(sendId);
});
it("calls to get a new token if none is found in storage and stores the retrieved token in session storage", async () => {
// Arrange
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockResolvedValue(sendAccessTokenResponse);
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toBeInstanceOf(SendAccessToken);
expect(result).toEqual(sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
describe("handles expected invalid_request scenarios appropriately", () => {
it.each(INVALID_REQUEST_CODES)(
"surfaces %s as an expected invalid_request error",
async (code) => {
// Arrange
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_request",
error_description: code,
send_access_error_type: code,
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: EXPECTED_SERVER_KIND,
error: sendAccessTokenApiErrorResponse,
});
},
);
it("handles bare expected invalid_request scenario appropriately", async () => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_request",
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: EXPECTED_SERVER_KIND,
error: sendAccessTokenApiErrorResponse,
});
});
});
it.each(SIMPLE_ERROR_TYPES)("handles expected %s error appropriately", async (errorType) => {
const api: SendAccessTokenApiErrorResponse = {
error: errorType,
error_description: `The ${errorType} error occurred`,
};
mockSdkRejectWith({ kind: "expected", data: api });
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
});
it.each(SIMPLE_ERROR_TYPES)(
"handles expected %s bare error appropriately",
async (errorType) => {
const api: SendAccessTokenApiErrorResponse = { error: errorType };
mockSdkRejectWith({ kind: "expected", data: api });
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
},
);
describe("handles expected invalid_grant scenarios appropriately", () => {
it.each(INVALID_GRANT_CODES)(
"surfaces %s as an expected invalid_grant error",
async (code) => {
// Arrange
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_grant",
error_description: code,
send_access_error_type: code,
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: EXPECTED_SERVER_KIND,
error: sendAccessTokenApiErrorResponse,
});
},
);
it("handles bare expected invalid_grant scenario appropriately", async () => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_grant",
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: EXPECTED_SERVER_KIND,
error: sendAccessTokenApiErrorResponse,
});
});
});
it("surfaces unexpected errors as unexpected_server error", async () => {
// Arrange
const unexpectedIdentityError: UnexpectedIdentityError = "unexpected error occurred";
mockSdkRejectWith({
kind: "unexpected",
data: unexpectedIdentityError,
});
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: UNEXPECTED_SERVER_KIND,
error: unexpectedIdentityError,
});
});
it("surfaces an unknown error as an unknown error", async () => {
// Arrange
const unknownError = "unknown error occurred";
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockRejectedValue(new Error(unknownError));
// Act
const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId));
// Assert
expect(result).toEqual({
kind: "unknown",
error: unknownError,
});
});
describe("getSendAccessTokenFromStorage", () => {
it("returns undefined if no token is found in storage", async () => {
const result = await (service as any).getSendAccessTokenFromStorage("nonexistentSendId");
expect(result).toBeUndefined();
});
it("returns the token if found in storage", async () => {
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken });
const result = await (service as any).getSendAccessTokenFromStorage(sendId);
expect(result).toEqual(sendAccessToken);
});
it("returns undefined if the global state isn't initialized yet", async () => {
sendAccessTokenDictGlobalState.stateSubject.next(null);
const result = await (service as any).getSendAccessTokenFromStorage(sendId);
expect(result).toBeUndefined();
});
});
describe("setSendAccessTokenInStorage", () => {
it("stores the token in storage", async () => {
await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
it("initializes the dictionary if it isn't already", async () => {
sendAccessTokenDictGlobalState.stateSubject.next(null);
await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
it("merges with existing tokens in storage", async () => {
const anotherSendId = "anotherSendId";
const anotherSendAccessToken = new SendAccessToken(
"anotherToken",
Date.now() + 1000 * 60,
);
sendAccessTokenDictGlobalState.stateSubject.next({
[anotherSendId]: anotherSendAccessToken,
});
await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
expect(sendAccessTokenDict).toHaveProperty(anotherSendId, anotherSendAccessToken);
});
});
});
describe("getSendAccessToken$", () => {
it("returns a send access token for a password protected send when given valid password credentials", async () => {
// Arrange
const sendPasswordCredentials: SendAccessDomainCredentials = {
kind: "password",
passwordHashB64: "testPassword" as SendHashedPasswordB64,
};
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockResolvedValue(sendAccessTokenResponse);
// Act
const result = await firstValueFrom(
service.getSendAccessToken$(sendId, sendPasswordCredentials),
);
// Assert
expect(result).toEqual(sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
// Note: we deliberately aren't testing the "success" scenario of passing
// just SendEmailCredentials as that will never return a send access token on it's own.
it("returns a send access token for a email + otp protected send when given valid email and otp", async () => {
// Arrange
const sendEmailOtpCredentials: SendAccessDomainCredentials = {
kind: "email_otp",
email: "test@example.com",
otp: "123456" as SendOtp,
};
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockResolvedValue(sendAccessTokenResponse);
// Act
const result = await firstValueFrom(
service.getSendAccessToken$(sendId, sendEmailOtpCredentials),
);
// Assert
expect(result).toEqual(sendAccessToken);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken);
});
describe.each(CREDS.map((c) => [c.kind, c] as const))(
"scenarios with %s credentials",
(_label, creds) => {
it.each(INVALID_REQUEST_CODES)(
"handles expected invalid_request.%s scenario appropriately",
async (code) => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_request",
error_description: code,
send_access_error_type: code,
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
const result = await firstValueFrom(service.getSendAccessToken$("abc123", creds));
expect(result).toEqual({
kind: "expected_server",
error: sendAccessTokenApiErrorResponse,
});
},
);
it("handles expected invalid_request scenario appropriately", async () => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_request",
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
// Assert
expect(result).toEqual({
kind: "expected_server",
error: sendAccessTokenApiErrorResponse,
});
});
it.each(INVALID_GRANT_CODES)(
"handles expected invalid_grant.%s scenario appropriately",
async (code) => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_grant",
error_description: code,
send_access_error_type: code,
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
const result = await firstValueFrom(service.getSendAccessToken$("abc123", creds));
expect(result).toEqual({
kind: "expected_server",
error: sendAccessTokenApiErrorResponse,
});
},
);
it("handles expected invalid_grant scenario appropriately", async () => {
const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = {
error: "invalid_grant",
};
mockSdkRejectWith({
kind: "expected",
data: sendAccessTokenApiErrorResponse,
});
// Act
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
// Assert
expect(result).toEqual({
kind: "expected_server",
error: sendAccessTokenApiErrorResponse,
});
});
it.each(SIMPLE_ERROR_TYPES)(
"handles expected %s error appropriately",
async (errorType) => {
const api: SendAccessTokenApiErrorResponse = {
error: errorType,
error_description: `The ${errorType} error occurred`,
};
mockSdkRejectWith({ kind: "expected", data: api });
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
},
);
it.each(SIMPLE_ERROR_TYPES)(
"handles expected %s bare error appropriately",
async (errorType) => {
const api: SendAccessTokenApiErrorResponse = { error: errorType };
mockSdkRejectWith({ kind: "expected", data: api });
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api });
},
);
it("surfaces unexpected errors as unexpected_server error", async () => {
// Arrange
const unexpectedIdentityError: UnexpectedIdentityError = "unexpected error occurred";
mockSdkRejectWith({
kind: "unexpected",
data: unexpectedIdentityError,
});
// Act
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
// Assert
expect(result).toEqual({
kind: UNEXPECTED_SERVER_KIND,
error: unexpectedIdentityError,
});
});
it("surfaces an unknown error as an unknown error", async () => {
// Arrange
const unknownError = "unknown error occurred";
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockRejectedValue(new Error(unknownError));
// Act
const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds));
// Assert
expect(result).toEqual({
kind: "unknown",
error: unknownError,
});
});
},
);
it("errors if passwordHashB64 is missing for password credentials", async () => {
const creds: SendAccessDomainCredentials = {
kind: "password",
passwordHashB64: "" as SendHashedPasswordB64,
};
await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow(
"passwordHashB64 must be provided for password credentials.",
);
});
it("errors if email is missing for email credentials", async () => {
const creds: SendAccessDomainCredentials = {
kind: "email",
email: "",
};
await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow(
"email must be provided for email credentials.",
);
});
it("errors if email or otp is missing for email_otp credentials", async () => {
const creds: SendAccessDomainCredentials = {
kind: "email_otp",
email: "",
otp: "" as SendOtp,
};
await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow(
"email and otp must be provided for email_otp credentials.",
);
});
});
describe("invalidateSendAccessToken", () => {
it("removes a send access token from storage", async () => {
// Arrange
sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken });
// Act
await service.invalidateSendAccessToken(sendId);
const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$);
// Assert
expect(sendAccessTokenDict).not.toHaveProperty(sendId);
});
});
});
describe("hashSendPassword", () => {
test.each(["", null, undefined])("rejects if password is %p", async (pwd) => {
await expect(service.hashSendPassword(pwd as any, "keyMaterialUrlB64")).rejects.toThrow(
"Password must be provided.",
);
});
test.each(["", null, undefined])(
"rejects if keyMaterialUrlB64 is %p",
async (keyMaterialUrlB64) => {
await expect(
service.hashSendPassword("password", keyMaterialUrlB64 as any),
).rejects.toThrow("KeyMaterialUrlB64 must be provided.");
},
);
it("correctly hashes the password", async () => {
// Arrange
const password = "testPassword";
const keyMaterialUrlB64 = "testKeyMaterialUrlB64";
const keyMaterialArray = new Uint8Array([1, 2, 3]) as SendPasswordKeyMaterial;
const hashedPasswordArray = new Uint8Array([4, 5, 6]) as SendHashedPassword;
const sendHashedPasswordB64 = "hashedPasswordB64" as SendHashedPasswordB64;
const utilsFromUrlB64ToArraySpy = jest
.spyOn(Utils, "fromUrlB64ToArray")
.mockReturnValue(keyMaterialArray);
sendPasswordService.hashPassword.mockResolvedValue(hashedPasswordArray);
const utilsFromBufferToB64Spy = jest
.spyOn(Utils, "fromBufferToB64")
.mockReturnValue(sendHashedPasswordB64);
// Act
const result = await service.hashSendPassword(password, keyMaterialUrlB64);
// Assert
expect(sendPasswordService.hashPassword).toHaveBeenCalledWith(password, keyMaterialArray);
expect(utilsFromUrlB64ToArraySpy).toHaveBeenCalledWith(keyMaterialUrlB64);
expect(utilsFromBufferToB64Spy).toHaveBeenCalledWith(hashedPasswordArray);
expect(result).toBe(sendHashedPasswordB64);
});
});
function mockSdkRejectWith(sendAccessTokenError: SendAccessTokenError) {
sdkService.client.auth
.mockDeep()
.send_access.mockDeep()
.request_send_access_token.mockRejectedValue(sendAccessTokenError);
}
});

View File

@@ -0,0 +1,316 @@
import { Observable, defer, firstValueFrom, from } from "rxjs";
import {
BitwardenClient,
SendAccessCredentials,
SendAccessTokenError,
SendAccessTokenRequest,
SendAccessTokenResponse,
} from "@bitwarden/sdk-internal";
import { GlobalState, GlobalStateProvider } from "@bitwarden/state";
import { SendPasswordService } from "../../../key-management/sends/abstractions/send-password.service";
import {
SendHashedPassword,
SendPasswordKeyMaterial,
} from "../../../key-management/sends/types/send-hashed-password.type";
import { SdkService } from "../../../platform/abstractions/sdk/sdk.service";
import { Utils } from "../../../platform/misc/utils";
import { SendTokenService as SendTokenServiceAbstraction } from "../abstractions/send-token.service";
import { SendAccessToken } from "../models/send-access-token";
import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type";
import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type";
import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type";
import { TryGetSendAccessTokenError } from "../types/try-get-send-access-token-error.type";
import { SEND_ACCESS_TOKEN_DICT } from "./send-access-token-dict.state";
export class DefaultSendTokenService implements SendTokenServiceAbstraction {
private sendAccessTokenDictGlobalState: GlobalState<Record<string, SendAccessToken>> | undefined;
constructor(
private globalStateProvider: GlobalStateProvider,
private sdkService: SdkService,
private sendPasswordService: SendPasswordService,
) {
this.initializeState();
}
private initializeState(): void {
this.sendAccessTokenDictGlobalState = this.globalStateProvider.get(SEND_ACCESS_TOKEN_DICT);
}
tryGetSendAccessToken$(sendId: string): Observable<SendAccessToken | TryGetSendAccessTokenError> {
// Defer the execution to ensure that a cold observable is returned.
return defer(() => from(this._tryGetSendAccessToken(sendId)));
}
private async _tryGetSendAccessToken(
sendId: string,
): Promise<SendAccessToken | TryGetSendAccessTokenError> {
// Validate the sendId is a non-empty string.
this.validateSendId(sendId);
// Check in storage for the access token for the given sendId.
const sendAccessTokenFromStorage = await this.getSendAccessTokenFromStorage(sendId);
if (sendAccessTokenFromStorage != null) {
// If it is expired, we clear the token from storage and return the expired error
if (sendAccessTokenFromStorage.isExpired()) {
await this.clearSendAccessTokenFromStorage(sendId);
return { kind: "expired" };
} else {
// If it is not expired, we return it
return sendAccessTokenFromStorage;
}
}
// If we don't have a token in storage, we can try to request a new token from the server.
const request: SendAccessTokenRequest = {
sendId: sendId,
};
const anonSdkClient: BitwardenClient = await firstValueFrom(this.sdkService.client$);
try {
const result: SendAccessTokenResponse = await anonSdkClient
.auth()
.send_access()
.request_send_access_token(request);
// Convert from SDK shape to SendAccessToken so it can be serialized into & out of state provider
const sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(result);
// If we get a token back, we need to store it in the global state.
await this.setSendAccessTokenInStorage(sendId, sendAccessToken);
return sendAccessToken;
} catch (error: unknown) {
return this.normalizeSendAccessTokenError(error);
}
}
getSendAccessToken$(
sendId: string,
sendCredentials: SendAccessDomainCredentials,
): Observable<SendAccessToken | GetSendAccessTokenError> {
// Defer the execution to ensure that a cold observable is returned.
return defer(() => from(this._getSendAccessToken(sendId, sendCredentials)));
}
private async _getSendAccessToken(
sendId: string,
sendAccessCredentials: SendAccessDomainCredentials,
): Promise<SendAccessToken | GetSendAccessTokenError> {
// Validate inputs to account for non-strict TS call sites.
this.validateCredentialsRequest(sendId, sendAccessCredentials);
// Convert inputs to SDK request shape
const request: SendAccessTokenRequest = {
sendId: sendId,
sendAccessCredentials: this.convertDomainCredentialsToSdkCredentials(sendAccessCredentials),
};
const anonSdkClient: BitwardenClient = await firstValueFrom(this.sdkService.client$);
try {
const result: SendAccessTokenResponse = await anonSdkClient
.auth()
.send_access()
.request_send_access_token(request);
// Convert from SDK interface to SendAccessToken class so it can be serialized into & out of state provider
const sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(result);
// Any time we get a token from the server, we need to store it in the global state.
await this.setSendAccessTokenInStorage(sendId, sendAccessToken);
return sendAccessToken;
} catch (error: unknown) {
return this.normalizeSendAccessTokenError(error);
}
}
async invalidateSendAccessToken(sendId: string): Promise<void> {
await this.clearSendAccessTokenFromStorage(sendId);
}
async hashSendPassword(
password: string,
keyMaterialUrlB64: string,
): Promise<SendHashedPasswordB64> {
// Validate the password and key material
if (password == null || password.trim() === "") {
throw new Error("Password must be provided.");
}
if (keyMaterialUrlB64 == null || keyMaterialUrlB64.trim() === "") {
throw new Error("KeyMaterialUrlB64 must be provided.");
}
// Convert the base64 URL encoded key material to a Uint8Array
const keyMaterialUrlB64Array = Utils.fromUrlB64ToArray(
keyMaterialUrlB64,
) as SendPasswordKeyMaterial;
const sendHashedPasswordArray: SendHashedPassword = await this.sendPasswordService.hashPassword(
password,
keyMaterialUrlB64Array,
);
// Convert the Uint8Array to a base64 encoded string which is required
// for the server to be able to compare the password hash.
const sendHashedPasswordB64 = Utils.fromBufferToB64(
sendHashedPasswordArray,
) as SendHashedPasswordB64;
return sendHashedPasswordB64;
}
private async getSendAccessTokenFromStorage(
sendId: string,
): Promise<SendAccessToken | undefined> {
if (this.sendAccessTokenDictGlobalState != null) {
const sendAccessTokenDict = await firstValueFrom(this.sendAccessTokenDictGlobalState.state$);
return sendAccessTokenDict?.[sendId];
}
return undefined;
}
private async setSendAccessTokenInStorage(
sendId: string,
sendAccessToken: SendAccessToken,
): Promise<void> {
if (this.sendAccessTokenDictGlobalState != null) {
await this.sendAccessTokenDictGlobalState.update(
(sendAccessTokenDict) => {
sendAccessTokenDict ??= {}; // Initialize if undefined
sendAccessTokenDict[sendId] = sendAccessToken;
return sendAccessTokenDict;
},
{
// only update if the value is different (to avoid unnecessary writes)
shouldUpdate: (prevDict) => {
const prevSendAccessToken = prevDict?.[sendId];
return (
prevSendAccessToken?.token !== sendAccessToken.token ||
prevSendAccessToken?.expiresAt !== sendAccessToken.expiresAt
);
},
},
);
}
}
private async clearSendAccessTokenFromStorage(sendId: string): Promise<void> {
if (this.sendAccessTokenDictGlobalState != null) {
await this.sendAccessTokenDictGlobalState.update(
(sendAccessTokenDict) => {
if (!sendAccessTokenDict) {
// If the dict is empty or undefined, there's nothing to clear
return sendAccessTokenDict;
}
if (sendAccessTokenDict[sendId] == null) {
// If the specific sendId does not exist, nothing to clear
return sendAccessTokenDict;
}
// Destructure to omit the specific sendId and get new reference for the rest of the dict for an immutable update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [sendId]: _, ...rest } = sendAccessTokenDict;
return rest;
},
{
// only update if the value is defined (to avoid unnecessary writes)
shouldUpdate: (prevDict) => prevDict?.[sendId] != null,
},
);
}
}
/**
* Normalizes an error from the SDK send access token request process.
* @param e The error to normalize.
* @returns A normalized GetSendAccessTokenError.
*/
private normalizeSendAccessTokenError(e: unknown): GetSendAccessTokenError {
if (this.isSendAccessTokenError(e)) {
if (e.kind === "unexpected") {
return { kind: "unexpected_server", error: e.data };
}
return { kind: "expected_server", error: e.data };
}
if (e instanceof Error) {
return { kind: "unknown", error: e.message };
}
try {
return { kind: "unknown", error: JSON.stringify(e) };
} catch {
return { kind: "unknown", error: "error cannot be stringified" };
}
}
private isSendAccessTokenError(e: unknown): e is SendAccessTokenError {
return (
typeof e === "object" &&
e !== null &&
"kind" in e &&
(e.kind === "expected" || e.kind === "unexpected")
);
}
private validateSendId(sendId: string): void {
if (sendId == null || sendId.trim() === "") {
throw new Error("sendId must be provided.");
}
}
private validateCredentialsRequest(
sendId: string,
sendAccessCredentials: SendAccessDomainCredentials,
): void {
this.validateSendId(sendId);
if (sendAccessCredentials == null) {
throw new Error("sendAccessCredentials must be provided.");
}
if (sendAccessCredentials.kind === "password" && !sendAccessCredentials.passwordHashB64) {
throw new Error("passwordHashB64 must be provided for password credentials.");
}
if (sendAccessCredentials.kind === "email" && !sendAccessCredentials.email) {
throw new Error("email must be provided for email credentials.");
}
if (
sendAccessCredentials.kind === "email_otp" &&
(!sendAccessCredentials.email || !sendAccessCredentials.otp)
) {
throw new Error("email and otp must be provided for email_otp credentials.");
}
}
private convertDomainCredentialsToSdkCredentials(
sendAccessCredentials: SendAccessDomainCredentials,
): SendAccessCredentials {
switch (sendAccessCredentials.kind) {
case "password":
return {
passwordHashB64: sendAccessCredentials.passwordHashB64,
};
case "email":
return {
email: sendAccessCredentials.email,
};
case "email_otp":
return {
email: sendAccessCredentials.email,
otp: sendAccessCredentials.otp,
};
}
}
}

View File

@@ -0,0 +1 @@
export * from "./default-send-token.service";

View File

@@ -0,0 +1,15 @@
import { Jsonify } from "type-fest";
import { KeyDefinition, SEND_ACCESS_DISK } from "@bitwarden/state";
import { SendAccessToken } from "../models/send-access-token";
export const SEND_ACCESS_TOKEN_DICT = KeyDefinition.record<SendAccessToken, string>(
SEND_ACCESS_DISK,
"accessTokenDict",
{
deserializer: (sendAccessTokenJson: Jsonify<SendAccessToken>) => {
return SendAccessToken.fromJson(sendAccessTokenJson);
},
},
);

View File

@@ -0,0 +1,12 @@
import { UnexpectedIdentityError, SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
/**
* Represents the possible errors that can occur when retrieving a SendAccessToken.
* Note: for expected_server errors, see invalid-request-errors.type.ts and
* invalid-grant-errors.type.ts for type guards that identify specific
* SendAccessTokenApiErrorResponse errors
*/
export type GetSendAccessTokenError =
| { kind: "unexpected_server"; error: UnexpectedIdentityError }
| { kind: "expected_server"; error: SendAccessTokenApiErrorResponse }
| { kind: "unknown"; error: string };

View File

@@ -0,0 +1,7 @@
export * from "./try-get-send-access-token-error.type";
export * from "./send-otp.type";
export * from "./send-hashed-password-b64.type";
export * from "./send-access-domain-credentials.type";
export * from "./invalid-request-errors.type";
export * from "./invalid-grant-errors.type";
export * from "./get-send-access-token-error.type";

View File

@@ -0,0 +1,62 @@
import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
export type InvalidGrant = Extract<SendAccessTokenApiErrorResponse, { error: "invalid_grant" }>;
export function isInvalidGrant(e: SendAccessTokenApiErrorResponse): e is InvalidGrant {
return e.error === "invalid_grant";
}
export type BareInvalidGrant = Extract<
SendAccessTokenApiErrorResponse,
{ error: "invalid_grant" }
> & { send_access_error_type?: undefined };
export function isBareInvalidGrant(e: SendAccessTokenApiErrorResponse): e is BareInvalidGrant {
return e.error === "invalid_grant" && e.send_access_error_type === undefined;
}
export type SendIdInvalid = InvalidGrant & {
send_access_error_type: "send_id_invalid";
};
export function sendIdInvalid(e: SendAccessTokenApiErrorResponse): e is SendIdInvalid {
return e.error === "invalid_grant" && e.send_access_error_type === "send_id_invalid";
}
export type PasswordHashB64Invalid = InvalidGrant & {
send_access_error_type: "password_hash_b64_invalid";
};
export function passwordHashB64Invalid(
e: SendAccessTokenApiErrorResponse,
): e is PasswordHashB64Invalid {
return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid";
}
export type EmailInvalid = InvalidGrant & {
send_access_error_type: "email_invalid";
};
export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid {
return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid";
}
export type OtpInvalid = InvalidGrant & {
send_access_error_type: "otp_invalid";
};
export function otpInvalid(e: SendAccessTokenApiErrorResponse): e is OtpInvalid {
return e.error === "invalid_grant" && e.send_access_error_type === "otp_invalid";
}
export type OtpGenerationFailed = InvalidGrant & {
send_access_error_type: "otp_generation_failed";
};
export function otpGenerationFailed(e: SendAccessTokenApiErrorResponse): e is OtpGenerationFailed {
return e.error === "invalid_grant" && e.send_access_error_type === "otp_generation_failed";
}
export type UnknownInvalidGrant = InvalidGrant & {
send_access_error_type: "unknown";
};
export function isUnknownInvalidGrant(
e: SendAccessTokenApiErrorResponse,
): e is UnknownInvalidGrant {
return e.error === "invalid_grant" && e.send_access_error_type === "unknown";
}

View File

@@ -0,0 +1,62 @@
import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
export type InvalidRequest = Extract<SendAccessTokenApiErrorResponse, { error: "invalid_request" }>;
export function isInvalidRequest(e: SendAccessTokenApiErrorResponse): e is InvalidRequest {
return e.error === "invalid_request";
}
export type BareInvalidRequest = Extract<
SendAccessTokenApiErrorResponse,
{ error: "invalid_request" }
> & { send_access_error_type?: undefined };
export function isBareInvalidRequest(e: SendAccessTokenApiErrorResponse): e is BareInvalidRequest {
return e.error === "invalid_request" && e.send_access_error_type === undefined;
}
export type SendIdRequired = InvalidRequest & {
send_access_error_type: "send_id_required";
};
export function sendIdRequired(e: SendAccessTokenApiErrorResponse): e is SendIdRequired {
return e.error === "invalid_request" && e.send_access_error_type === "send_id_required";
}
export type PasswordHashB64Required = InvalidRequest & {
send_access_error_type: "password_hash_b64_required";
};
export function passwordHashB64Required(
e: SendAccessTokenApiErrorResponse,
): e is PasswordHashB64Required {
return e.error === "invalid_request" && e.send_access_error_type === "password_hash_b64_required";
}
export type EmailRequired = InvalidRequest & { send_access_error_type: "email_required" };
export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailRequired {
return e.error === "invalid_request" && e.send_access_error_type === "email_required";
}
export type EmailAndOtpRequiredEmailSent = InvalidRequest & {
send_access_error_type: "email_and_otp_required_otp_sent";
};
export function emailAndOtpRequiredEmailSent(
e: SendAccessTokenApiErrorResponse,
): e is EmailAndOtpRequiredEmailSent {
return (
e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent"
);
}
export type UnknownInvalidRequest = InvalidRequest & {
send_access_error_type: "unknown";
};
export function isUnknownInvalidRequest(
e: SendAccessTokenApiErrorResponse,
): e is UnknownInvalidRequest {
return e.error === "invalid_request" && e.send_access_error_type === "unknown";
}

View File

@@ -0,0 +1,11 @@
import { SendHashedPasswordB64 } from "./send-hashed-password-b64.type";
import { SendOtp } from "./send-otp.type";
/**
* The domain facing send access credentials
* Will be internally mapped to the SDK types
*/
export type SendAccessDomainCredentials =
| { kind: "password"; passwordHashB64: SendHashedPasswordB64 }
| { kind: "email"; email: string }
| { kind: "email_otp"; email: string; otp: SendOtp };

View File

@@ -0,0 +1,3 @@
import { Opaque } from "type-fest";
export type SendHashedPasswordB64 = Opaque<string, "SendHashedPasswordB64">;

View File

@@ -0,0 +1,3 @@
import { Opaque } from "type-fest";
export type SendOtp = Opaque<string, "SendOtp">;

View File

@@ -0,0 +1,7 @@
import { GetSendAccessTokenError } from "./get-send-access-token-error.type";
/**
* Represents the possible errors that can occur when trying to retrieve a SendAccessToken by
* just a sendId. Extends {@link GetSendAccessTokenError}.
*/
export type TryGetSendAccessTokenError = { kind: "expired" } | GetSendAccessTokenError;

View File

@@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
import { PasswordRequest } from "../../models/request/password.request";
import { SetPasswordRequest } from "../../models/request/set-password.request";
@@ -42,8 +42,7 @@ describe("MasterPasswordApiService", () => {
publicKey: "publicKey",
encryptedPrivateKey: "encryptedPrivateKey",
},
KdfType.PBKDF2_SHA256,
600_000,
new PBKDF2KdfConfig(600_000),
);
// Act

View File

@@ -1,9 +1,13 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
CODE_VERIFIER,
GLOBAL_ORGANIZATION_SSO_IDENTIFIER,
SSO_EMAIL,
SSO_REQUIRED_CACHE,
SSO_STATE,
SsoLoginService,
USER_ORGANIZATION_SSO_IDENTIFIER,
@@ -18,8 +22,9 @@ describe("SSOLoginService ", () => {
let sut: SsoLoginService;
let accountService: FakeAccountService;
let mockSingleUserStateProvider: FakeStateProvider;
let mockStateProvider: FakeStateProvider;
let mockLogService: MockProxy<LogService>;
let mockPolicyService: MockProxy<PolicyService>;
let userId: UserId;
beforeEach(() => {
@@ -27,10 +32,11 @@ describe("SSOLoginService ", () => {
userId = Utils.newGuid() as UserId;
accountService = mockAccountServiceWith(userId);
mockSingleUserStateProvider = new FakeStateProvider(accountService);
mockStateProvider = new FakeStateProvider(accountService);
mockLogService = mock<LogService>();
mockPolicyService = mock<PolicyService>();
sut = new SsoLoginService(mockSingleUserStateProvider, mockLogService);
sut = new SsoLoginService(mockStateProvider, mockLogService, mockPolicyService);
});
it("instantiates", () => {
@@ -40,7 +46,7 @@ describe("SSOLoginService ", () => {
it("gets and sets code verifier", async () => {
const codeVerifier = "test-code-verifier";
await sut.setCodeVerifier(codeVerifier);
mockSingleUserStateProvider.getGlobal(CODE_VERIFIER);
mockStateProvider.getGlobal(CODE_VERIFIER);
const result = await sut.getCodeVerifier();
expect(result).toBe(codeVerifier);
@@ -49,7 +55,7 @@ describe("SSOLoginService ", () => {
it("gets and sets SSO state", async () => {
const ssoState = "test-sso-state";
await sut.setSsoState(ssoState);
mockSingleUserStateProvider.getGlobal(SSO_STATE);
mockStateProvider.getGlobal(SSO_STATE);
const result = await sut.getSsoState();
expect(result).toBe(ssoState);
@@ -58,7 +64,7 @@ describe("SSOLoginService ", () => {
it("gets and sets organization SSO identifier", async () => {
const orgIdentifier = "test-org-identifier";
await sut.setOrganizationSsoIdentifier(orgIdentifier);
mockSingleUserStateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
mockStateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
const result = await sut.getOrganizationSsoIdentifier();
expect(result).toBe(orgIdentifier);
@@ -67,7 +73,7 @@ describe("SSOLoginService ", () => {
it("gets and sets SSO email", async () => {
const email = "test@example.com";
await sut.setSsoEmail(email);
mockSingleUserStateProvider.getGlobal(SSO_EMAIL);
mockStateProvider.getGlobal(SSO_EMAIL);
const result = await sut.getSsoEmail();
expect(result).toBe(email);
@@ -77,7 +83,7 @@ describe("SSOLoginService ", () => {
const userId = Utils.newGuid() as UserId;
const orgIdentifier = "test-active-org-identifier";
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, userId);
mockSingleUserStateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
mockStateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
const result = await sut.getActiveUserOrganizationSsoIdentifier(userId);
expect(result).toBe(orgIdentifier);
@@ -91,4 +97,153 @@ describe("SSOLoginService ", () => {
"Tried to set a user organization sso identifier with an undefined user id.",
);
});
describe("updateSsoRequiredCache()", () => {
it("should add email to cache when SSO is required", async () => {
const email = "test@example.com";
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([]);
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(email);
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
await sut.updateSsoRequiredCache(email, userId);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).toHaveBeenCalledWith([email.toLowerCase()]);
});
it("should add email to existing cache when SSO is required and email is not already present", async () => {
const existingEmail = "existing@example.com";
const newEmail = "new@example.com";
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([existingEmail]);
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(newEmail);
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
await sut.updateSsoRequiredCache(newEmail, userId);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).toHaveBeenCalledWith([existingEmail, newEmail.toLowerCase()]);
});
it("should not add duplicate email to cache when SSO is required", async () => {
const duplicateEmail = "duplicate@example.com";
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([duplicateEmail]);
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(duplicateEmail);
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
await sut.updateSsoRequiredCache(duplicateEmail, userId);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).not.toHaveBeenCalled();
});
it("should initialize new cache with email when SSO is required and no cache exists", async () => {
const email = "test@example.com";
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next(null);
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(email);
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
await sut.updateSsoRequiredCache(email, userId);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).toHaveBeenCalledWith([email.toLowerCase()]);
});
it("should remove email from cache when SSO is not required", async () => {
const emailToRemove = "remove@example.com";
const remainingEmail = "keep@example.com";
mockStateProvider.global
.getFake(SSO_REQUIRED_CACHE)
.stateSubject.next([emailToRemove, remainingEmail]);
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(emailToRemove);
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(false));
await sut.updateSsoRequiredCache(emailToRemove, userId);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).toHaveBeenCalledWith([remainingEmail]);
});
it("should not update cache when SSO is not required and email is not present", async () => {
const existingEmail = "existing@example.com";
const nonExistentEmail = "nonexistent@example.com";
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([existingEmail]);
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(nonExistentEmail);
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(false));
await sut.updateSsoRequiredCache(nonExistentEmail, userId);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).not.toHaveBeenCalled();
});
it("should check policy for correct PolicyType and userId", async () => {
const email = "test@example.com";
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([]);
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
await sut.updateSsoRequiredCache(email, userId);
expect(mockPolicyService.policyAppliesToUser$).toHaveBeenCalledWith(
PolicyType.RequireSso,
userId,
);
});
});
describe("removeFromSsoRequiredCacheIfPresent()", () => {
it("should remove email from cache when present", async () => {
const emailToRemove = "remove@example.com";
const remainingEmail = "keep@example.com";
mockStateProvider.global
.getFake(SSO_REQUIRED_CACHE)
.stateSubject.next([emailToRemove, remainingEmail]);
await sut.removeFromSsoRequiredCacheIfPresent(emailToRemove);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).toHaveBeenCalledWith([remainingEmail]);
});
it("should not update cache when email is not present", async () => {
const existingEmail = "existing@example.com";
const nonExistentEmail = "nonexistent@example.com";
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([existingEmail]);
await sut.removeFromSsoRequiredCacheIfPresent(nonExistentEmail);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).not.toHaveBeenCalled();
});
it("should not update cache when cache is already null", async () => {
const email = "test@example.com";
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next(null);
await sut.removeFromSsoRequiredCacheIfPresent(email);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).not.toHaveBeenCalled();
});
it("should result in an empty array when removing last email", async () => {
const email = "test@example.com";
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([email]);
await sut.removeFromSsoRequiredCacheIfPresent(email);
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
expect(cacheState.nextMock).toHaveBeenCalledWith([]);
});
});
});

View File

@@ -1,5 +1,7 @@
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map, Observable } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -8,6 +10,7 @@ import {
KeyDefinition,
SingleUserState,
SSO_DISK,
SSO_DISK_LOCAL,
StateProvider,
UserKeyDefinition,
} from "../../platform/state";
@@ -57,20 +60,35 @@ export const SSO_EMAIL = new KeyDefinition<string>(SSO_DISK, "ssoEmail", {
deserializer: (state) => state,
});
/**
* A cache list of user emails for whom the `PolicyType.RequireSso` policy is applied (that is, a list
* of users who are required to authenticate via SSO only). The cache lives on the current device only.
*/
export const SSO_REQUIRED_CACHE = new KeyDefinition<string[]>(SSO_DISK_LOCAL, "ssoRequiredCache", {
deserializer: (ssoRequiredCache) => ssoRequiredCache,
});
export class SsoLoginService implements SsoLoginServiceAbstraction {
private codeVerifierState: GlobalState<string>;
private ssoState: GlobalState<string>;
private orgSsoIdentifierState: GlobalState<string>;
private ssoEmailState: GlobalState<string>;
private ssoRequiredCacheState: GlobalState<string[]>;
ssoRequiredCache$: Observable<Set<string> | null>;
constructor(
private stateProvider: StateProvider,
private logService: LogService,
private policyService: PolicyService,
) {
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.ssoRequiredCacheState = this.stateProvider.getGlobal(SSO_REQUIRED_CACHE);
this.ssoRequiredCache$ = this.ssoRequiredCacheState.state$.pipe(map((cache) => new Set(cache)));
}
getCodeVerifier(): Promise<string | null> {
@@ -105,6 +123,10 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
await this.ssoEmailState.update((_) => email);
}
async clearSsoEmail(): Promise<void> {
await this.ssoEmailState.update((_) => null);
}
getActiveUserOrganizationSsoIdentifier(userId: UserId): Promise<string | null> {
return firstValueFrom(this.userOrgSsoIdentifierState(userId).state$);
}
@@ -125,4 +147,53 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
private userOrgSsoIdentifierState(userId: UserId): SingleUserState<string> {
return this.stateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
}
/**
* Add an email to the cached list of emails that must authenticate via SSO.
*/
private async addToSsoRequiredCache(email: string): Promise<void> {
await this.ssoRequiredCacheState.update(
(cache) => (cache == null ? [email] : [...cache, email]),
{
shouldUpdate: (cache) => {
if (cache == null) {
return true;
}
return !cache.includes(email);
},
},
);
}
async removeFromSsoRequiredCacheIfPresent(email: string): Promise<void> {
await this.ssoRequiredCacheState.update(
(cache) => cache?.filter((cachedEmail) => cachedEmail !== email) ?? cache,
{
shouldUpdate: (cache) => {
if (cache == null) {
return false;
}
return cache.includes(email);
},
},
);
}
async updateSsoRequiredCache(ssoLoginEmail: string, userId: UserId): Promise<void> {
const ssoRequired = await firstValueFrom(
this.policyService.policyAppliesToUser$(PolicyType.RequireSso, userId),
);
if (ssoRequired) {
await this.addToSsoRequiredCache(ssoLoginEmail.toLowerCase());
} else {
/**
* If user is not required to authenticate via SSO, remove email from the cache
* list (if it was on the list). This is necessary because the user may have been
* required to authenticate via SSO at some point in the past, but now their org
* no longer requires SSO authenticaiton.
*/
await this.removeFromSsoRequiredCacheIfPresent(ssoLoginEmail.toLowerCase());
}
}
}

View File

@@ -1,19 +1,12 @@
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.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 {
@@ -29,14 +22,10 @@ export abstract class BillingApiServiceAbstraction {
request: CreateClientOrganizationRequest,
): Promise<void>;
abstract createSetupIntent(paymentMethodType: PaymentMethodType): Promise<string>;
abstract getOrganizationBillingMetadata(
organizationId: string,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationPaymentMethod(organizationId: string): Promise<PaymentMethodResponse>;
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>;
@@ -49,44 +38,12 @@ export abstract class BillingApiServiceAbstraction {
abstract getProviderSubscription(providerId: string): Promise<ProviderSubscriptionResponse>;
abstract getProviderTaxInformation(providerId: string): Promise<TaxInfoResponse>;
abstract updateOrganizationPaymentMethod(
organizationId: string,
request: UpdatePaymentMethodRequest,
): Promise<void>;
abstract updateOrganizationTaxInformation(
organizationId: string,
request: ExpandedTaxInfoUpdateRequest,
): Promise<void>;
abstract updateProviderClientOrganization(
providerId: string,
organizationId: string,
request: UpdateClientOrganizationRequest,
): Promise<any>;
abstract updateProviderPaymentMethod(
providerId: string,
request: UpdatePaymentMethodRequest,
): Promise<void>;
abstract updateProviderTaxInformation(
providerId: string,
request: ExpandedTaxInfoUpdateRequest,
): Promise<void>;
abstract verifyOrganizationBankAccount(
organizationId: string,
request: VerifyBankAccountRequest,
): Promise<void>;
abstract verifyProviderBankAccount(
providerId: string,
request: VerifyBankAccountRequest,
): Promise<void>;
abstract restartSubscription(
organizationId: string,
request: OrganizationCreateRequest,

View File

@@ -3,7 +3,6 @@ import { UserId } from "@bitwarden/user-core";
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;
@@ -45,8 +44,6 @@ export type SubscriptionInformation = {
};
export abstract class OrganizationBillingServiceAbstraction {
abstract getPaymentSource(organizationId: string): Promise<PaymentSourceResponse>;
abstract purchaseSubscription(
subscription: SubscriptionInformation,
activeUserId: UserId,

View File

@@ -1,23 +0,0 @@
import { CountryListItem } from "../models/domain";
import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request";
import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request";
import { PreviewTaxAmountForOrganizationTrialRequest } from "../models/request/tax";
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>;
abstract previewTaxAmountForOrganizationTrial: (
request: PreviewTaxAmountForOrganizationTrialRequest,
) => Promise<number>;
}

View File

@@ -1,6 +0,0 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum BitwardenProductType {
PasswordManager = 0,
SecretsManager = 1,
}

View File

@@ -2,7 +2,6 @@ export * from "./payment-method-type.enum";
export * from "./plan-sponsorship-type.enum";
export * from "./plan-type.enum";
export * from "./transaction-type.enum";
export * from "./bitwarden-product-type.enum";
export * from "./product-tier-type.enum";
export * from "./product-type.enum";
export * from "./plan-interval.enum";

View File

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

View File

@@ -1,10 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { PaymentMethodType } from "../../enums";
import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request";
export class PaymentRequest extends ExpandedTaxInfoUpdateRequest {
paymentMethodType: PaymentMethodType;
paymentToken: string;
}

View File

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

@@ -1,55 +0,0 @@
import { PlanSponsorshipType, 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;
sponsoredPlan?: PlanSponsorshipType;
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 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export class TaxInfoUpdateRequest {
country: string;
postalCode: string;
}

View File

@@ -1 +0,0 @@
export * from "./preview-tax-amount-for-organization-trial.request";

View File

@@ -1,11 +0,0 @@
import { PlanType, ProductType } from "../../../enums";
export type PreviewTaxAmountForOrganizationTrialRequest = {
planType: PlanType;
productType: ProductType;
taxInformation: {
country: string;
postalCode: string;
taxId?: string;
};
};

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
export class VerifyBankAccountRequest {
descriptorCode: string;
constructor(descriptorCode: string) {
this.descriptorCode = descriptorCode;
}
}

View File

@@ -1,17 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BaseResponse } from "../../../models/response/base.response";
import { BillingSourceResponse } from "./billing.response";
export class BillingPaymentResponse extends BaseResponse {
balance: number;
paymentSource: BillingSourceResponse;
constructor(response: any) {
super(response);
this.balance = this.getResponseProperty("Balance");
const paymentSource = this.getResponseProperty("PaymentSource");
this.paymentSource = paymentSource == null ? null : new BillingSourceResponse(paymentSource);
}
}

View File

@@ -1,28 +0,0 @@
import { BaseResponse } from "../../../models/response/base.response";
import { PaymentSourceResponse } from "./payment-source.response";
import { TaxInfoResponse } from "./tax-info.response";
export class PaymentMethodResponse extends BaseResponse {
accountCredit: number;
paymentSource?: PaymentSourceResponse;
subscriptionStatus?: string;
taxInformation?: TaxInfoResponse;
constructor(response: any) {
super(response);
this.accountCredit = this.getResponseProperty("AccountCredit");
const paymentSource = this.getResponseProperty("PaymentSource");
if (paymentSource) {
this.paymentSource = new PaymentSourceResponse(paymentSource);
}
this.subscriptionStatus = this.getResponseProperty("SubscriptionStatus");
const taxInformation = this.getResponseProperty("TaxInformation");
if (taxInformation) {
this.taxInformation = new TaxInfoResponse(taxInformation);
}
}
}

View File

@@ -1,28 +0,0 @@
import { BaseResponse } from "../../../models/response/base.response";
export class TaxIdTypesResponse extends BaseResponse {
taxIdTypes: TaxIdTypeResponse[] = [];
constructor(response: any) {
super(response);
const taxIdTypes = this.getResponseProperty("TaxIdTypes");
if (taxIdTypes && taxIdTypes.length) {
this.taxIdTypes = taxIdTypes.map((t: any) => new TaxIdTypeResponse(t));
}
}
}
export class TaxIdTypeResponse extends BaseResponse {
code: string;
country: string;
description: string;
example: string;
constructor(response: any) {
super(response);
this.code = this.getResponseProperty("Code");
this.country = this.getResponseProperty("Country");
this.description = this.getResponseProperty("Description");
this.example = this.getResponseProperty("Example");
}
}

View File

@@ -1 +0,0 @@
export * from "./preview-tax-amount.response";

View File

@@ -1,11 +0,0 @@
import { BaseResponse } from "../../../../models/response/base.response";
export class PreviewTaxAmountResponse extends BaseResponse {
taxAmount: number;
constructor(response: any) {
super(response);
this.taxAmount = this.getResponseProperty("TaxAmount");
}
}

View File

@@ -1,23 +1,16 @@
// 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 { ApiService } from "../../abstractions/api.service";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
import { ListResponse } from "../../models/response/list.response";
import { BillingApiServiceAbstraction } from "../abstractions";
import { PaymentMethodType } from "../enums";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
import { ExpandedTaxInfoUpdateRequest } from "../models/request/expanded-tax-info-update.request";
import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.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 { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response";
import { PaymentMethodResponse } from "../models/response/payment-method.response";
import { PlanResponse } from "../models/response/plan.response";
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
@@ -54,21 +47,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
);
}
async createSetupIntent(type: PaymentMethodType) {
const getPath = () => {
switch (type) {
case PaymentMethodType.BankAccount: {
return "/setup-intent/bank-account";
}
case PaymentMethodType.Card: {
return "/setup-intent/card";
}
}
};
const response = await this.apiService.send("POST", getPath(), null, true, true);
return response as string;
}
async getOrganizationBillingMetadata(
organizationId: string,
): Promise<OrganizationBillingMetadataResponse> {
@@ -83,17 +61,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new OrganizationBillingMetadataResponse(r);
}
async getOrganizationPaymentMethod(organizationId: string): Promise<PaymentMethodResponse> {
const response = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/payment-method",
null,
true,
true,
);
return new PaymentMethodResponse(response);
}
async getPlans(): Promise<ListResponse<PlanResponse>> {
const r = await this.apiService.send("GET", "/plans", null, false, true);
return new ListResponse(r, PlanResponse);
@@ -145,43 +112,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new ProviderSubscriptionResponse(response);
}
async getProviderTaxInformation(providerId: string): Promise<TaxInfoResponse> {
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/tax-information",
null,
true,
true,
);
return new TaxInfoResponse(response);
}
async updateOrganizationPaymentMethod(
organizationId: string,
request: UpdatePaymentMethodRequest,
): Promise<void> {
return await this.apiService.send(
"PUT",
"/organizations/" + organizationId + "/billing/payment-method",
request,
true,
false,
);
}
async updateOrganizationTaxInformation(
organizationId: string,
request: ExpandedTaxInfoUpdateRequest,
): Promise<void> {
return await this.apiService.send(
"PUT",
"/organizations/" + organizationId + "/billing/tax-information",
request,
true,
false,
);
}
async updateProviderClientOrganization(
providerId: string,
organizationId: string,
@@ -196,55 +126,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
);
}
async updateProviderPaymentMethod(
providerId: string,
request: UpdatePaymentMethodRequest,
): Promise<void> {
return await this.apiService.send(
"PUT",
"/providers/" + providerId + "/billing/payment-method",
request,
true,
false,
);
}
async updateProviderTaxInformation(providerId: string, request: ExpandedTaxInfoUpdateRequest) {
return await this.apiService.send(
"PUT",
"/providers/" + providerId + "/billing/tax-information",
request,
true,
false,
);
}
async verifyOrganizationBankAccount(
organizationId: string,
request: VerifyBankAccountRequest,
): Promise<void> {
return await this.apiService.send(
"POST",
"/organizations/" + organizationId + "/billing/payment-method/verify-bank-account",
request,
true,
false,
);
}
async verifyProviderBankAccount(
providerId: string,
request: VerifyBankAccountRequest,
): Promise<void> {
return await this.apiService.send(
"POST",
"/providers/" + providerId + "/billing/payment-method/verify-bank-account",
request,
true,
false,
);
}
async restartSubscription(
organizationId: string,
request: OrganizationCreateRequest,

View File

@@ -23,7 +23,6 @@ import { OrganizationResponse } from "../../admin-console/models/response/organi
import { EncString } from "../../key-management/crypto/models/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { OrgKey } from "../../types/key";
import { PaymentMethodResponse } from "../models/response/payment-method.response";
describe("OrganizationBillingService", () => {
let apiService: jest.Mocked<ApiService>;
@@ -62,47 +61,6 @@ describe("OrganizationBillingService", () => {
return jest.resetAllMocks();
});
describe("getPaymentSource()", () => {
it("given a valid organization id, then it returns a payment source", async () => {
//Arrange
const orgId = "organization-test";
const paymentMethodResponse = {
paymentSource: { type: PaymentMethodType.Card },
} as PaymentMethodResponse;
billingApiService.getOrganizationPaymentMethod.mockResolvedValue(paymentMethodResponse);
//Act
const returnedPaymentSource = await sut.getPaymentSource(orgId);
//Assert
expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1);
expect(returnedPaymentSource).toEqual(paymentMethodResponse.paymentSource);
});
it("given an invalid organizationId, it should return undefined", async () => {
//Arrange
const orgId = "invalid-id";
billingApiService.getOrganizationPaymentMethod.mockResolvedValue(null);
//Act
const returnedPaymentSource = await sut.getPaymentSource(orgId);
//Assert
expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1);
expect(returnedPaymentSource).toBeUndefined();
});
it("given an API error occurs, then it throws the error", async () => {
// Arrange
const orgId = "error-org";
billingApiService.getOrganizationPaymentMethod.mockRejectedValue(new Error("API Error"));
// Act & Assert
await expect(sut.getPaymentSource(orgId)).rejects.toThrow("API Error");
expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1);
});
});
describe("purchaseSubscription()", () => {
it("given valid subscription information, then it returns successful response", async () => {
//Arrange
@@ -118,7 +76,7 @@ describe("OrganizationBillingService", () => {
const organizationResponse = {
name: subscriptionInformation.organization.name,
billingEmail: subscriptionInformation.organization.billingEmail,
planType: subscriptionInformation.plan.type,
planType: subscriptionInformation.plan!.type,
} as OrganizationResponse;
organizationApiService.create.mockResolvedValue(organizationResponse);
@@ -201,8 +159,8 @@ describe("OrganizationBillingService", () => {
const organizationResponse = {
name: subscriptionInformation.organization.name,
plan: { type: subscriptionInformation.plan.type },
planType: subscriptionInformation.plan.type,
plan: { type: subscriptionInformation.plan!.type },
planType: subscriptionInformation.plan!.type,
} as OrganizationResponse;
organizationApiService.createWithoutPayment.mockResolvedValue(organizationResponse);
@@ -262,7 +220,7 @@ describe("OrganizationBillingService", () => {
const organizationResponse = {
name: subscriptionInformation.organization.name,
billingEmail: subscriptionInformation.organization.billingEmail,
planType: subscriptionInformation.plan.type,
planType: subscriptionInformation.plan!.type,
} as OrganizationResponse;
organizationApiService.create.mockResolvedValue(organizationResponse);

View File

@@ -25,7 +25,6 @@ import {
} from "../abstractions";
import { PlanType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
import { PaymentSourceResponse } from "../models/response/payment-source.response";
interface OrganizationKeys {
encryptedKey: EncString;
@@ -45,11 +44,6 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
private syncService: SyncService,
) {}
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
const paymentMethod = await this.billingApiService.getOrganizationPaymentMethod(organizationId);
return paymentMethod?.paymentSource;
}
async purchaseSubscription(
subscription: SubscriptionInformation,
activeUserId: UserId,

View File

@@ -1,318 +0,0 @@
import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax";
import { ApiService } from "../../abstractions/api.service";
import { TaxServiceAbstraction } from "../abstractions/tax.service.abstraction";
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 class TaxService implements TaxServiceAbstraction {
constructor(private apiService: ApiService) {}
getCountries(): CountryListItem[] {
return [
{ name: "-- Select --", value: "", disabled: false },
{ name: "United States", value: "US", disabled: false },
{ name: "China", value: "CN", disabled: false },
{ name: "France", value: "FR", disabled: false },
{ name: "Germany", value: "DE", disabled: false },
{ name: "Canada", value: "CA", disabled: false },
{ name: "United Kingdom", value: "GB", disabled: false },
{ name: "Australia", value: "AU", disabled: false },
{ name: "India", value: "IN", disabled: false },
{ name: "", value: "-", disabled: true },
{ name: "Afghanistan", value: "AF", disabled: false },
{ name: "Åland Islands", value: "AX", disabled: false },
{ name: "Albania", value: "AL", disabled: false },
{ name: "Algeria", value: "DZ", disabled: false },
{ name: "American Samoa", value: "AS", disabled: false },
{ name: "Andorra", value: "AD", disabled: false },
{ name: "Angola", value: "AO", disabled: false },
{ name: "Anguilla", value: "AI", disabled: false },
{ name: "Antarctica", value: "AQ", disabled: false },
{ name: "Antigua and Barbuda", value: "AG", disabled: false },
{ name: "Argentina", value: "AR", disabled: false },
{ name: "Armenia", value: "AM", disabled: false },
{ name: "Aruba", value: "AW", disabled: false },
{ name: "Austria", value: "AT", disabled: false },
{ name: "Azerbaijan", value: "AZ", disabled: false },
{ name: "Bahamas", value: "BS", disabled: false },
{ name: "Bahrain", value: "BH", disabled: false },
{ name: "Bangladesh", value: "BD", disabled: false },
{ name: "Barbados", value: "BB", disabled: false },
{ name: "Belarus", value: "BY", disabled: false },
{ name: "Belgium", value: "BE", disabled: false },
{ name: "Belize", value: "BZ", disabled: false },
{ name: "Benin", value: "BJ", disabled: false },
{ name: "Bermuda", value: "BM", disabled: false },
{ name: "Bhutan", value: "BT", disabled: false },
{ name: "Bolivia, Plurinational State of", value: "BO", disabled: false },
{ name: "Bonaire, Sint Eustatius and Saba", value: "BQ", disabled: false },
{ name: "Bosnia and Herzegovina", value: "BA", disabled: false },
{ name: "Botswana", value: "BW", disabled: false },
{ name: "Bouvet Island", value: "BV", disabled: false },
{ name: "Brazil", value: "BR", disabled: false },
{ name: "British Indian Ocean Territory", value: "IO", disabled: false },
{ name: "Brunei Darussalam", value: "BN", disabled: false },
{ name: "Bulgaria", value: "BG", disabled: false },
{ name: "Burkina Faso", value: "BF", disabled: false },
{ name: "Burundi", value: "BI", disabled: false },
{ name: "Cambodia", value: "KH", disabled: false },
{ name: "Cameroon", value: "CM", disabled: false },
{ name: "Cape Verde", value: "CV", disabled: false },
{ name: "Cayman Islands", value: "KY", disabled: false },
{ name: "Central African Republic", value: "CF", disabled: false },
{ name: "Chad", value: "TD", disabled: false },
{ name: "Chile", value: "CL", disabled: false },
{ name: "Christmas Island", value: "CX", disabled: false },
{ name: "Cocos (Keeling) Islands", value: "CC", disabled: false },
{ name: "Colombia", value: "CO", disabled: false },
{ name: "Comoros", value: "KM", disabled: false },
{ name: "Congo", value: "CG", disabled: false },
{ name: "Congo, the Democratic Republic of the", value: "CD", disabled: false },
{ name: "Cook Islands", value: "CK", disabled: false },
{ name: "Costa Rica", value: "CR", disabled: false },
{ name: "Côte d'Ivoire", value: "CI", disabled: false },
{ name: "Croatia", value: "HR", disabled: false },
{ name: "Cuba", value: "CU", disabled: false },
{ name: "Curaçao", value: "CW", disabled: false },
{ name: "Cyprus", value: "CY", disabled: false },
{ name: "Czech Republic", value: "CZ", disabled: false },
{ name: "Denmark", value: "DK", disabled: false },
{ name: "Djibouti", value: "DJ", disabled: false },
{ name: "Dominica", value: "DM", disabled: false },
{ name: "Dominican Republic", value: "DO", disabled: false },
{ name: "Ecuador", value: "EC", disabled: false },
{ name: "Egypt", value: "EG", disabled: false },
{ name: "El Salvador", value: "SV", disabled: false },
{ name: "Equatorial Guinea", value: "GQ", disabled: false },
{ name: "Eritrea", value: "ER", disabled: false },
{ name: "Estonia", value: "EE", disabled: false },
{ name: "Ethiopia", value: "ET", disabled: false },
{ name: "Falkland Islands (Malvinas)", value: "FK", disabled: false },
{ name: "Faroe Islands", value: "FO", disabled: false },
{ name: "Fiji", value: "FJ", disabled: false },
{ name: "Finland", value: "FI", disabled: false },
{ name: "French Guiana", value: "GF", disabled: false },
{ name: "French Polynesia", value: "PF", disabled: false },
{ name: "French Southern Territories", value: "TF", disabled: false },
{ name: "Gabon", value: "GA", disabled: false },
{ name: "Gambia", value: "GM", disabled: false },
{ name: "Georgia", value: "GE", disabled: false },
{ name: "Ghana", value: "GH", disabled: false },
{ name: "Gibraltar", value: "GI", disabled: false },
{ name: "Greece", value: "GR", disabled: false },
{ name: "Greenland", value: "GL", disabled: false },
{ name: "Grenada", value: "GD", disabled: false },
{ name: "Guadeloupe", value: "GP", disabled: false },
{ name: "Guam", value: "GU", disabled: false },
{ name: "Guatemala", value: "GT", disabled: false },
{ name: "Guernsey", value: "GG", disabled: false },
{ name: "Guinea", value: "GN", disabled: false },
{ name: "Guinea-Bissau", value: "GW", disabled: false },
{ name: "Guyana", value: "GY", disabled: false },
{ name: "Haiti", value: "HT", disabled: false },
{ name: "Heard Island and McDonald Islands", value: "HM", disabled: false },
{ name: "Holy See (Vatican City State)", value: "VA", disabled: false },
{ name: "Honduras", value: "HN", disabled: false },
{ name: "Hong Kong", value: "HK", disabled: false },
{ name: "Hungary", value: "HU", disabled: false },
{ name: "Iceland", value: "IS", disabled: false },
{ name: "Indonesia", value: "ID", disabled: false },
{ name: "Iran, Islamic Republic of", value: "IR", disabled: false },
{ name: "Iraq", value: "IQ", disabled: false },
{ name: "Ireland", value: "IE", disabled: false },
{ name: "Isle of Man", value: "IM", disabled: false },
{ name: "Israel", value: "IL", disabled: false },
{ name: "Italy", value: "IT", disabled: false },
{ name: "Jamaica", value: "JM", disabled: false },
{ name: "Japan", value: "JP", disabled: false },
{ name: "Jersey", value: "JE", disabled: false },
{ name: "Jordan", value: "JO", disabled: false },
{ name: "Kazakhstan", value: "KZ", disabled: false },
{ name: "Kenya", value: "KE", disabled: false },
{ name: "Kiribati", value: "KI", disabled: false },
{ name: "Korea, Democratic People's Republic of", value: "KP", disabled: false },
{ name: "Korea, Republic of", value: "KR", disabled: false },
{ name: "Kuwait", value: "KW", disabled: false },
{ name: "Kyrgyzstan", value: "KG", disabled: false },
{ name: "Lao People's Democratic Republic", value: "LA", disabled: false },
{ name: "Latvia", value: "LV", disabled: false },
{ name: "Lebanon", value: "LB", disabled: false },
{ name: "Lesotho", value: "LS", disabled: false },
{ name: "Liberia", value: "LR", disabled: false },
{ name: "Libya", value: "LY", disabled: false },
{ name: "Liechtenstein", value: "LI", disabled: false },
{ name: "Lithuania", value: "LT", disabled: false },
{ name: "Luxembourg", value: "LU", disabled: false },
{ name: "Macao", value: "MO", disabled: false },
{ name: "Macedonia, the former Yugoslav Republic of", value: "MK", disabled: false },
{ name: "Madagascar", value: "MG", disabled: false },
{ name: "Malawi", value: "MW", disabled: false },
{ name: "Malaysia", value: "MY", disabled: false },
{ name: "Maldives", value: "MV", disabled: false },
{ name: "Mali", value: "ML", disabled: false },
{ name: "Malta", value: "MT", disabled: false },
{ name: "Marshall Islands", value: "MH", disabled: false },
{ name: "Martinique", value: "MQ", disabled: false },
{ name: "Mauritania", value: "MR", disabled: false },
{ name: "Mauritius", value: "MU", disabled: false },
{ name: "Mayotte", value: "YT", disabled: false },
{ name: "Mexico", value: "MX", disabled: false },
{ name: "Micronesia, Federated States of", value: "FM", disabled: false },
{ name: "Moldova, Republic of", value: "MD", disabled: false },
{ name: "Monaco", value: "MC", disabled: false },
{ name: "Mongolia", value: "MN", disabled: false },
{ name: "Montenegro", value: "ME", disabled: false },
{ name: "Montserrat", value: "MS", disabled: false },
{ name: "Morocco", value: "MA", disabled: false },
{ name: "Mozambique", value: "MZ", disabled: false },
{ name: "Myanmar", value: "MM", disabled: false },
{ name: "Namibia", value: "NA", disabled: false },
{ name: "Nauru", value: "NR", disabled: false },
{ name: "Nepal", value: "NP", disabled: false },
{ name: "Netherlands", value: "NL", disabled: false },
{ name: "New Caledonia", value: "NC", disabled: false },
{ name: "New Zealand", value: "NZ", disabled: false },
{ name: "Nicaragua", value: "NI", disabled: false },
{ name: "Niger", value: "NE", disabled: false },
{ name: "Nigeria", value: "NG", disabled: false },
{ name: "Niue", value: "NU", disabled: false },
{ name: "Norfolk Island", value: "NF", disabled: false },
{ name: "Northern Mariana Islands", value: "MP", disabled: false },
{ name: "Norway", value: "NO", disabled: false },
{ name: "Oman", value: "OM", disabled: false },
{ name: "Pakistan", value: "PK", disabled: false },
{ name: "Palau", value: "PW", disabled: false },
{ name: "Palestinian Territory, Occupied", value: "PS", disabled: false },
{ name: "Panama", value: "PA", disabled: false },
{ name: "Papua New Guinea", value: "PG", disabled: false },
{ name: "Paraguay", value: "PY", disabled: false },
{ name: "Peru", value: "PE", disabled: false },
{ name: "Philippines", value: "PH", disabled: false },
{ name: "Pitcairn", value: "PN", disabled: false },
{ name: "Poland", value: "PL", disabled: false },
{ name: "Portugal", value: "PT", disabled: false },
{ name: "Puerto Rico", value: "PR", disabled: false },
{ name: "Qatar", value: "QA", disabled: false },
{ name: "Réunion", value: "RE", disabled: false },
{ name: "Romania", value: "RO", disabled: false },
{ name: "Russian Federation", value: "RU", disabled: false },
{ name: "Rwanda", value: "RW", disabled: false },
{ name: "Saint Barthélemy", value: "BL", disabled: false },
{ name: "Saint Helena, Ascension and Tristan da Cunha", value: "SH", disabled: false },
{ name: "Saint Kitts and Nevis", value: "KN", disabled: false },
{ name: "Saint Lucia", value: "LC", disabled: false },
{ name: "Saint Martin (French part)", value: "MF", disabled: false },
{ name: "Saint Pierre and Miquelon", value: "PM", disabled: false },
{ name: "Saint Vincent and the Grenadines", value: "VC", disabled: false },
{ name: "Samoa", value: "WS", disabled: false },
{ name: "San Marino", value: "SM", disabled: false },
{ name: "Sao Tome and Principe", value: "ST", disabled: false },
{ name: "Saudi Arabia", value: "SA", disabled: false },
{ name: "Senegal", value: "SN", disabled: false },
{ name: "Serbia", value: "RS", disabled: false },
{ name: "Seychelles", value: "SC", disabled: false },
{ name: "Sierra Leone", value: "SL", disabled: false },
{ name: "Singapore", value: "SG", disabled: false },
{ name: "Sint Maarten (Dutch part)", value: "SX", disabled: false },
{ name: "Slovakia", value: "SK", disabled: false },
{ name: "Slovenia", value: "SI", disabled: false },
{ name: "Solomon Islands", value: "SB", disabled: false },
{ name: "Somalia", value: "SO", disabled: false },
{ name: "South Africa", value: "ZA", disabled: false },
{ name: "South Georgia and the South Sandwich Islands", value: "GS", disabled: false },
{ name: "South Sudan", value: "SS", disabled: false },
{ name: "Spain", value: "ES", disabled: false },
{ name: "Sri Lanka", value: "LK", disabled: false },
{ name: "Sudan", value: "SD", disabled: false },
{ name: "Suriname", value: "SR", disabled: false },
{ name: "Svalbard and Jan Mayen", value: "SJ", disabled: false },
{ name: "Swaziland", value: "SZ", disabled: false },
{ name: "Sweden", value: "SE", disabled: false },
{ name: "Switzerland", value: "CH", disabled: false },
{ name: "Syrian Arab Republic", value: "SY", disabled: false },
{ name: "Taiwan", value: "TW", disabled: false },
{ name: "Tajikistan", value: "TJ", disabled: false },
{ name: "Tanzania, United Republic of", value: "TZ", disabled: false },
{ name: "Thailand", value: "TH", disabled: false },
{ name: "Timor-Leste", value: "TL", disabled: false },
{ name: "Togo", value: "TG", disabled: false },
{ name: "Tokelau", value: "TK", disabled: false },
{ name: "Tonga", value: "TO", disabled: false },
{ name: "Trinidad and Tobago", value: "TT", disabled: false },
{ name: "Tunisia", value: "TN", disabled: false },
{ name: "Turkey", value: "TR", disabled: false },
{ name: "Turkmenistan", value: "TM", disabled: false },
{ name: "Turks and Caicos Islands", value: "TC", disabled: false },
{ name: "Tuvalu", value: "TV", disabled: false },
{ name: "Uganda", value: "UG", disabled: false },
{ name: "Ukraine", value: "UA", disabled: false },
{ name: "United Arab Emirates", value: "AE", disabled: false },
{ name: "United States Minor Outlying Islands", value: "UM", disabled: false },
{ name: "Uruguay", value: "UY", disabled: false },
{ name: "Uzbekistan", value: "UZ", disabled: false },
{ name: "Vanuatu", value: "VU", disabled: false },
{ name: "Venezuela, Bolivarian Republic of", value: "VE", disabled: false },
{ name: "Viet Nam", value: "VN", disabled: false },
{ name: "Virgin Islands, British", value: "VG", disabled: false },
{ name: "Virgin Islands, U.S.", value: "VI", disabled: false },
{ name: "Wallis and Futuna", value: "WF", disabled: false },
{ name: "Western Sahara", value: "EH", disabled: false },
{ name: "Yemen", value: "YE", disabled: false },
{ name: "Zambia", value: "ZM", disabled: false },
{ name: "Zimbabwe", value: "ZW", disabled: false },
];
}
async isCountrySupported(country: string): Promise<boolean> {
const response = await this.apiService.send(
"GET",
"/tax/is-country-supported?country=" + country,
null,
true,
true,
);
return response;
}
async previewIndividualInvoice(
request: PreviewIndividualInvoiceRequest,
): Promise<PreviewInvoiceResponse> {
const response = await this.apiService.send(
"POST",
"/accounts/billing/preview-invoice",
request,
true,
true,
);
return new PreviewInvoiceResponse(response);
}
async previewOrganizationInvoice(
request: PreviewOrganizationInvoiceRequest,
): Promise<PreviewInvoiceResponse> {
const response = await this.apiService.send(
"POST",
`/invoices/preview-organization`,
request,
true,
true,
);
return new PreviewInvoiceResponse(response);
}
async previewTaxAmountForOrganizationTrial(
request: PreviewTaxAmountForOrganizationTrialRequest,
): Promise<number> {
const response = await this.apiService.send(
"POST",
"/tax/preview-amount/organization-trial",
request,
true,
true,
);
return response as number;
}
}

View File

@@ -0,0 +1,2 @@
export * from "./models";
export * from "./services";

View File

@@ -0,0 +1 @@
export * from "./response";

View File

@@ -1,4 +1,4 @@
import { BaseResponse } from "./base.response";
import { BaseResponse } from "../../../models/response/base.response";
export class BreachAccountResponse extends BaseResponse {
addedDate: string;

View File

@@ -0,0 +1 @@
export * from "./breach-account.response";

View File

@@ -0,0 +1,20 @@
import { ApiService } from "../../abstractions/api.service";
import { DirtApiService } from "./dirt-api.service";
describe("DirtApiService", () => {
let sut: DirtApiService;
let apiService: jest.Mocked<ApiService>;
beforeEach(() => {
apiService = {
send: jest.fn(),
} as any;
sut = new DirtApiService(apiService);
});
it("should be created", () => {
expect(sut).toBeTruthy();
});
});

View File

@@ -0,0 +1,8 @@
import { ApiService } from "../../abstractions/api.service";
export class DirtApiService {
constructor(private apiService: ApiService) {}
// This service can be used for general DIRT-related API methods
// For specific domains like HIBP, use dedicated services like HibpApiService
}

View File

@@ -0,0 +1,39 @@
import { ApiService } from "../../abstractions/api.service";
import { BreachAccountResponse } from "../models";
import { HibpApiService } from "./hibp-api.service";
describe("HibpApiService", () => {
let sut: HibpApiService;
let apiService: jest.Mocked<ApiService>;
beforeEach(() => {
apiService = {
send: jest.fn(),
} as any;
sut = new HibpApiService(apiService);
});
describe("getHibpBreach", () => {
it("should properly URL encode username with special characters", async () => {
const mockResponse = [{ name: "test" }];
const username = "connect#bwpm@simplelogin.co";
apiService.send.mockResolvedValue(mockResponse);
const result = await sut.getHibpBreach(username);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/hibp/breach?username=" + encodeURIComponent(username),
null,
true,
true,
);
expect(result).toBeInstanceOf(Array);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(BreachAccountResponse);
});
});
});

View File

@@ -0,0 +1,18 @@
import { ApiService } from "../../abstractions/api.service";
import { BreachAccountResponse } from "../models";
export class HibpApiService {
constructor(private apiService: ApiService) {}
async getHibpBreach(username: string): Promise<BreachAccountResponse[]> {
const encodedUsername = encodeURIComponent(username);
const r = await this.apiService.send(
"GET",
"/hibp/breach?username=" + encodedUsername,
null,
true,
true,
);
return r.map((a: any) => new BreachAccountResponse(a));
}
}

View File

@@ -0,0 +1,2 @@
export * from "./dirt-api.service";
export * from "./hibp-api.service";

View File

@@ -0,0 +1,110 @@
import { EventType } from "./event-type.enum";
export const EventCategory = {
UserEvents: "userEvents",
ItemEvents: "itemEvents",
CollectionEvents: "collectionEvents",
GroupEvents: "groupEvents",
OrganizationMemberEvents: "organizationMemberEvents",
OrganizationEvents: "organizationEvents",
ProviderEvents: "providerEvents",
} as const;
export type EventCategory = (typeof EventCategory)[keyof typeof EventCategory];
export const EventCategoryEventTypes: Record<EventCategory, EventType[]> = {
[EventCategory.UserEvents]: [
EventType.User_LoggedIn,
EventType.User_ChangedPassword,
EventType.User_Updated2fa,
EventType.User_Disabled2fa,
EventType.User_Recovered2fa,
EventType.User_FailedLogIn,
EventType.User_FailedLogIn2fa,
EventType.User_ClientExportedVault,
EventType.User_UpdatedTempPassword,
EventType.User_MigratedKeyToKeyConnector,
EventType.User_RequestedDeviceApproval,
EventType.User_TdeOffboardingPasswordSet,
],
[EventCategory.ItemEvents]: [
EventType.Cipher_Created,
EventType.Cipher_Updated,
EventType.Cipher_Deleted,
EventType.Cipher_AttachmentCreated,
EventType.Cipher_AttachmentDeleted,
EventType.Cipher_Shared,
EventType.Cipher_UpdatedCollections,
EventType.Cipher_ClientViewed,
EventType.Cipher_ClientToggledPasswordVisible,
EventType.Cipher_ClientToggledHiddenFieldVisible,
EventType.Cipher_ClientToggledCardCodeVisible,
EventType.Cipher_ClientCopiedPassword,
EventType.Cipher_ClientCopiedHiddenField,
EventType.Cipher_ClientCopiedCardCode,
EventType.Cipher_ClientAutofilled,
EventType.Cipher_SoftDeleted,
EventType.Cipher_Restored,
EventType.Cipher_ClientToggledCardNumberVisible,
EventType.Cipher_ClientToggledTOTPSeedVisible,
],
[EventCategory.CollectionEvents]: [
EventType.Collection_Created,
EventType.Collection_Updated,
EventType.Collection_Deleted,
],
[EventCategory.GroupEvents]: [
EventType.Group_Created,
EventType.Group_Updated,
EventType.Group_Deleted,
],
[EventCategory.OrganizationMemberEvents]: [
EventType.OrganizationUser_Invited,
EventType.OrganizationUser_Confirmed,
EventType.OrganizationUser_Updated,
EventType.OrganizationUser_Removed,
EventType.OrganizationUser_UpdatedGroups,
EventType.OrganizationUser_UnlinkedSso,
EventType.OrganizationUser_ResetPassword_Enroll,
EventType.OrganizationUser_ResetPassword_Withdraw,
EventType.OrganizationUser_AdminResetPassword,
EventType.OrganizationUser_ResetSsoLink,
EventType.OrganizationUser_FirstSsoLogin,
EventType.OrganizationUser_Revoked,
EventType.OrganizationUser_Restored,
EventType.OrganizationUser_ApprovedAuthRequest,
EventType.OrganizationUser_RejectedAuthRequest,
EventType.OrganizationUser_Deleted,
EventType.OrganizationUser_Left,
],
[EventCategory.OrganizationEvents]: [
EventType.Organization_Updated,
EventType.Organization_PurgedVault,
EventType.Organization_ClientExportedVault,
EventType.Organization_VaultAccessed,
EventType.Organization_EnabledSso,
EventType.Organization_DisabledSso,
EventType.Organization_EnabledKeyConnector,
EventType.Organization_DisabledKeyConnector,
EventType.Organization_SponsorshipsSynced,
EventType.Organization_CollectionManagementUpdated,
EventType.Organization_CollectionManagement_LimitCollectionCreationEnabled,
EventType.Organization_CollectionManagement_LimitCollectionCreationDisabled,
EventType.Organization_CollectionManagement_LimitCollectionDeletionEnabled,
EventType.Organization_CollectionManagement_LimitCollectionDeletionDisabled,
EventType.Organization_CollectionManagement_LimitItemDeletionEnabled,
EventType.Organization_CollectionManagement_LimitItemDeletionDisabled,
EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled,
EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled,
],
[EventCategory.ProviderEvents]: [
EventType.ProviderUser_Invited,
EventType.ProviderUser_Confirmed,
EventType.ProviderUser_Updated,
EventType.ProviderUser_Removed,
EventType.ProviderOrganization_Created,
EventType.ProviderOrganization_Added,
EventType.ProviderOrganization_Removed,
EventType.ProviderOrganization_VaultAccessed,
],
};

View File

@@ -108,4 +108,11 @@ export enum EventType {
Project_Created = 2201,
Project_Edited = 2202,
Project_Deleted = 2203,
ServiceAccount_UserAdded = 2300,
ServiceAccount_UserRemoved = 2301,
ServiceAccount_GroupAdded = 2302,
ServiceAccount_GroupRemoved = 2303,
ServiceAccount_Created = 2304,
ServiceAccount_Deleted = 2305,
}

View File

@@ -12,21 +12,17 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
export enum FeatureFlag {
/* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location",
CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors",
/* Auth */
PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals",
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
/* Autofill */
NotificationRefresh = "notification-refresh",
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
UseOrganizationWarningsService = "use-organization-warnings-service",
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
@@ -42,18 +38,22 @@ export enum FeatureFlag {
/* DIRT */
EventBasedOrganizationIntegrations = "event-based-organization-integrations",
PhishingDetection = "phishing-detection",
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
/* Vault */
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
CipherKeyEncryption = "cipher-key-encryption",
RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users",
PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked",
/* Innovation */
PM19148_InnovationArchive = "pm-19148-innovation-archive",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -72,10 +72,8 @@ const FALSE = false as boolean;
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.CollectionVaultRefactor]: FALSE,
/* Autofill */
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
@@ -86,22 +84,21 @@ export const DefaultFeatureFlagValue = {
/* DIRT */
[FeatureFlag.EventBasedOrganizationIntegrations]: FALSE,
[FeatureFlag.PhishingDetection]: FALSE,
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
/* Vault */
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
/* Auth */
[FeatureFlag.PM14938_BrowserExtensionLoginApproval]: FALSE,
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.UseOrganizationWarningsService]: FALSE,
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
@@ -114,6 +111,9 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.IpcChannelFramework]: FALSE,
[FeatureFlag.InactiveUserServerNotification]: FALSE,
[FeatureFlag.PushNotificationsWhenLocked]: FALSE,
/* Innovation */
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -0,0 +1,9 @@
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
export abstract class ChangeKdfApiService {
/**
* Sends a request to update the user's KDF parameters.
* @param request The KDF request containing authentication data, unlock data, and old authentication data
*/
abstract updateUserKdfParams(request: KdfRequest): Promise<void>;
}

View File

@@ -0,0 +1,15 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
/**
* @internal
*/
export class DefaultChangeKdfApiService implements ChangeKdfApiService {
constructor(private apiService: ApiService) {}
async updateUserKdfParams(request: KdfRequest): Promise<void> {
return this.apiService.send("POST", "/accounts/kdf", request, true, false);
}
}

View File

@@ -0,0 +1,20 @@
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig } from "@bitwarden/key-management";
export abstract class ChangeKdfService {
/**
* Updates the user's KDF parameters
* @param masterPassword The user's current master password
* @param kdf The new KDF configuration to apply
* @param userId The ID of the user whose KDF parameters are being updated
* @throws If any of the parameters is null
* @throws If the user is locked or logged out
* @throws If the kdf change request fails
*/
abstract updateUserKdfParams(
masterPassword: string,
kdf: KdfConfig,
userId: UserId,
): Promise<void>;
}

View File

@@ -0,0 +1,167 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationHash,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "../master-password/types/master-password.types";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "./change-kdf-service";
describe("ChangeKdfService", () => {
const changeKdfApiService = mock<ChangeKdfApiService>();
const masterPasswordService = mock<MasterPasswordServiceAbstraction>();
const keyService = mock<KeyService>();
const kdfConfigService = mock<KdfConfigService>();
let sut: DefaultChangeKdfService = mock<DefaultChangeKdfService>();
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockOldKdfConfig = new PBKDF2KdfConfig(100000);
const mockNewKdfConfig = new PBKDF2KdfConfig(200000);
const mockOldHash = "oldHash" as MasterPasswordAuthenticationHash;
const mockNewHash = "newHash" as MasterPasswordAuthenticationHash;
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
const mockSalt = "test@bitwarden.com" as MasterPasswordSalt;
const mockWrappedUserKey = "wrappedUserKey";
beforeEach(() => {
sut = new DefaultChangeKdfService(
masterPasswordService,
keyService,
kdfConfigService,
changeKdfApiService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("updateUserKdfParams", () => {
it("should throw an error if masterPassword is null", async () => {
await expect(
sut.updateUserKdfParams(null as unknown as string, mockNewKdfConfig, mockUserId),
).rejects.toThrow("masterPassword");
});
it("should throw an error if masterPassword is undefined", async () => {
await expect(
sut.updateUserKdfParams(undefined as unknown as string, mockNewKdfConfig, mockUserId),
).rejects.toThrow("masterPassword");
});
it("should throw an error if kdf is null", async () => {
await expect(
sut.updateUserKdfParams("masterPassword", null as unknown as PBKDF2KdfConfig, mockUserId),
).rejects.toThrow("kdf");
});
it("should throw an error if kdf is undefined", async () => {
await expect(
sut.updateUserKdfParams(
"masterPassword",
undefined as unknown as PBKDF2KdfConfig,
mockUserId,
),
).rejects.toThrow("kdf");
});
it("should throw an error if userId is null", async () => {
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, null as unknown as UserId),
).rejects.toThrow("userId");
});
it("should throw an error if userId is undefined", async () => {
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, undefined as unknown as UserId),
).rejects.toThrow("userId");
});
it("should throw an error if userKey is null", async () => {
keyService.userKey$.mockReturnValueOnce(of(null));
masterPasswordService.saltForUser$.mockReturnValueOnce(of(mockSalt));
kdfConfigService.getKdfConfig$.mockReturnValueOnce(of(mockOldKdfConfig));
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId),
).rejects.toThrow();
});
it("should throw an error if salt is null", async () => {
keyService.userKey$.mockReturnValueOnce(of(mockUserKey));
masterPasswordService.saltForUser$.mockReturnValueOnce(of(null));
kdfConfigService.getKdfConfig$.mockReturnValueOnce(of(mockOldKdfConfig));
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId),
).rejects.toThrow("Failed to get salt");
});
it("should throw an error if oldKdfConfig is null", async () => {
keyService.userKey$.mockReturnValueOnce(of(mockUserKey));
masterPasswordService.saltForUser$.mockReturnValueOnce(of(mockSalt));
kdfConfigService.getKdfConfig$.mockReturnValueOnce(of(null));
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId),
).rejects.toThrow("Failed to get oldKdfConfig");
});
it("should call apiService.send with correct parameters", async () => {
keyService.userKey$.mockReturnValueOnce(of(mockUserKey));
masterPasswordService.saltForUser$.mockReturnValueOnce(of(mockSalt));
kdfConfigService.getKdfConfig$.mockReturnValueOnce(of(mockOldKdfConfig));
masterPasswordService.makeMasterPasswordAuthenticationData
.mockResolvedValueOnce({
salt: mockSalt,
kdf: mockOldKdfConfig,
masterPasswordAuthenticationHash: mockOldHash,
})
.mockResolvedValueOnce({
salt: mockSalt,
kdf: mockNewKdfConfig,
masterPasswordAuthenticationHash: mockNewHash,
});
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValueOnce(
new MasterPasswordUnlockData(
mockSalt,
mockNewKdfConfig,
mockWrappedUserKey as MasterKeyWrappedUserKey,
),
);
await sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId);
const expected = new KdfRequest(
{
salt: mockSalt,
kdf: mockNewKdfConfig,
masterPasswordAuthenticationHash: mockNewHash,
},
new MasterPasswordUnlockData(
mockSalt,
mockNewKdfConfig,
mockWrappedUserKey as MasterKeyWrappedUserKey,
),
).authenticateWith({
salt: mockSalt,
kdf: mockOldKdfConfig,
masterPasswordAuthenticationHash: mockOldHash,
});
expect(changeKdfApiService.updateUserKdfParams).toHaveBeenCalledWith(expected);
});
});
});

View File

@@ -0,0 +1,59 @@
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { firstValueFromOrThrow } from "../utils";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { ChangeKdfService } from "./change-kdf-service.abstraction";
export class DefaultChangeKdfService implements ChangeKdfService {
constructor(
private masterPasswordService: MasterPasswordServiceAbstraction,
private keyService: KeyService,
private kdfConfigService: KdfConfigService,
private changeKdfApiService: ChangeKdfApiService,
) {}
async updateUserKdfParams(masterPassword: string, kdf: KdfConfig, userId: UserId): Promise<void> {
assertNonNullish(masterPassword, "masterPassword");
assertNonNullish(kdf, "kdf");
assertNonNullish(userId, "userId");
const userKey = await firstValueFromOrThrow(this.keyService.userKey$(userId), "userKey");
const salt = await firstValueFromOrThrow(
this.masterPasswordService.saltForUser$(userId),
"salt",
);
const oldKdfConfig = await firstValueFromOrThrow(
this.kdfConfigService.getKdfConfig$(userId),
"oldKdfConfig",
);
const oldAuthenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
masterPassword,
oldKdfConfig,
salt,
);
const authenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
masterPassword,
kdf,
salt,
);
const unlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
masterPassword,
kdf,
salt,
userKey,
);
const request = new KdfRequest(authenticationData, unlockData);
request.authenticateWith(oldAuthenticationData);
await this.changeKdfApiService.updateUserKdfParams(request);
}
}

View File

@@ -27,6 +27,11 @@ export abstract class MasterPasswordServiceAbstraction {
* @throws If the user ID is provided, but the user is not found.
*/
abstract saltForUser$: (userId: UserId) => Observable<MasterPasswordSalt>;
/**
* Converts an email to a master password salt. This is a canonical encoding of the
* email, no matter how the email is capitalized.
*/
abstract emailToSalt(email: string): MasterPasswordSalt;
/**
* An observable that emits the master key for the user.
* @deprecated Interacting with the master-key directly is deprecated. Please use {@link makeMasterPasswordUnlockData}, {@link makeMasterPasswordAuthenticationData} or {@link unwrapUserKeyFromMasterPasswordUnlockData} instead.

View File

@@ -1,13 +1,11 @@
// eslint-disable-next-line no-restricted-imports
import { KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { makeEncString } from "../../../../../spec";
import { MasterPasswordUnlockResponse } from "./master-password-unlock.response";
describe("MasterPasswordUnlockResponse", () => {
const salt = "test@example.com";
const encryptedUserKey = makeEncString("testUserKey");
const encryptedUserKey = "testUserKey";
const testKdfResponse = { KdfType: KdfType.PBKDF2_SHA256, Iterations: 600_000 };
it("should throw error when salt is not provided", () => {
@@ -15,7 +13,7 @@ describe("MasterPasswordUnlockResponse", () => {
new MasterPasswordUnlockResponse({
Salt: undefined,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
});
}).toThrow("MasterPasswordUnlockResponse does not contain a valid salt");
});
@@ -36,7 +34,7 @@ describe("MasterPasswordUnlockResponse", () => {
const response = new MasterPasswordUnlockResponse({
Salt: salt,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
});
expect(response.salt).toBe(salt);
@@ -50,7 +48,7 @@ describe("MasterPasswordUnlockResponse", () => {
const response = new MasterPasswordUnlockResponse({
Salt: salt,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
});
const unlockData = response.toMasterPasswordUnlockData();

View File

@@ -1,5 +1,4 @@
import { BaseResponse } from "../../../../models/response/base.response";
import { EncString } from "../../../crypto/models/enc-string";
import { KdfConfigResponse } from "../../../models/response/kdf-config.response";
import {
MasterKeyWrappedUserKey,
@@ -29,9 +28,7 @@ export class MasterPasswordUnlockResponse extends BaseResponse {
"MasterPasswordUnlockResponse does not contain a valid master key encrypted user key",
);
}
this.masterKeyWrappedUserKey = new EncString(
masterKeyEncryptedUserKey,
) as MasterKeyWrappedUserKey;
this.masterKeyWrappedUserKey = masterKeyEncryptedUserKey as MasterKeyWrappedUserKey;
}
toMasterPasswordUnlockData() {

View File

@@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
this.masterKeyHashSubject.next(initialMasterKeyHash);
}
emailToSalt(email: string): MasterPasswordSalt {
return this.mock.emailToSalt(email);
}
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
return this.mock.saltForUser$(userId);
}

View File

@@ -10,7 +10,6 @@ import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden
import {
FakeAccountService,
makeEncString,
makeSymmetricCryptoKey,
mockAccountServiceWith,
} from "../../../../spec";
@@ -385,7 +384,7 @@ describe("MasterPasswordService", () => {
const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000);
const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3);
const salt = "test@bitwarden.com" as MasterPasswordSalt;
const encryptedUserKey = makeEncString("testUserKet") as MasterKeyWrappedUserKey;
const encryptedUserKey = "testUserKet" as MasterKeyWrappedUserKey;
it("returns null when value is null", () => {
const deserialized = MASTER_PASSWORD_UNLOCK_KEY.deserializer(
@@ -401,7 +400,7 @@ describe("MasterPasswordService", () => {
kdfType: KdfType.PBKDF2_SHA256,
iterations: kdfPBKDF2.iterations,
},
masterKeyWrappedUserKey: encryptedUserKey.encryptedString as string,
masterKeyWrappedUserKey: encryptedUserKey as string,
};
const deserialized = MASTER_PASSWORD_UNLOCK_KEY.deserializer(data);
@@ -419,7 +418,7 @@ describe("MasterPasswordService", () => {
memory: kdfArgon2.memory,
parallelism: kdfArgon2.parallelism,
},
masterKeyWrappedUserKey: encryptedUserKey.encryptedString as string,
masterKeyWrappedUserKey: encryptedUserKey as string,
};
const deserialized = MASTER_PASSWORD_UNLOCK_KEY.deserializer(data);

View File

@@ -132,7 +132,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
return EncString.fromJSON(key);
}
private emailToSalt(email: string): MasterPasswordSalt {
emailToSalt(email: string): MasterPasswordSalt {
return email.toLowerCase().trim() as MasterPasswordSalt;
}
@@ -256,6 +256,9 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
assertNonNullish(password, "password");
assertNonNullish(kdf, "kdf");
assertNonNullish(salt, "salt");
if (password === "") {
throw new Error("Master password cannot be empty.");
}
// We don't trust callers to use masterpasswordsalt correctly. They may type assert incorrectly.
salt = salt.toLowerCase().trim() as MasterPasswordSalt;
@@ -294,18 +297,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
assertNonNullish(kdf, "kdf");
assertNonNullish(salt, "salt");
assertNonNullish(userKey, "userKey");
if (password === "") {
throw new Error("Master password cannot be empty.");
}
// We don't trust callers to use masterpasswordsalt correctly. They may type assert incorrectly.
salt = salt.toLowerCase().trim() as MasterPasswordSalt;
await SdkLoadService.Ready;
const masterKeyWrappedUserKey = new EncString(
PureCrypto.encrypt_user_key_with_master_password(
userKey.toEncoded(),
password,
salt,
kdf.toSdkConfig(),
),
const masterKeyWrappedUserKey = PureCrypto.encrypt_user_key_with_master_password(
userKey.toEncoded(),
password,
salt,
kdf.toSdkConfig(),
) as MasterKeyWrappedUserKey;
return new MasterPasswordUnlockData(salt, kdf, masterKeyWrappedUserKey);
}
@@ -320,7 +324,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
await SdkLoadService.Ready;
const userKey = new SymmetricCryptoKey(
PureCrypto.decrypt_user_key_with_master_password(
masterPasswordUnlockData.masterKeyWrappedUserKey.encryptedString,
masterPasswordUnlockData.masterKeyWrappedUserKey,
password,
masterPasswordUnlockData.salt,
masterPasswordUnlockData.kdf.toSdkConfig(),

View File

@@ -2,8 +2,7 @@ import { Jsonify, Opaque } from "type-fest";
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { EncString } from "../../crypto/models/enc-string";
import { EncString } from "@bitwarden/sdk-internal";
/**
* The Base64-encoded master password authentication hash, that is sent to the server for authentication.
@@ -13,7 +12,7 @@ export type MasterPasswordAuthenticationHash = Opaque<string, "MasterPasswordAut
* You MUST obtain this through the emailToSalt function in MasterPasswordService
*/
export type MasterPasswordSalt = Opaque<string, "MasterPasswordSalt">;
export type MasterKeyWrappedUserKey = Opaque<EncString, "MasterPasswordSalt">;
export type MasterKeyWrappedUserKey = Opaque<EncString, "MasterKeyWrappedUserKey">;
/**
* The data required to unlock with the master password.
@@ -29,7 +28,7 @@ export class MasterPasswordUnlockData {
return {
salt: this.salt,
kdf: this.kdf,
masterKeyWrappedUserKey: this.masterKeyWrappedUserKey.toJSON(),
masterKeyWrappedUserKey: this.masterKeyWrappedUserKey,
};
}
@@ -43,7 +42,7 @@ export class MasterPasswordUnlockData {
obj.kdf.kdfType === KdfType.PBKDF2_SHA256
? PBKDF2KdfConfig.fromJSON(obj.kdf)
: Argon2KdfConfig.fromJSON(obj.kdf),
EncString.fromJSON(obj.masterKeyWrappedUserKey) as MasterKeyWrappedUserKey,
obj.masterKeyWrappedUserKey as MasterKeyWrappedUserKey,
);
}
}

View File

@@ -1,14 +1,12 @@
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import { makeEncString } from "../../../../spec";
import { UserDecryptionResponse } from "./user-decryption.response";
describe("UserDecryptionResponse", () => {
it("should create response when masterPasswordUnlock provided", () => {
const salt = "test@example.com";
const encryptedUserKey = makeEncString("testUserKey");
const encryptedUserKey = "testUserKey";
const kdfIterations = 600_000;
const response = {
@@ -18,7 +16,7 @@ describe("UserDecryptionResponse", () => {
KdfType: KdfType.PBKDF2_SHA256 as number,
Iterations: kdfIterations,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
},
};

View File

@@ -0,0 +1,12 @@
import { firstValueFrom, Observable } from "rxjs";
export async function firstValueFromOrThrow<T>(
value: Observable<T | null>,
name: string,
): Promise<T> {
const result = await firstValueFrom(value);
if (result == null) {
throw new Error(`Failed to get ${name}`);
}
return result;
}

View File

@@ -144,6 +144,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.searchService.clearIndex(lockingUserId);
// ! DO NOT REMOVE folderService.clearDecryptedFolderState ! For more information see PM-25660
await this.folderService.clearDecryptedFolderState(lockingUserId);
await this.masterPasswordService.clearMasterKey(lockingUserId);

View File

@@ -36,6 +36,7 @@ export class CipherExport {
req.creationDate = null;
req.revisionDate = null;
req.deletedDate = null;
req.archivedDate = null;
return req;
}
@@ -84,6 +85,7 @@ export class CipherExport {
view.creationDate = req.creationDate ? new Date(req.creationDate) : view.creationDate;
view.revisionDate = req.revisionDate ? new Date(req.revisionDate) : view.revisionDate;
view.deletedDate = req.deletedDate ? new Date(req.deletedDate) : view.deletedDate;
view.archivedDate = req.archivedDate ? new Date(req.archivedDate) : view.archivedDate;
return view;
}
@@ -128,6 +130,7 @@ export class CipherExport {
domain.creationDate = req.creationDate ? new Date(req.creationDate) : null;
domain.revisionDate = req.revisionDate ? new Date(req.revisionDate) : null;
domain.deletedDate = req.deletedDate ? new Date(req.deletedDate) : null;
domain.archivedDate = req.archivedDate ? new Date(req.archivedDate) : null;
return domain;
}
@@ -149,6 +152,7 @@ export class CipherExport {
revisionDate: Date = null;
creationDate: Date = null;
deletedDate: Date = null;
archivedDate: Date = null;
key: string;
// Use build method instead of ctor so that we can control order of JSON stringify for pretty print
@@ -195,5 +199,6 @@ export class CipherExport {
this.creationDate = o.creationDate;
this.revisionDate = o.revisionDate;
this.deletedDate = o.deletedDate;
this.archivedDate = o.archivedDate;
}
}

View File

@@ -1,14 +1,20 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { PasswordRequest } from "../../auth/models/request/password.request";
export class KdfRequest extends PasswordRequest {
kdf: KdfType;
kdfIterations: number;
kdfMemory?: number;
kdfParallelism?: number;
constructor(
authenticationData: MasterPasswordAuthenticationData,
unlockData: MasterPasswordUnlockData,
) {
super();
// Note, this init code should be in the super constructor, once PasswordRequest's constructor is updated.
this.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
this.key = unlockData.masterKeyWrappedUserKey;
this.authenticationData = authenticationData;
this.unlockData = unlockData;
}
}

View File

@@ -24,6 +24,7 @@ export class EventResponse extends BaseResponse {
secretId: string;
projectId: string;
serviceAccountId: string;
grantedServiceAccountId: string;
constructor(response: any) {
super(response);
@@ -48,5 +49,6 @@ export class EventResponse extends BaseResponse {
this.secretId = this.getResponseProperty("SecretId");
this.projectId = this.getResponseProperty("ProjectId");
this.serviceAccountId = this.getResponseProperty("ServiceAccountId");
this.grantedServiceAccountId = this.getResponseProperty("GrantedServiceAccountId");
}
}

View File

@@ -21,6 +21,7 @@ export abstract class PlatformUtilsService {
abstract isOpera(): boolean;
abstract isVivaldi(): boolean;
abstract isSafari(): boolean;
abstract isChromium(): boolean;
abstract isMacAppStore(): boolean;
abstract isPopupOpen(): Promise<boolean>;
abstract launchUri(uri: string, options?: any): void;

View File

@@ -1 +1 @@
export * from "@bitwarden/messaging-internal";
export { SubjectMessageSender, tagAsExternal, getCommand } from "@bitwarden/messaging";

View File

@@ -12,6 +12,7 @@ export type SharedDevFlags = {
skipWelcomeOnInstall: boolean;
configRetrievalIntervalMs: number;
showRiskInsightsDebug: boolean;
testPhishingUrls: string[];
};
function getFlags<T>(envFlags: string | T): T {

View File

@@ -302,7 +302,7 @@ describe("Utils Service", () => {
expect(b64String).toBe(b64HelloWorldString);
});
runInBothEnvironments("should return an empty string for an empty ArrayBuffer", () => {
runInBothEnvironments("should return empty string for an empty ArrayBuffer", () => {
const buffer = new Uint8Array([]).buffer;
const b64String = Utils.fromBufferToB64(buffer);
expect(b64String).toBe("");
@@ -312,6 +312,81 @@ describe("Utils Service", () => {
const b64String = Utils.fromBufferToB64(null);
expect(b64String).toBeNull();
});
runInBothEnvironments("returns null for undefined input", () => {
const b64 = Utils.fromBufferToB64(undefined as unknown as ArrayBuffer);
expect(b64).toBeNull();
});
runInBothEnvironments("returns empty string for empty input", () => {
const b64 = Utils.fromBufferToB64(new ArrayBuffer(0));
expect(b64).toBe("");
});
runInBothEnvironments("accepts Uint8Array directly", () => {
const u8 = new Uint8Array(asciiHelloWorldArray);
const b64 = Utils.fromBufferToB64(u8);
expect(b64).toBe(b64HelloWorldString);
});
runInBothEnvironments("respects byteOffset/byteLength (view window)", () => {
// [xx, 'hello world', yy] — view should only encode the middle slice
const prefix = [1, 2, 3];
const suffix = [4, 5];
const all = new Uint8Array([...prefix, ...asciiHelloWorldArray, ...suffix]);
const view = new Uint8Array(all.buffer, prefix.length, asciiHelloWorldArray.length);
const b64 = Utils.fromBufferToB64(view);
expect(b64).toBe(b64HelloWorldString);
});
runInBothEnvironments("handles DataView (ArrayBufferView other than Uint8Array)", () => {
const u8 = new Uint8Array(asciiHelloWorldArray);
const dv = new DataView(u8.buffer, 0, u8.byteLength);
const b64 = Utils.fromBufferToB64(dv);
expect(b64).toBe(b64HelloWorldString);
});
runInBothEnvironments("handles DataView with offset/length window", () => {
// Buffer: [xx, 'hello world', yy]
const prefix = [9, 9, 9];
const suffix = [8, 8];
const all = new Uint8Array([...prefix, ...asciiHelloWorldArray, ...suffix]);
// DataView over just the "hello world" window
const dv = new DataView(all.buffer, prefix.length, asciiHelloWorldArray.length);
const b64 = Utils.fromBufferToB64(dv);
expect(b64).toBe(b64HelloWorldString);
});
runInBothEnvironments(
"encodes empty view (offset-length window of zero) as empty string",
() => {
const backing = new Uint8Array([1, 2, 3, 4]);
const emptyView = new Uint8Array(backing.buffer, 2, 0);
const b64 = Utils.fromBufferToB64(emptyView);
expect(b64).toBe("");
},
);
runInBothEnvironments("does not mutate the input", () => {
const original = new Uint8Array(asciiHelloWorldArray);
const copyBefore = new Uint8Array(original); // snapshot
Utils.fromBufferToB64(original);
expect(original).toEqual(copyBefore); // unchanged
});
it("produces the same Base64 in Node vs non-Node mode", () => {
const bytes = new Uint8Array(asciiHelloWorldArray);
Utils.isNode = true;
const nodeB64 = Utils.fromBufferToB64(bytes);
Utils.isNode = false;
const browserB64 = Utils.fromBufferToB64(bytes);
expect(browserB64).toBe(nodeB64);
});
});
describe("fromB64ToArray(...)", () => {

View File

@@ -128,15 +128,52 @@ export class Utils {
return arr;
}
static fromBufferToB64(buffer: ArrayBuffer): string {
/**
* Convert binary data into a Base64 string.
*
* Overloads are provided for two categories of input:
*
* 1. ArrayBuffer
* - A raw, fixed-length chunk of memory (no element semantics).
* - Example: `const buf = new ArrayBuffer(16);`
*
* 2. ArrayBufferView
* - A *view* onto an existing buffer that gives the bytes meaning.
* - Examples: Uint8Array, Int32Array, DataView, etc.
* - Views can expose only a *window* of the underlying buffer via
* `byteOffset` and `byteLength`.
* Example:
* ```ts
* const buf = new ArrayBuffer(8);
* const full = new Uint8Array(buf); // sees all 8 bytes
* const half = new Uint8Array(buf, 4, 4); // sees only last 4 bytes
* ```
*
* Returns:
* - Base64 string for non-empty inputs,
* - null if `buffer` is `null` or `undefined`
* - empty string if `buffer` is empty (0 bytes)
*/
static fromBufferToB64(buffer: null | undefined): null;
static fromBufferToB64(buffer: ArrayBuffer): string;
static fromBufferToB64(buffer: ArrayBufferView): string;
static fromBufferToB64(buffer: ArrayBuffer | ArrayBufferView | null | undefined): string | null {
// Handle null / undefined input
if (buffer == null) {
return null;
}
const bytes: Uint8Array = Utils.normalizeToUint8Array(buffer);
// Handle empty input
if (bytes.length === 0) {
return "";
}
if (Utils.isNode) {
return Buffer.from(buffer).toString("base64");
return Buffer.from(bytes).toString("base64");
} else {
let binary = "";
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
@@ -144,6 +181,30 @@ export class Utils {
}
}
/**
* Normalizes input into a Uint8Array so we always have a uniform,
* byte-level view of the data. This avoids dealing with differences
* between ArrayBuffer (raw memory with no indexing) and other typed
* views (which may have element sizes, offsets, and lengths).
* @param buffer ArrayBuffer or ArrayBufferView (e.g. Uint8Array, DataView, etc.)
*/
private static normalizeToUint8Array(buffer: ArrayBuffer | ArrayBufferView): Uint8Array {
/**
* 1) Uint8Array: already bytes → use directly.
* 2) ArrayBuffer: wrap whole buffer.
* 3) Other ArrayBufferView (e.g., DataView, Int32Array):
* wrap the views window (byteOffset..byteOffset+byteLength).
*/
if (buffer instanceof Uint8Array) {
return buffer;
} else if (buffer instanceof ArrayBuffer) {
return new Uint8Array(buffer);
} else {
const view = buffer as ArrayBufferView;
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}
}
static fromBufferToUrlB64(buffer: ArrayBuffer): string {
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
}
@@ -392,7 +453,7 @@ export class Utils {
};
}
static isNullOrWhitespace(str: string): boolean {
static isNullOrWhitespace(str: string | null | undefined): boolean {
return str == null || typeof str !== "string" || str.trim() === "";
}

View File

@@ -8,6 +8,7 @@ export const ScheduledTaskNames = {
eventUploadsInterval: "eventUploadsInterval",
vaultTimeoutCheckInterval: "vaultTimeoutCheckInterval",
clearPopupViewCache: "clearPopupViewCache",
phishingDomainUpdate: "phishingDomainUpdate",
} as const;
export type ScheduledTaskName = (typeof ScheduledTaskNames)[keyof typeof ScheduledTaskNames];

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