mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 01:33:33 +00:00
Conflict resolution
This commit is contained in:
@@ -70,6 +70,9 @@ export class FakeAccountService implements AccountService {
|
||||
}
|
||||
|
||||
async switchAccount(userId: UserId): Promise<void> {
|
||||
const next =
|
||||
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
|
||||
this.activeAccountSubject.next(next);
|
||||
await this.mock.switchAccount(userId);
|
||||
}
|
||||
}
|
||||
|
||||
54
libs/common/spec/matchers/to-almost-equal.spec.ts
Normal file
54
libs/common/spec/matchers/to-almost-equal.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
describe("toAlmostEqual custom matcher", () => {
|
||||
it("matches identical Dates", () => {
|
||||
const date = new Date();
|
||||
expect(date).toAlmostEqual(date);
|
||||
});
|
||||
|
||||
it("matches when older but within default ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() - 5);
|
||||
expect(date).toAlmostEqual(olderDate);
|
||||
});
|
||||
|
||||
it("matches when newer but within default ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() + 5);
|
||||
expect(date).toAlmostEqual(olderDate);
|
||||
});
|
||||
|
||||
it("doesn't match if older than default ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() - 11);
|
||||
expect(date).not.toAlmostEqual(olderDate);
|
||||
});
|
||||
|
||||
it("doesn't match if newer than default ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() + 11);
|
||||
expect(date).not.toAlmostEqual(olderDate);
|
||||
});
|
||||
|
||||
it("matches when older but within custom ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() - 15);
|
||||
expect(date).toAlmostEqual(olderDate, 20);
|
||||
});
|
||||
|
||||
it("matches when newer but within custom ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() + 15);
|
||||
expect(date).toAlmostEqual(olderDate, 20);
|
||||
});
|
||||
|
||||
it("doesn't match if older than custom ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() - 21);
|
||||
expect(date).not.toAlmostEqual(olderDate, 20);
|
||||
});
|
||||
|
||||
it("doesn't match if newer than custom ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() + 21);
|
||||
expect(date).not.toAlmostEqual(olderDate, 20);
|
||||
});
|
||||
});
|
||||
20
libs/common/spec/matchers/to-almost-equal.ts
Normal file
20
libs/common/spec/matchers/to-almost-equal.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Matches the expected date within an optional ms precision
|
||||
* @param received The received date
|
||||
* @param expected The expected date
|
||||
* @param msPrecision The optional precision in milliseconds
|
||||
*/
|
||||
export const toAlmostEqual: jest.CustomMatcher = function (
|
||||
received: Date,
|
||||
expected: Date,
|
||||
msPrecision: number = 10,
|
||||
) {
|
||||
const receivedTime = received.getTime();
|
||||
const expectedTime = expected.getTime();
|
||||
const difference = Math.abs(receivedTime - expectedTime);
|
||||
return {
|
||||
pass: difference <= msPrecision,
|
||||
message: () =>
|
||||
`expected ${received} to be within ${msPrecision}ms of ${expected} (actual difference: ${difference}ms)`,
|
||||
};
|
||||
};
|
||||
86
libs/common/spec/observable-tracker.ts
Normal file
86
libs/common/spec/observable-tracker.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Observable, Subscription, firstValueFrom, throwError, timeout } from "rxjs";
|
||||
|
||||
/** Test class to enable async awaiting of observable emissions */
|
||||
export class ObservableTracker<T> {
|
||||
private subscription: Subscription;
|
||||
emissions: T[] = [];
|
||||
constructor(private observable: Observable<T>) {
|
||||
this.emissions = this.trackEmissions(observable);
|
||||
}
|
||||
|
||||
/** Unsubscribes from the observable */
|
||||
unsubscribe() {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaits the next emission from the observable, or throws if the timeout is exceeded
|
||||
* @param msTimeout The maximum time to wait for another emission before throwing
|
||||
*/
|
||||
async expectEmission(msTimeout = 50) {
|
||||
await firstValueFrom(
|
||||
this.observable.pipe(
|
||||
timeout({
|
||||
first: msTimeout,
|
||||
with: () => throwError(() => new Error("Timeout exceeded waiting for another emission.")),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Awaits until the the total number of emissions observed by this tracker equals or exceeds {@link count}
|
||||
* @param count The number of emissions to wait for
|
||||
*/
|
||||
async pauseUntilReceived(count: number, msTimeout = 50): Promise<T[]> {
|
||||
for (let i = 0; i < count - this.emissions.length; i++) {
|
||||
await this.expectEmission(msTimeout);
|
||||
}
|
||||
return this.emissions;
|
||||
}
|
||||
|
||||
private trackEmissions<T>(observable: Observable<T>): T[] {
|
||||
const emissions: T[] = [];
|
||||
this.subscription = observable.subscribe((value) => {
|
||||
switch (value) {
|
||||
case undefined:
|
||||
case null:
|
||||
emissions.push(value);
|
||||
return;
|
||||
default:
|
||||
// process by type
|
||||
break;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
emissions.push(value);
|
||||
break;
|
||||
case "symbol":
|
||||
// Cheating types to make symbols work at all
|
||||
emissions.push(value.toString() as T);
|
||||
break;
|
||||
default: {
|
||||
emissions.push(clone(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
return emissions;
|
||||
}
|
||||
}
|
||||
function clone(value: any): any {
|
||||
if (global.structuredClone != undefined) {
|
||||
return structuredClone(value);
|
||||
} else {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
/** A test helper that builds an @see{@link ObservableTracker}, which can be used to assert things about the
|
||||
* emissions of the given observable
|
||||
* @param observable The observable to track
|
||||
*/
|
||||
export function subscribeTo<T>(observable: Observable<T>) {
|
||||
return new ObservableTracker(observable);
|
||||
}
|
||||
@@ -170,7 +170,6 @@ export abstract class ApiService {
|
||||
postRegister: (request: RegisterRequest) => Promise<RegisterResponse>;
|
||||
postPremium: (data: FormData) => Promise<PaymentResponse>;
|
||||
postReinstatePremium: () => Promise<any>;
|
||||
postCancelPremium: () => Promise<any>;
|
||||
postAccountStorage: (request: StorageRequest) => Promise<PaymentResponse>;
|
||||
postAccountPayment: (request: PaymentRequest) => Promise<void>;
|
||||
postAccountLicense: (data: FormData) => Promise<any>;
|
||||
|
||||
@@ -51,7 +51,6 @@ export class OrganizationApiServiceAbstraction {
|
||||
updateSeats: (id: string, request: SeatRequest) => Promise<PaymentResponse>;
|
||||
updateStorage: (id: string, request: StorageRequest) => Promise<PaymentResponse>;
|
||||
verifyBank: (id: string, request: VerifyBankRequest) => Promise<void>;
|
||||
cancel: (id: string) => Promise<void>;
|
||||
reinstate: (id: string) => Promise<void>;
|
||||
leave: (id: string) => Promise<void>;
|
||||
delete: (id: string, request: SecretVerificationRequest) => Promise<void>;
|
||||
|
||||
@@ -184,10 +184,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
|
||||
);
|
||||
}
|
||||
|
||||
async cancel(id: string): Promise<void> {
|
||||
return this.apiService.send("POST", "/organizations/" + id + "/cancel", null, true, false);
|
||||
}
|
||||
|
||||
async reinstate(id: string): Promise<void> {
|
||||
return this.apiService.send("POST", "/organizations/" + id + "/reinstate", null, true, false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export abstract class AnonymousHubService {
|
||||
createHubConnection: (token: string) => void;
|
||||
stopHubConnection: () => void;
|
||||
createHubConnection: (token: string) => Promise<void>;
|
||||
stopHubConnection: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||
|
||||
export abstract class AuthService {
|
||||
getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>;
|
||||
logOut: (callback: () => void) => void;
|
||||
/** Authentication status for the active user */
|
||||
abstract activeAccountStatus$: Observable<AuthenticationStatus>;
|
||||
/**
|
||||
* Returns an observable authentication status for the given user id.
|
||||
* @note userId is a required parameter, null values will always return `AuthenticationStatus.LoggedOut`
|
||||
* @param userId The user id to check for an access token.
|
||||
*/
|
||||
abstract authStatusFor$(userId: UserId): Observable<AuthenticationStatus>;
|
||||
/** @deprecated use {@link activeAccountStatus$} instead */
|
||||
abstract getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>;
|
||||
abstract logOut: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ export abstract class AvatarService {
|
||||
* @returns a promise that resolves when the avatar color is set
|
||||
*/
|
||||
abstract setAvatarColor(color: string): Promise<void>;
|
||||
/**
|
||||
* Sets the avatar color for the given user, meant to be used via sync.
|
||||
*
|
||||
* @remarks This is meant to be used for getting an updated avatar color from
|
||||
* the sync endpoint. If the user is changing their avatar color
|
||||
* on device, you should instead call {@link setAvatarColor}.
|
||||
*
|
||||
* @param userId The user id for the user to set the avatar color for
|
||||
* @param color The color to set the avatar color to
|
||||
*/
|
||||
abstract setSyncAvatarColor(userId: UserId, color: string): Promise<void>;
|
||||
/**
|
||||
* Gets the avatar color of the specified user.
|
||||
*
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, UserKey } from "../../types/key";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
|
||||
export abstract class DeviceTrustCryptoServiceAbstraction {
|
||||
supportsDeviceTrust$: Observable<boolean>;
|
||||
/**
|
||||
* @description Retrieves the users choice to trust the device which can only happen after decryption
|
||||
* Note: this value should only be used once and then reset
|
||||
*/
|
||||
getShouldTrustDevice: () => Promise<boolean | null>;
|
||||
setShouldTrustDevice: (value: boolean) => Promise<void>;
|
||||
getShouldTrustDevice: (userId: UserId) => Promise<boolean | null>;
|
||||
setShouldTrustDevice: (userId: UserId, value: boolean) => Promise<void>;
|
||||
|
||||
trustDeviceIfRequired: () => Promise<void>;
|
||||
trustDeviceIfRequired: (userId: UserId) => Promise<void>;
|
||||
|
||||
trustDevice: () => Promise<DeviceResponse>;
|
||||
getDeviceKey: () => Promise<DeviceKey>;
|
||||
trustDevice: (userId: UserId) => Promise<DeviceResponse>;
|
||||
|
||||
/** Retrieves the device key if it exists from state or secure storage if supported for the active user. */
|
||||
getDeviceKey: (userId: UserId) => Promise<DeviceKey | null>;
|
||||
decryptUserKeyWithDeviceKey: (
|
||||
userId: UserId,
|
||||
encryptedDevicePrivateKey: EncString,
|
||||
encryptedUserKey: EncString,
|
||||
deviceKey?: DeviceKey,
|
||||
deviceKey: DeviceKey,
|
||||
) => Promise<UserKey | null>;
|
||||
rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise<void>;
|
||||
|
||||
supportsDeviceTrust: () => Promise<boolean>;
|
||||
rotateDevicesTrust: (
|
||||
userId: UserId,
|
||||
newUserKey: UserKey,
|
||||
masterPasswordHash: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -15,5 +15,4 @@ export abstract class KeyConnectorService {
|
||||
setConvertAccountRequired: (status: boolean) => Promise<void>;
|
||||
getConvertAccountRequired: () => Promise<boolean>;
|
||||
removeConvertAccountRequired: () => Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export abstract class LoginService {
|
||||
getEmail: () => string;
|
||||
getRememberEmail: () => boolean;
|
||||
setEmail: (value: string) => void;
|
||||
setRememberEmail: (value: boolean) => void;
|
||||
clearValues: () => void;
|
||||
saveEmailSettings: () => Promise<void>;
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DecodedAccessToken } from "../services/token.service";
|
||||
|
||||
export abstract class TokenService {
|
||||
/**
|
||||
* Returns an observable that emits a boolean indicating whether the user has an access token.
|
||||
* @param userId The user id to check for an access token.
|
||||
*/
|
||||
abstract hasAccessToken$(userId: UserId): Observable<boolean>;
|
||||
/**
|
||||
* Sets the access token, refresh token, API Key Client ID, and API Key Client Secret in memory or disk
|
||||
* based on the given vaultTimeoutAction and vaultTimeout and the derived access token user id.
|
||||
@@ -10,17 +17,18 @@ export abstract class TokenService {
|
||||
* Note 2: this method also enforces always setting the access token and the refresh token together as
|
||||
* we can retrieve the user id required to set the refresh token from the access token for efficiency.
|
||||
* @param accessToken The access token to set.
|
||||
* @param refreshToken The refresh token to set.
|
||||
* @param clientIdClientSecret The API Key Client ID and Client Secret to set.
|
||||
* @param vaultTimeoutAction The action to take when the vault times out.
|
||||
* @param vaultTimeout The timeout for the vault.
|
||||
* @param refreshToken The optional refresh token to set. Note: this is undefined when using the CLI Login Via API Key flow
|
||||
* @param clientIdClientSecret The API Key Client ID and Client Secret to set.
|
||||
*
|
||||
* @returns A promise that resolves when the tokens have been set.
|
||||
*/
|
||||
setTokens: (
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
vaultTimeoutAction: VaultTimeoutAction,
|
||||
vaultTimeout: number | null,
|
||||
refreshToken?: string,
|
||||
clientIdClientSecret?: [string, string],
|
||||
) => Promise<void>;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export class AuthResult {
|
||||
// TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal
|
||||
/**
|
||||
* @deprecated
|
||||
* Replace with using AccountDecryptionOptions to determine if the user does
|
||||
* Replace with using UserDecryptionOptions to determine if the user does
|
||||
* not have a master password and is not using Key Connector.
|
||||
* */
|
||||
resetMasterPassword = false;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
export class EnvironmentUrls {
|
||||
base: string = null;
|
||||
api: string = null;
|
||||
identity: string = null;
|
||||
icons: string = null;
|
||||
notifications: string = null;
|
||||
events: string = null;
|
||||
webVault: string = null;
|
||||
keyConnector: string = null;
|
||||
|
||||
static fromJSON(obj: Jsonify<EnvironmentUrls>): EnvironmentUrls {
|
||||
return Object.assign(new EnvironmentUrls(), obj);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export class KeyConnectorUserDecryptionOption {
|
||||
constructor(public keyConnectorUrl: string) {}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export class TrustedDeviceUserDecryptionOption {
|
||||
constructor(
|
||||
public hasAdminApproval: boolean,
|
||||
public hasLoginApprovingDevice: boolean,
|
||||
public hasManageResetPasswordPermission: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -5,14 +5,15 @@ import {
|
||||
IHubProtocol,
|
||||
} from "@microsoft/signalr";
|
||||
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "../../../../auth/src/common/abstractions/login-strategy.service";
|
||||
import { AuthRequestServiceAbstraction } from "../../../../auth/src/common/abstractions";
|
||||
import { NotificationType } from "../../enums";
|
||||
import {
|
||||
AuthRequestPushNotification,
|
||||
NotificationResponse,
|
||||
} from "../../models/response/notification.response";
|
||||
import { EnvironmentService } from "../../platform/abstractions/environment.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymous-hub.service";
|
||||
|
||||
export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
@@ -21,12 +22,11 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async createHubConnection(token: string) {
|
||||
this.url = this.environmentService.getNotificationsUrl();
|
||||
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
|
||||
|
||||
this.anonHubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.url + "/anonymous-hub?Token=" + token, {
|
||||
@@ -36,26 +36,25 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
.withHubProtocol(new MessagePackHubProtocol() as IHubProtocol)
|
||||
.build();
|
||||
|
||||
this.anonHubConnection.start().catch((error) => this.logService.error(error));
|
||||
await this.anonHubConnection.start();
|
||||
|
||||
this.anonHubConnection.on("AuthRequestResponseRecieved", (data: any) => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.ProcessNotification(new NotificationResponse(data));
|
||||
});
|
||||
}
|
||||
|
||||
stopHubConnection() {
|
||||
async stopHubConnection() {
|
||||
if (this.anonHubConnection) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.anonHubConnection.stop();
|
||||
await this.anonHubConnection.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async ProcessNotification(notification: NotificationResponse) {
|
||||
await this.loginStrategyService.sendAuthRequestPushNotification(
|
||||
notification.payload as AuthRequestPushNotification,
|
||||
);
|
||||
private ProcessNotification(notification: NotificationResponse) {
|
||||
switch (notification.type) {
|
||||
case NotificationType.AuthRequestResponse:
|
||||
this.authRequestService.sendAuthRequestPushNotification(
|
||||
notification.payload as AuthRequestPushNotification,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
161
libs/common/src/auth/services/auth.service.spec.ts
Normal file
161
libs/common/src/auth/services/auth.service.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
makeStaticByteArray,
|
||||
mockAccountServiceWith,
|
||||
trackEmissions,
|
||||
} from "../../../spec";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { TokenService } from "../abstractions/token.service";
|
||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||
|
||||
import { AuthService } from "./auth.service";
|
||||
|
||||
describe("AuthService", () => {
|
||||
let sut: AuthService;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const userKey = new SymmetricCryptoKey(makeStaticByteArray(32) as Uint8Array) as UserKey;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
messagingService = mock();
|
||||
cryptoService = mock();
|
||||
apiService = mock();
|
||||
stateService = mock();
|
||||
tokenService = mock();
|
||||
|
||||
sut = new AuthService(
|
||||
accountService,
|
||||
messagingService,
|
||||
cryptoService,
|
||||
apiService,
|
||||
stateService,
|
||||
tokenService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("activeAccountStatus$", () => {
|
||||
const accountInfo = {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: userId,
|
||||
email: "email",
|
||||
name: "name",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
accountService.activeAccountSubject.next(accountInfo);
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||
});
|
||||
|
||||
it("emits LoggedOut when there is no active account", async () => {
|
||||
accountService.activeAccountSubject.next(undefined);
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits LoggedOut when there is no access token", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(false));
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits LoggedOut when there is no access token but has a user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(false));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits Locked when there is an access token and no user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Locked);
|
||||
});
|
||||
|
||||
it("emits Unlocked when there is an access token and user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Unlocked);
|
||||
});
|
||||
|
||||
it("follows the current active user", async () => {
|
||||
const accountInfo2 = {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: Utils.newGuid() as UserId,
|
||||
email: "email2",
|
||||
name: "name2",
|
||||
};
|
||||
|
||||
const emissions = trackEmissions(sut.activeAccountStatus$);
|
||||
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
|
||||
accountService.activeAccountSubject.next(accountInfo2);
|
||||
|
||||
expect(emissions).toEqual([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authStatusFor$", () => {
|
||||
beforeEach(() => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||
});
|
||||
|
||||
it("emits LoggedOut when userId is null", async () => {
|
||||
expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits LoggedOut when there is no access token", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(false));
|
||||
|
||||
expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits Locked when there is an access token and no user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||
|
||||
expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(AuthenticationStatus.Locked);
|
||||
});
|
||||
|
||||
it("emits Unlocked when there is an access token and user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
|
||||
|
||||
expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(
|
||||
AuthenticationStatus.Unlocked,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,67 @@
|
||||
import {
|
||||
Observable,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "../../platform/enums";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AccountService } from "../abstractions/account.service";
|
||||
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
|
||||
import { TokenService } from "../abstractions/token.service";
|
||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||
|
||||
export class AuthService implements AuthServiceAbstraction {
|
||||
activeAccountStatus$: Observable<AuthenticationStatus>;
|
||||
|
||||
constructor(
|
||||
protected accountService: AccountService,
|
||||
protected messagingService: MessagingService,
|
||||
protected cryptoService: CryptoService,
|
||||
protected apiService: ApiService,
|
||||
protected stateService: StateService,
|
||||
) {}
|
||||
private tokenService: TokenService,
|
||||
) {
|
||||
this.activeAccountStatus$ = this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
switchMap((userId) => {
|
||||
return this.authStatusFor$(userId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
authStatusFor$(userId: UserId): Observable<AuthenticationStatus> {
|
||||
if (userId == null) {
|
||||
return of(AuthenticationStatus.LoggedOut);
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.cryptoService.getInMemoryUserKeyFor$(userId),
|
||||
this.tokenService.hasAccessToken$(userId),
|
||||
]).pipe(
|
||||
map(([userKey, hasAccessToken]) => {
|
||||
if (!hasAccessToken) {
|
||||
return AuthenticationStatus.LoggedOut;
|
||||
}
|
||||
|
||||
if (!userKey) {
|
||||
return AuthenticationStatus.Locked;
|
||||
}
|
||||
|
||||
return AuthenticationStatus.Unlocked;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
}
|
||||
|
||||
async getAuthStatus(userId?: string): Promise<AuthenticationStatus> {
|
||||
// If we don't have an access token or userId, we're logged out
|
||||
|
||||
@@ -27,6 +27,10 @@ export class AvatarService implements AvatarServiceAbstraction {
|
||||
await this.stateProvider.setUserState(AVATAR_COLOR, avatarColor);
|
||||
}
|
||||
|
||||
async setSyncAvatarColor(userId: UserId, color: string): Promise<void> {
|
||||
await this.stateProvider.getUser(userId, AVATAR_COLOR).update(() => color);
|
||||
}
|
||||
|
||||
getUserAvatarColor$(userId: UserId): Observable<string | null> {
|
||||
return this.stateProvider.getUser(userId, AVATAR_COLOR).state$;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
|
||||
@@ -7,9 +9,13 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { DEVICE_TRUST_DISK_LOCAL, KeyDefinition, StateProvider } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserKey, DeviceKey } from "../../types/key";
|
||||
import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
@@ -20,41 +26,87 @@ import {
|
||||
UpdateDevicesTrustRequest,
|
||||
} from "../models/request/update-devices-trust.request";
|
||||
|
||||
/** Uses disk storage so that the device key can persist after log out and tab removal. */
|
||||
export const DEVICE_KEY = new KeyDefinition<DeviceKey>(DEVICE_TRUST_DISK_LOCAL, "deviceKey", {
|
||||
deserializer: (deviceKey) => SymmetricCryptoKey.fromJSON(deviceKey) as DeviceKey,
|
||||
});
|
||||
|
||||
/** Uses disk storage so that the shouldTrustDevice bool can persist across login. */
|
||||
export const SHOULD_TRUST_DEVICE = new KeyDefinition<boolean>(
|
||||
DEVICE_TRUST_DISK_LOCAL,
|
||||
"shouldTrustDevice",
|
||||
{
|
||||
deserializer: (shouldTrustDevice) => shouldTrustDevice,
|
||||
},
|
||||
);
|
||||
|
||||
export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction {
|
||||
private readonly platformSupportsSecureStorage =
|
||||
this.platformUtilsService.supportsSecureStorage();
|
||||
private readonly deviceKeySecureStorageKey: string = "_deviceKey";
|
||||
|
||||
supportsDeviceTrust$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private cryptoService: CryptoService,
|
||||
private encryptService: EncryptService,
|
||||
private stateService: StateService,
|
||||
private appIdService: AppIdService,
|
||||
private devicesApiService: DevicesApiServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
private stateProvider: StateProvider,
|
||||
private secureStorageService: AbstractStorageService,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
) {
|
||||
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
|
||||
map((options) => options?.trustedDeviceOption != null ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Retrieves the users choice to trust the device which can only happen after decryption
|
||||
* Note: this value should only be used once and then reset
|
||||
*/
|
||||
async getShouldTrustDevice(): Promise<boolean> {
|
||||
return await this.stateService.getShouldTrustDevice();
|
||||
async getShouldTrustDevice(userId: UserId): Promise<boolean> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot get should trust device.");
|
||||
}
|
||||
|
||||
const shouldTrustDevice = await firstValueFrom(
|
||||
this.stateProvider.getUserState$(SHOULD_TRUST_DEVICE, userId),
|
||||
);
|
||||
|
||||
return shouldTrustDevice;
|
||||
}
|
||||
|
||||
async setShouldTrustDevice(value: boolean): Promise<void> {
|
||||
await this.stateService.setShouldTrustDevice(value);
|
||||
async setShouldTrustDevice(userId: UserId, value: boolean): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot set should trust device.");
|
||||
}
|
||||
|
||||
await this.stateProvider.setUserState(SHOULD_TRUST_DEVICE, value, userId);
|
||||
}
|
||||
|
||||
async trustDeviceIfRequired(): Promise<void> {
|
||||
const shouldTrustDevice = await this.getShouldTrustDevice();
|
||||
async trustDeviceIfRequired(userId: UserId): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot trust device if required.");
|
||||
}
|
||||
|
||||
const shouldTrustDevice = await this.getShouldTrustDevice(userId);
|
||||
if (shouldTrustDevice) {
|
||||
await this.trustDevice();
|
||||
await this.trustDevice(userId);
|
||||
// reset the trust choice
|
||||
await this.setShouldTrustDevice(false);
|
||||
await this.setShouldTrustDevice(userId, false);
|
||||
}
|
||||
}
|
||||
|
||||
async trustDevice(): Promise<DeviceResponse> {
|
||||
async trustDevice(userId: UserId): Promise<DeviceResponse> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot trust device.");
|
||||
}
|
||||
|
||||
// Attempt to get user key
|
||||
const userKey: UserKey = await this.cryptoService.getUserKey();
|
||||
|
||||
@@ -95,15 +147,23 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
|
||||
);
|
||||
|
||||
// store device key in local/secure storage if enc keys posted to server successfully
|
||||
await this.setDeviceKey(deviceKey);
|
||||
await this.setDeviceKey(userId, deviceKey);
|
||||
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
|
||||
|
||||
return deviceResponse;
|
||||
}
|
||||
|
||||
async rotateDevicesTrust(newUserKey: UserKey, masterPasswordHash: string): Promise<void> {
|
||||
const currentDeviceKey = await this.getDeviceKey();
|
||||
async rotateDevicesTrust(
|
||||
userId: UserId,
|
||||
newUserKey: UserKey,
|
||||
masterPasswordHash: string,
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot rotate device's trust.");
|
||||
}
|
||||
|
||||
const currentDeviceKey = await this.getDeviceKey(userId);
|
||||
if (currentDeviceKey == null) {
|
||||
// If the current device doesn't have a device key available to it, then we can't
|
||||
// rotate any trust at all, so early return.
|
||||
@@ -156,26 +216,59 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
|
||||
await this.devicesApiService.updateTrust(trustRequest, deviceIdentifier);
|
||||
}
|
||||
|
||||
async getDeviceKey(): Promise<DeviceKey> {
|
||||
return await this.stateService.getDeviceKey();
|
||||
async getDeviceKey(userId: UserId): Promise<DeviceKey | null> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot get device key.");
|
||||
}
|
||||
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
const deviceKeyB64 = await this.secureStorageService.get<
|
||||
ReturnType<SymmetricCryptoKey["toJSON"]>
|
||||
>(`${userId}${this.deviceKeySecureStorageKey}`, this.getSecureStorageOptions(userId));
|
||||
|
||||
const deviceKey = SymmetricCryptoKey.fromJSON(deviceKeyB64) as DeviceKey;
|
||||
|
||||
return deviceKey;
|
||||
}
|
||||
|
||||
const deviceKey = await firstValueFrom(this.stateProvider.getUserState$(DEVICE_KEY, userId));
|
||||
|
||||
return deviceKey;
|
||||
}
|
||||
|
||||
private async setDeviceKey(deviceKey: DeviceKey | null): Promise<void> {
|
||||
await this.stateService.setDeviceKey(deviceKey);
|
||||
private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot set device key.");
|
||||
}
|
||||
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
await this.secureStorageService.save<DeviceKey>(
|
||||
`${userId}${this.deviceKeySecureStorageKey}`,
|
||||
deviceKey,
|
||||
this.getSecureStorageOptions(userId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stateProvider.setUserState(DEVICE_KEY, deviceKey?.toJSON(), userId);
|
||||
}
|
||||
|
||||
private async makeDeviceKey(): Promise<DeviceKey> {
|
||||
// Create 512-bit device key
|
||||
return (await this.keyGenerationService.createKey(512)) as DeviceKey;
|
||||
const deviceKey = (await this.keyGenerationService.createKey(512)) as DeviceKey;
|
||||
|
||||
return deviceKey;
|
||||
}
|
||||
|
||||
async decryptUserKeyWithDeviceKey(
|
||||
userId: UserId,
|
||||
encryptedDevicePrivateKey: EncString,
|
||||
encryptedUserKey: EncString,
|
||||
deviceKey?: DeviceKey,
|
||||
deviceKey: DeviceKey,
|
||||
): Promise<UserKey | null> {
|
||||
// If device key provided use it, otherwise try to retrieve from storage
|
||||
deviceKey ||= await this.getDeviceKey();
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot decrypt user key with device key.");
|
||||
}
|
||||
|
||||
if (!deviceKey) {
|
||||
// User doesn't have a device key anymore so device is untrusted
|
||||
@@ -198,14 +291,17 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
|
||||
return new SymmetricCryptoKey(userKey) as UserKey;
|
||||
} catch (e) {
|
||||
// If either decryption effort fails, we want to remove the device key
|
||||
await this.setDeviceKey(null);
|
||||
await this.setDeviceKey(userId, null);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async supportsDeviceTrust(): Promise<boolean> {
|
||||
const decryptionOptions = await this.stateService.getAccountDecryptionOptions();
|
||||
return decryptionOptions?.trustedDeviceOption != null;
|
||||
private getSecureStorageOptions(userId: UserId): StorageOptions {
|
||||
return {
|
||||
storageLocation: StorageLocation.Disk,
|
||||
useSecureStorage: true,
|
||||
userId: userId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { matches, mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
|
||||
import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeActiveUserState } from "../../../spec/fake-state";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
import { DeviceType } from "../../enums";
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
|
||||
@@ -9,18 +15,26 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
import { EncryptionType } from "../../platform/enums/encryption-type.enum";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, UserKey } from "../../types/key";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
|
||||
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
|
||||
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
|
||||
|
||||
import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation";
|
||||
import {
|
||||
SHOULD_TRUST_DEVICE,
|
||||
DEVICE_KEY,
|
||||
DeviceTrustCryptoService,
|
||||
} from "./device-trust-crypto.service.implementation";
|
||||
|
||||
describe("deviceTrustCryptoService", () => {
|
||||
let deviceTrustCryptoService: DeviceTrustCryptoService;
|
||||
@@ -29,26 +43,34 @@ describe("deviceTrustCryptoService", () => {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const stateService = mock<StateService>();
|
||||
const appIdService = mock<AppIdService>();
|
||||
const devicesApiService = mock<DevicesApiServiceAbstraction>();
|
||||
const i18nService = mock<I18nService>();
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
const secureStorageService = mock<AbstractStorageService>();
|
||||
|
||||
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
const decryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
|
||||
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
const deviceKeyPartialSecureStorageKey = "_deviceKey";
|
||||
const deviceKeySecureStorageKey = `${mockUserId}${deviceKeyPartialSecureStorageKey}`;
|
||||
|
||||
const secureStorageOptions: StorageOptions = {
|
||||
storageLocation: StorageLocation.Disk,
|
||||
useSecureStorage: true,
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
deviceTrustCryptoService = new DeviceTrustCryptoService(
|
||||
keyGenerationService,
|
||||
cryptoFunctionService,
|
||||
cryptoService,
|
||||
encryptService,
|
||||
stateService,
|
||||
appIdService,
|
||||
devicesApiService,
|
||||
i18nService,
|
||||
platformUtilsService,
|
||||
);
|
||||
const supportsSecureStorage = false; // default to false; tests will override as needed
|
||||
// By default all the tests will have a mocked active user in state provider.
|
||||
deviceTrustCryptoService = createDeviceTrustCryptoService(mockUserId, supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
@@ -57,27 +79,26 @@ describe("deviceTrustCryptoService", () => {
|
||||
|
||||
describe("User Trust Device Choice For Decryption", () => {
|
||||
describe("getShouldTrustDevice", () => {
|
||||
it("gets the user trust device choice for decryption from the state service", async () => {
|
||||
const stateSvcGetShouldTrustDeviceSpy = jest.spyOn(stateService, "getShouldTrustDevice");
|
||||
it("gets the user trust device choice for decryption", async () => {
|
||||
const newValue = true;
|
||||
|
||||
const expectedValue = true;
|
||||
stateSvcGetShouldTrustDeviceSpy.mockResolvedValue(expectedValue);
|
||||
const result = await deviceTrustCryptoService.getShouldTrustDevice();
|
||||
await stateProvider.setUserState(SHOULD_TRUST_DEVICE, newValue, mockUserId);
|
||||
|
||||
expect(stateSvcGetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(expectedValue);
|
||||
const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId);
|
||||
|
||||
expect(result).toEqual(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setShouldTrustDevice", () => {
|
||||
it("sets the user trust device choice for decryption in the state service", async () => {
|
||||
const stateSvcSetShouldTrustDeviceSpy = jest.spyOn(stateService, "setShouldTrustDevice");
|
||||
it("sets the user trust device choice for decryption ", async () => {
|
||||
await stateProvider.setUserState(SHOULD_TRUST_DEVICE, false, mockUserId);
|
||||
|
||||
const newValue = true;
|
||||
await deviceTrustCryptoService.setShouldTrustDevice(newValue);
|
||||
await deviceTrustCryptoService.setShouldTrustDevice(mockUserId, newValue);
|
||||
|
||||
expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledWith(newValue);
|
||||
const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId);
|
||||
expect(result).toEqual(newValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -88,11 +109,11 @@ describe("deviceTrustCryptoService", () => {
|
||||
jest.spyOn(deviceTrustCryptoService, "trustDevice").mockResolvedValue({} as DeviceResponse);
|
||||
jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice").mockResolvedValue();
|
||||
|
||||
await deviceTrustCryptoService.trustDeviceIfRequired();
|
||||
await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId);
|
||||
|
||||
expect(deviceTrustCryptoService.getShouldTrustDevice).toHaveBeenCalledTimes(1);
|
||||
expect(deviceTrustCryptoService.trustDevice).toHaveBeenCalledTimes(1);
|
||||
expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(false);
|
||||
expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(mockUserId, false);
|
||||
});
|
||||
|
||||
it("should not trust device nor reset when getShouldTrustDevice returns false", async () => {
|
||||
@@ -102,7 +123,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
const trustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "trustDevice");
|
||||
const setShouldTrustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice");
|
||||
|
||||
await deviceTrustCryptoService.trustDeviceIfRequired();
|
||||
await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId);
|
||||
|
||||
expect(getShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(trustDeviceSpy).not.toHaveBeenCalled();
|
||||
@@ -116,53 +137,140 @@ describe("deviceTrustCryptoService", () => {
|
||||
|
||||
describe("getDeviceKey", () => {
|
||||
let existingDeviceKey: DeviceKey;
|
||||
let stateSvcGetDeviceKeySpy: jest.SpyInstance;
|
||||
let existingDeviceKeyB64: { keyB64: string };
|
||||
|
||||
beforeEach(() => {
|
||||
existingDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
stateSvcGetDeviceKeySpy = jest.spyOn(stateService, "getDeviceKey");
|
||||
existingDeviceKeyB64 = existingDeviceKey.toJSON();
|
||||
});
|
||||
|
||||
it("returns null when there is not an existing device key", async () => {
|
||||
stateSvcGetDeviceKeySpy.mockResolvedValue(null);
|
||||
describe("Secure Storage not supported", () => {
|
||||
it("returns null when there is not an existing device key", async () => {
|
||||
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
|
||||
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey();
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
|
||||
|
||||
expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(deviceKey).toBeNull();
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(deviceKey).toBeNull();
|
||||
it("returns the device key when there is an existing device key", async () => {
|
||||
await stateProvider.setUserState(DEVICE_KEY, existingDeviceKey, mockUserId);
|
||||
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
|
||||
|
||||
expect(deviceKey).not.toBeNull();
|
||||
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
|
||||
expect(deviceKey).toEqual(existingDeviceKey);
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the device key when there is an existing device key", async () => {
|
||||
stateSvcGetDeviceKeySpy.mockResolvedValue(existingDeviceKey);
|
||||
describe("Secure Storage supported", () => {
|
||||
beforeEach(() => {
|
||||
const supportsSecureStorage = true;
|
||||
deviceTrustCryptoService = createDeviceTrustCryptoService(
|
||||
mockUserId,
|
||||
supportsSecureStorage,
|
||||
);
|
||||
});
|
||||
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey();
|
||||
it("returns null when there is not an existing device key for the passed in user id", async () => {
|
||||
secureStorageService.get.mockResolvedValue(null);
|
||||
|
||||
expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
// Act
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
|
||||
|
||||
expect(deviceKey).not.toBeNull();
|
||||
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
|
||||
expect(deviceKey).toEqual(existingDeviceKey);
|
||||
// Assert
|
||||
expect(deviceKey).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the device key when there is an existing device key for the passed in user id", async () => {
|
||||
// Arrange
|
||||
secureStorageService.get.mockResolvedValue(existingDeviceKeyB64);
|
||||
|
||||
// Act
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(deviceKey).not.toBeNull();
|
||||
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
|
||||
expect(deviceKey).toEqual(existingDeviceKey);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws an error when no user id is passed in", async () => {
|
||||
await expect(deviceTrustCryptoService.getDeviceKey(null)).rejects.toThrow(
|
||||
"UserId is required. Cannot get device key.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setDeviceKey", () => {
|
||||
it("sets the device key in the state service", async () => {
|
||||
const stateSvcSetDeviceKeySpy = jest.spyOn(stateService, "setDeviceKey");
|
||||
describe("Secure Storage not supported", () => {
|
||||
it("successfully sets the device key in state provider", async () => {
|
||||
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
|
||||
|
||||
const deviceKey = new SymmetricCryptoKey(
|
||||
const newDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
// TypeScript will allow calling private methods if the object is of type 'any'
|
||||
// This is a hacky workaround, but it allows for cleaner tests
|
||||
await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey);
|
||||
|
||||
expect(stateProvider.mock.setUserState).toHaveBeenLastCalledWith(
|
||||
DEVICE_KEY,
|
||||
newDeviceKey.toJSON(),
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("Secure Storage supported", () => {
|
||||
beforeEach(() => {
|
||||
const supportsSecureStorage = true;
|
||||
deviceTrustCryptoService = createDeviceTrustCryptoService(
|
||||
mockUserId,
|
||||
supportsSecureStorage,
|
||||
);
|
||||
});
|
||||
|
||||
it("successfully sets the device key in secure storage", async () => {
|
||||
// Arrange
|
||||
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(null);
|
||||
|
||||
const newDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
// Act
|
||||
// TypeScript will allow calling private methods if the object is of type 'any'
|
||||
// This is a hacky workaround, but it allows for cleaner tests
|
||||
await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey);
|
||||
|
||||
// Assert
|
||||
expect(stateProvider.mock.setUserState).not.toHaveBeenCalledTimes(2);
|
||||
expect(secureStorageService.save).toHaveBeenCalledWith(
|
||||
deviceKeySecureStorageKey,
|
||||
newDeviceKey,
|
||||
secureStorageOptions,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
const newDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
// TypeScript will allow calling private methods if the object is of type 'any'
|
||||
// This is a hacky workaround, but it allows for cleaner tests
|
||||
await (deviceTrustCryptoService as any).setDeviceKey(deviceKey);
|
||||
|
||||
expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledWith(deviceKey);
|
||||
await expect(
|
||||
(deviceTrustCryptoService as any).setDeviceKey(null, newDeviceKey),
|
||||
).rejects.toThrow("UserId is required. Cannot set device key.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,7 +398,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
});
|
||||
|
||||
it("calls the required methods with the correct arguments and returns a DeviceResponse", async () => {
|
||||
const response = await deviceTrustCryptoService.trustDevice();
|
||||
const response = await deviceTrustCryptoService.trustDevice(mockUserId);
|
||||
|
||||
expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -321,7 +429,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
// setup the spy to return null
|
||||
cryptoSvcGetUserKeySpy.mockResolvedValue(null);
|
||||
// check if the expected error is thrown
|
||||
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(
|
||||
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
"User symmetric key not found",
|
||||
);
|
||||
|
||||
@@ -331,7 +439,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
// setup the spy to return undefined
|
||||
cryptoSvcGetUserKeySpy.mockResolvedValue(undefined);
|
||||
// check if the expected error is thrown
|
||||
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(
|
||||
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
"User symmetric key not found",
|
||||
);
|
||||
});
|
||||
@@ -371,7 +479,9 @@ describe("deviceTrustCryptoService", () => {
|
||||
it(`throws an error if ${method} fails`, async () => {
|
||||
const methodSpy = spy();
|
||||
methodSpy.mockRejectedValue(new Error(errorText));
|
||||
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(errorText);
|
||||
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
errorText,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([null, undefined])(
|
||||
@@ -379,11 +489,17 @@ describe("deviceTrustCryptoService", () => {
|
||||
async (invalidValue) => {
|
||||
const methodSpy = spy();
|
||||
methodSpy.mockResolvedValue(invalidValue);
|
||||
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow();
|
||||
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
await expect(deviceTrustCryptoService.trustDevice(null)).rejects.toThrow(
|
||||
"UserId is required. Cannot trust device.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptUserKeyWithDeviceKey", () => {
|
||||
@@ -412,19 +528,26 @@ describe("deviceTrustCryptoService", () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when device key isn't provided and isn't in state", async () => {
|
||||
const getDeviceKeySpy = jest
|
||||
.spyOn(deviceTrustCryptoService, "getDeviceKey")
|
||||
.mockResolvedValue(null);
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
null,
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
mockDeviceKey,
|
||||
),
|
||||
).rejects.toThrow("UserId is required. Cannot decrypt user key with device key.");
|
||||
});
|
||||
|
||||
it("returns null when device key isn't provided", async () => {
|
||||
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
mockUserId,
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
mockDeviceKey,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
|
||||
expect(getDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("successfully returns the user key when provided keys (including device key) can decrypt it", async () => {
|
||||
@@ -436,6 +559,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
|
||||
|
||||
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
mockUserId,
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
mockDeviceKey,
|
||||
@@ -446,31 +570,6 @@ describe("deviceTrustCryptoService", () => {
|
||||
expect(rsaDecryptSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("successfully returns the user key when a device key is not provided (retrieves device key from state)", async () => {
|
||||
const getDeviceKeySpy = jest
|
||||
.spyOn(deviceTrustCryptoService, "getDeviceKey")
|
||||
.mockResolvedValue(mockDeviceKey);
|
||||
|
||||
const decryptToBytesSpy = jest
|
||||
.spyOn(encryptService, "decryptToBytes")
|
||||
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
|
||||
const rsaDecryptSpy = jest
|
||||
.spyOn(cryptoService, "rsaDecrypt")
|
||||
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
|
||||
|
||||
// Call without providing a device key
|
||||
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
);
|
||||
|
||||
expect(getDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result).toEqual(mockUserKey);
|
||||
expect(decryptToBytesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(rsaDecryptSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null and removes device key when the decryption fails", async () => {
|
||||
const decryptToBytesSpy = jest
|
||||
.spyOn(encryptService, "decryptToBytes")
|
||||
@@ -478,6 +577,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
const setDeviceKeySpy = jest.spyOn(deviceTrustCryptoService as any, "setDeviceKey");
|
||||
|
||||
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
mockUserId,
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
mockDeviceKey,
|
||||
@@ -486,7 +586,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
expect(result).toBeNull();
|
||||
expect(decryptToBytesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(setDeviceKeySpy).toHaveBeenCalledWith(null);
|
||||
expect(setDeviceKeySpy).toHaveBeenCalledWith(mockUserId, null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -504,19 +604,28 @@ describe("deviceTrustCryptoService", () => {
|
||||
cryptoService.activeUserKey$ = of(fakeNewUserKey);
|
||||
});
|
||||
|
||||
it("does an early exit when the current device is not a trusted device", async () => {
|
||||
stateService.getDeviceKey.mockResolvedValue(null);
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustCryptoService.rotateDevicesTrust(null, fakeNewUserKey, ""),
|
||||
).rejects.toThrow("UserId is required. Cannot rotate device's trust.");
|
||||
});
|
||||
|
||||
await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "");
|
||||
it("does an early exit when the current device is not a trusted device", async () => {
|
||||
const deviceKeyState: FakeActiveUserState<DeviceKey> =
|
||||
stateProvider.activeUser.getFake(DEVICE_KEY);
|
||||
deviceKeyState.nextState(null);
|
||||
|
||||
await deviceTrustCryptoService.rotateDevicesTrust(mockUserId, fakeNewUserKey, "");
|
||||
|
||||
expect(devicesApiService.updateTrust).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("is on a trusted device", () => {
|
||||
beforeEach(() => {
|
||||
stateService.getDeviceKey.mockResolvedValue(
|
||||
new SymmetricCryptoKey(new Uint8Array(deviceKeyBytesLength)) as DeviceKey,
|
||||
);
|
||||
beforeEach(async () => {
|
||||
const mockDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength),
|
||||
) as DeviceKey;
|
||||
await stateProvider.setUserState(DEVICE_KEY, mockDeviceKey, mockUserId);
|
||||
});
|
||||
|
||||
it("rotates current device keys and calls api service when the current device is trusted", async () => {
|
||||
@@ -582,7 +691,11 @@ describe("deviceTrustCryptoService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "my_password_hash");
|
||||
await deviceTrustCryptoService.rotateDevicesTrust(
|
||||
mockUserId,
|
||||
fakeNewUserKey,
|
||||
"my_password_hash",
|
||||
);
|
||||
|
||||
expect(devicesApiService.updateTrust).toHaveBeenCalledWith(
|
||||
matches((updateTrustModel: UpdateDevicesTrustRequest) => {
|
||||
@@ -598,4 +711,32 @@ describe("deviceTrustCryptoService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers
|
||||
function createDeviceTrustCryptoService(
|
||||
mockUserId: UserId | null,
|
||||
supportsSecureStorage: boolean,
|
||||
) {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(supportsSecureStorage);
|
||||
|
||||
decryptionOptions.next({} as any);
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
|
||||
|
||||
return new DeviceTrustCryptoService(
|
||||
keyGenerationService,
|
||||
cryptoFunctionService,
|
||||
cryptoService,
|
||||
encryptService,
|
||||
appIdService,
|
||||
devicesApiService,
|
||||
i18nService,
|
||||
platformUtilsService,
|
||||
stateProvider,
|
||||
secureStorageService,
|
||||
userDecryptionOptionsService,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
376
libs/common/src/auth/services/key-connector.service.spec.ts
Normal file
376
libs/common/src/auth/services/key-connector.service.spec.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationData } from "../../admin-console/models/data/organization.data";
|
||||
import { Organization } from "../../admin-console/models/domain/organization";
|
||||
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { KeyGenerationService } from "../../platform/services/key-generation.service";
|
||||
import { OrganizationId, UserId } from "../../types/guid";
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request";
|
||||
import { KeyConnectorUserKeyResponse } from "../models/response/key-connector-user-key.response";
|
||||
|
||||
import {
|
||||
USES_KEY_CONNECTOR,
|
||||
CONVERT_ACCOUNT_TO_KEY_CONNECTOR,
|
||||
KeyConnectorService,
|
||||
} from "./key-connector.service";
|
||||
import { TokenService } from "./token.service";
|
||||
|
||||
describe("KeyConnectorService", () => {
|
||||
let keyConnectorService: KeyConnectorService;
|
||||
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const apiService = mock<ApiService>();
|
||||
const tokenService = mock<TokenService>();
|
||||
const logService = mock<LogService>();
|
||||
const organizationService = mock<OrganizationService>();
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const mockOrgId = Utils.newGuid() as OrganizationId;
|
||||
|
||||
const mockMasterKeyResponse: KeyConnectorUserKeyResponse = new KeyConnectorUserKeyResponse({
|
||||
key: "eO9nVlVl3I3sU6O+CyK0kEkpGtl/auT84Hig2WTXmZtDTqYtKpDvUPfjhgMOHf+KQzx++TVS2AOLYq856Caa7w==",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
keyConnectorService = new KeyConnectorService(
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
logService,
|
||||
organizationService,
|
||||
keyGenerationService,
|
||||
async () => {},
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(keyConnectorService).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("setUsesKeyConnector()", () => {
|
||||
it("should update the usesKeyConnectorState with the provided value", async () => {
|
||||
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
|
||||
state.nextState(false);
|
||||
|
||||
const newValue = true;
|
||||
|
||||
await keyConnectorService.setUsesKeyConnector(newValue);
|
||||
|
||||
expect(await keyConnectorService.getUsesKeyConnector()).toBe(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getManagingOrganization()", () => {
|
||||
it("should return the managing organization with key connector enabled", async () => {
|
||||
// Arrange
|
||||
const orgs = [
|
||||
organizationData(true, true, "https://key-connector-url.com", 2, false),
|
||||
organizationData(false, true, "https://key-connector-url.com", 2, false),
|
||||
organizationData(true, false, "https://key-connector-url.com", 2, false),
|
||||
organizationData(true, true, "https://other-url.com", 2, false),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(orgs[0]);
|
||||
});
|
||||
|
||||
it("should return undefined if no managing organization with key connector enabled is found", async () => {
|
||||
// Arrange
|
||||
const orgs = [
|
||||
organizationData(true, false, "https://key-connector-url.com", 2, false),
|
||||
organizationData(false, false, "https://key-connector-url.com", 2, false),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined if user is Owner or Admin", async () => {
|
||||
// Arrange
|
||||
const orgs = [
|
||||
organizationData(true, true, "https://key-connector-url.com", 0, false),
|
||||
organizationData(true, true, "https://key-connector-url.com", 1, false),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined if user is a Provider", async () => {
|
||||
// Arrange
|
||||
const orgs = [
|
||||
organizationData(true, true, "https://key-connector-url.com", 2, true),
|
||||
organizationData(false, true, "https://key-connector-url.com", 2, true),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConvertAccountRequired()", () => {
|
||||
it("should update the convertAccountToKeyConnectorState with the provided value", async () => {
|
||||
const state = stateProvider.activeUser.getFake(CONVERT_ACCOUNT_TO_KEY_CONNECTOR);
|
||||
state.nextState(false);
|
||||
|
||||
const newValue = true;
|
||||
|
||||
await keyConnectorService.setConvertAccountRequired(newValue);
|
||||
|
||||
expect(await keyConnectorService.getConvertAccountRequired()).toBe(newValue);
|
||||
});
|
||||
|
||||
it("should remove the convertAccountToKeyConnectorState", async () => {
|
||||
const state = stateProvider.activeUser.getFake(CONVERT_ACCOUNT_TO_KEY_CONNECTOR);
|
||||
state.nextState(false);
|
||||
|
||||
const newValue: boolean = null;
|
||||
|
||||
await keyConnectorService.setConvertAccountRequired(newValue);
|
||||
|
||||
expect(await keyConnectorService.getConvertAccountRequired()).toBe(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userNeedsMigration()", () => {
|
||||
it("should return true if the user needs migration", async () => {
|
||||
// token
|
||||
tokenService.getIsExternal.mockResolvedValue(true);
|
||||
|
||||
// create organization object
|
||||
const data = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
organizationService.getAll.mockResolvedValue([data]);
|
||||
|
||||
// uses KeyConnector
|
||||
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
|
||||
state.nextState(false);
|
||||
|
||||
const result = await keyConnectorService.userNeedsMigration();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the user does not need migration", async () => {
|
||||
tokenService.getIsExternal.mockResolvedValue(false);
|
||||
const data = organizationData(false, false, "https://key-connector-url.com", 2, false);
|
||||
organizationService.getAll.mockResolvedValue([data]);
|
||||
|
||||
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
|
||||
state.nextState(true);
|
||||
const result = await keyConnectorService.userNeedsMigration();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMasterKeyFromUrl", () => {
|
||||
it("should set the master key from the provided URL", async () => {
|
||||
// Arrange
|
||||
const url = "https://key-connector-url.com";
|
||||
|
||||
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse);
|
||||
|
||||
// Hard to mock these, but we can generate the same keys
|
||||
const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key);
|
||||
const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey;
|
||||
|
||||
// Act
|
||||
await keyConnectorService.setMasterKeyFromUrl(url);
|
||||
|
||||
// Assert
|
||||
expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url);
|
||||
expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey);
|
||||
});
|
||||
|
||||
it("should handle errors thrown during the process", async () => {
|
||||
// Arrange
|
||||
const url = "https://key-connector-url.com";
|
||||
|
||||
const error = new Error("Failed to get master key");
|
||||
apiService.getMasterKeyFromKeyConnector.mockRejectedValue(error);
|
||||
jest.spyOn(logService, "error");
|
||||
|
||||
try {
|
||||
// Act
|
||||
await keyConnectorService.setMasterKeyFromUrl(url);
|
||||
} catch {
|
||||
// Assert
|
||||
expect(logService.error).toHaveBeenCalledWith(error);
|
||||
expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateUser()", () => {
|
||||
it("should migrate the user to the key connector", async () => {
|
||||
// Arrange
|
||||
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
const masterKey = getMockMasterKey();
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
|
||||
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
|
||||
jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey);
|
||||
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue();
|
||||
|
||||
// Act
|
||||
await keyConnectorService.migrateUser();
|
||||
|
||||
// Assert
|
||||
expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled();
|
||||
expect(cryptoService.getMasterKey).toHaveBeenCalled();
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
organization.keyConnectorUrl,
|
||||
keyConnectorRequest,
|
||||
);
|
||||
expect(apiService.postConvertToKeyConnector).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors thrown during migration", async () => {
|
||||
// Arrange
|
||||
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
const masterKey = getMockMasterKey();
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const error = new Error("Failed to post user key to key connector");
|
||||
organizationService.getAll.mockResolvedValue([organization]);
|
||||
|
||||
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
|
||||
jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey);
|
||||
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockRejectedValue(error);
|
||||
jest.spyOn(logService, "error");
|
||||
|
||||
try {
|
||||
// Act
|
||||
await keyConnectorService.migrateUser();
|
||||
} catch {
|
||||
// Assert
|
||||
expect(logService.error).toHaveBeenCalledWith(error);
|
||||
expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled();
|
||||
expect(cryptoService.getMasterKey).toHaveBeenCalled();
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
organization.keyConnectorUrl,
|
||||
keyConnectorRequest,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function organizationData(
|
||||
usesKeyConnector: boolean,
|
||||
keyConnectorEnabled: boolean,
|
||||
keyConnectorUrl: string,
|
||||
userType: number,
|
||||
isProviderUser: boolean,
|
||||
): Organization {
|
||||
return new Organization(
|
||||
new OrganizationData(
|
||||
new ProfileOrganizationResponse({
|
||||
id: mockOrgId,
|
||||
name: "TEST_KEY_CONNECTOR_ORG",
|
||||
usePolicies: true,
|
||||
useSso: true,
|
||||
useKeyConnector: usesKeyConnector,
|
||||
useScim: true,
|
||||
useGroups: true,
|
||||
useDirectory: true,
|
||||
useEvents: true,
|
||||
useTotp: true,
|
||||
use2fa: true,
|
||||
useApi: true,
|
||||
useResetPassword: true,
|
||||
useSecretsManager: true,
|
||||
usePasswordManager: true,
|
||||
usersGetPremium: true,
|
||||
useCustomPermissions: true,
|
||||
useActivateAutofillPolicy: true,
|
||||
selfHost: true,
|
||||
seats: 5,
|
||||
maxCollections: null,
|
||||
maxStorageGb: 1,
|
||||
key: "super-secret-key",
|
||||
status: 2,
|
||||
type: userType,
|
||||
enabled: true,
|
||||
ssoBound: true,
|
||||
identifier: "TEST_KEY_CONNECTOR_ORG",
|
||||
permissions: {
|
||||
accessEventLogs: false,
|
||||
accessImportExport: false,
|
||||
accessReports: false,
|
||||
createNewCollections: false,
|
||||
editAnyCollection: false,
|
||||
deleteAnyCollection: false,
|
||||
editAssignedCollections: false,
|
||||
deleteAssignedCollections: false,
|
||||
manageGroups: false,
|
||||
managePolicies: false,
|
||||
manageSso: false,
|
||||
manageUsers: false,
|
||||
manageResetPassword: false,
|
||||
manageScim: false,
|
||||
},
|
||||
resetPasswordEnrolled: true,
|
||||
userId: mockUserId,
|
||||
hasPublicAndPrivateKeys: true,
|
||||
providerId: null,
|
||||
providerName: null,
|
||||
providerType: null,
|
||||
familySponsorshipFriendlyName: null,
|
||||
familySponsorshipAvailable: true,
|
||||
planProductType: 3,
|
||||
KeyConnectorEnabled: keyConnectorEnabled,
|
||||
KeyConnectorUrl: keyConnectorUrl,
|
||||
familySponsorshipLastSyncDate: null,
|
||||
familySponsorshipValidUntil: null,
|
||||
familySponsorshipToDelete: null,
|
||||
accessSecretsManager: false,
|
||||
limitCollectionCreationDeletion: true,
|
||||
allowAdminAccessToAllCollectionItems: true,
|
||||
flexibleCollections: false,
|
||||
object: "profileOrganization",
|
||||
}),
|
||||
{ isMember: true, isProviderUser: isProviderUser },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getMockMasterKey(): MasterKey {
|
||||
const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key);
|
||||
const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey;
|
||||
return masterKey;
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserType } from "../../admin-console/enums";
|
||||
@@ -5,9 +7,14 @@ import { KeysRequest } from "../../models/request/keys.request";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
ActiveUserState,
|
||||
KEY_CONNECTOR_DISK,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "../../platform/state";
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
|
||||
import { TokenService } from "../abstractions/token.service";
|
||||
@@ -16,9 +23,28 @@ import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user
|
||||
import { SetKeyConnectorKeyRequest } from "../models/request/set-key-connector-key.request";
|
||||
import { IdentityTokenResponse } from "../models/response/identity-token.response";
|
||||
|
||||
export const USES_KEY_CONNECTOR = new UserKeyDefinition<boolean>(
|
||||
KEY_CONNECTOR_DISK,
|
||||
"usesKeyConnector",
|
||||
{
|
||||
deserializer: (usesKeyConnector) => usesKeyConnector,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export const CONVERT_ACCOUNT_TO_KEY_CONNECTOR = new UserKeyDefinition<boolean>(
|
||||
KEY_CONNECTOR_DISK,
|
||||
"convertAccountToKeyConnector",
|
||||
{
|
||||
deserializer: (convertAccountToKeyConnector) => convertAccountToKeyConnector,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
private usesKeyConnectorState: ActiveUserState<boolean>;
|
||||
private convertAccountToKeyConnectorState: ActiveUserState<boolean>;
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private cryptoService: CryptoService,
|
||||
private apiService: ApiService,
|
||||
private tokenService: TokenService,
|
||||
@@ -26,14 +52,20 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
private organizationService: OrganizationService,
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private logoutCallback: (expired: boolean, userId?: string) => Promise<void>,
|
||||
) {}
|
||||
|
||||
setUsesKeyConnector(usesKeyConnector: boolean) {
|
||||
return this.stateService.setUsesKeyConnector(usesKeyConnector);
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.usesKeyConnectorState = this.stateProvider.getActive(USES_KEY_CONNECTOR);
|
||||
this.convertAccountToKeyConnectorState = this.stateProvider.getActive(
|
||||
CONVERT_ACCOUNT_TO_KEY_CONNECTOR,
|
||||
);
|
||||
}
|
||||
|
||||
async getUsesKeyConnector(): Promise<boolean> {
|
||||
return await this.stateService.getUsesKeyConnector();
|
||||
async setUsesKeyConnector(usesKeyConnector: boolean) {
|
||||
await this.usesKeyConnectorState.update(() => usesKeyConnector);
|
||||
}
|
||||
|
||||
getUsesKeyConnector(): Promise<boolean> {
|
||||
return firstValueFrom(this.usesKeyConnectorState.state$);
|
||||
}
|
||||
|
||||
async userNeedsMigration() {
|
||||
@@ -132,19 +164,15 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
}
|
||||
|
||||
async setConvertAccountRequired(status: boolean) {
|
||||
await this.stateService.setConvertAccountToKeyConnector(status);
|
||||
await this.convertAccountToKeyConnectorState.update(() => status);
|
||||
}
|
||||
|
||||
async getConvertAccountRequired(): Promise<boolean> {
|
||||
return await this.stateService.getConvertAccountToKeyConnector();
|
||||
getConvertAccountRequired(): Promise<boolean> {
|
||||
return firstValueFrom(this.convertAccountToKeyConnectorState.state$);
|
||||
}
|
||||
|
||||
async removeConvertAccountRequired() {
|
||||
await this.stateService.setConvertAccountToKeyConnector(null);
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.removeConvertAccountRequired();
|
||||
await this.setConvertAccountRequired(null);
|
||||
}
|
||||
|
||||
private handleKeyConnectorError(e: any) {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { LoginService as LoginServiceAbstraction } from "../abstractions/login.service";
|
||||
|
||||
export class LoginService implements LoginServiceAbstraction {
|
||||
private _email: string;
|
||||
private _rememberEmail: boolean;
|
||||
|
||||
constructor(private stateService: StateService) {}
|
||||
|
||||
getEmail() {
|
||||
return this._email;
|
||||
}
|
||||
|
||||
getRememberEmail() {
|
||||
return this._rememberEmail;
|
||||
}
|
||||
|
||||
setEmail(value: string) {
|
||||
this._email = value;
|
||||
}
|
||||
|
||||
setRememberEmail(value: boolean) {
|
||||
this._rememberEmail = value;
|
||||
}
|
||||
|
||||
clearValues() {
|
||||
this._email = null;
|
||||
this._rememberEmail = null;
|
||||
}
|
||||
|
||||
async saveEmailSettings() {
|
||||
await this.stateService.setRememberedEmail(this._rememberEmail ? this._email : null);
|
||||
this.clearValues();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec";
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
@@ -12,7 +16,6 @@ import { DecodedAccessToken, TokenService } from "./token.service";
|
||||
import {
|
||||
ACCESS_TOKEN_DISK,
|
||||
ACCESS_TOKEN_MEMORY,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
API_KEY_CLIENT_ID_DISK,
|
||||
API_KEY_CLIENT_ID_MEMORY,
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
@@ -28,7 +31,10 @@ describe("TokenService", () => {
|
||||
let singleUserStateProvider: FakeSingleUserStateProvider;
|
||||
let globalStateProvider: FakeGlobalStateProvider;
|
||||
|
||||
const secureStorageService = mock<AbstractStorageService>();
|
||||
let secureStorageService: MockProxy<AbstractStorageService>;
|
||||
let keyGenerationService: MockProxy<KeyGenerationService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
const memoryVaultTimeoutAction = VaultTimeoutAction.LogOut;
|
||||
const memoryVaultTimeout = 30;
|
||||
@@ -74,12 +80,19 @@ describe("TokenService", () => {
|
||||
userId: userIdFromAccessToken,
|
||||
};
|
||||
|
||||
const accessTokenKeyB64 = { keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
singleUserStateProvider = new FakeSingleUserStateProvider();
|
||||
globalStateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
secureStorageService = mock<AbstractStorageService>();
|
||||
keyGenerationService = mock<KeyGenerationService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
const supportsSecureStorage = false; // default to false; tests will override as needed
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
@@ -89,8 +102,63 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Access Token methods", () => {
|
||||
const accessTokenPartialSecureStorageKey = `_accessToken`;
|
||||
const accessTokenSecureStorageKey = `${userIdFromAccessToken}${accessTokenPartialSecureStorageKey}`;
|
||||
const accessTokenKeyPartialSecureStorageKey = `_accessTokenKey`;
|
||||
const accessTokenKeySecureStorageKey = `${userIdFromAccessToken}${accessTokenKeyPartialSecureStorageKey}`;
|
||||
|
||||
describe("hasAccessToken$", () => {
|
||||
it("returns true when an access token exists in memory", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns true when an access token exists in disk", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns true when an access token exists in secure storage", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false if no access token exists in memory, disk, or secure storage", async () => {
|
||||
// Act
|
||||
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAccessToken", () => {
|
||||
it("should throw an error if the access token is null", async () => {
|
||||
@@ -150,18 +218,22 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("should set the access token in secure storage, null out data on disk or in memory, and set a flag to indicate the token has been migrated", async () => {
|
||||
it("should set an access token key in secure storage, the encrypted access token in disk, and clear out the token in memory", async () => {
|
||||
// Arrange:
|
||||
|
||||
// For testing purposes, let's assume that the access token is already in disk and memory
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// For testing purposes, let's assume that the access token is already in memory
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
keyGenerationService.createKey.mockResolvedValue("accessTokenKey" as any);
|
||||
|
||||
const mockEncryptedAccessToken = "encryptedAccessToken";
|
||||
|
||||
encryptService.encrypt.mockResolvedValue({
|
||||
encryptedString: mockEncryptedAccessToken,
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
await tokenService.setAccessToken(
|
||||
accessTokenJwt,
|
||||
@@ -170,27 +242,22 @@ describe("TokenService", () => {
|
||||
);
|
||||
// Assert
|
||||
|
||||
// assert that the access token was set in secure storage
|
||||
// assert that the AccessTokenKey was set in secure storage
|
||||
expect(secureStorageService.save).toHaveBeenCalledWith(
|
||||
accessTokenSecureStorageKey,
|
||||
accessTokenJwt,
|
||||
accessTokenKeySecureStorageKey,
|
||||
"accessTokenKey",
|
||||
secureStorageOptions,
|
||||
);
|
||||
|
||||
// assert data was migrated out of disk and memory + flag was set
|
||||
// assert that the access token was encrypted and set in disk
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
).toHaveBeenCalledWith(mockEncryptedAccessToken);
|
||||
|
||||
// assert data was migrated out of memory
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(
|
||||
singleUserStateProvider.getFake(
|
||||
userIdFromAccessToken,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
).nextMock,
|
||||
).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -216,7 +283,13 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
it("should get the access token from memory with no user id specified (uses global active user)", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should get the access token from memory for the provided user id",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
["should get the access token from memory with no user id provided", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -228,37 +301,28 @@ describe("TokenService", () => {
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should get the access token from memory for the specified user id", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// set disk to undefined
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
||||
it("should get the access token from disk with no user id specified", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should get the access token from disk for the specified user id",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
["should get the access token from disk with no user id specified", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -269,28 +333,14 @@ describe("TokenService", () => {
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should get the access token from disk for the specified user id", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
@@ -302,7 +352,16 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("should get the access token from secure storage when no user id is specified and the migration flag is set to true", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should get the encrypted access token from disk, decrypt it, and return it when user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"should get the encrypted access token from disk, decrypt it, and return it when no user id is provided",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -310,76 +369,35 @@ describe("TokenService", () => {
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(accessTokenJwt);
|
||||
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
|
||||
encryptService.decryptToUtf8.mockResolvedValue("decryptedAccessToken");
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
|
||||
// set access token migration flag to true
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, true]);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should get the access token from secure storage when user id is specified and the migration flag set to true", async () => {
|
||||
// Arrange
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(accessTokenJwt);
|
||||
|
||||
// set access token migration flag to true
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, true]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should fallback and get the access token from disk when user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// set access token migration flag to false
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, false]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
|
||||
// assert that secure storage was not called
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
expect(result).toEqual("decryptedAccessToken");
|
||||
});
|
||||
|
||||
it("should fallback and get the access token from disk when no user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should fallback and get the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"should fallback and get the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -390,23 +408,19 @@ describe("TokenService", () => {
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// set access token migration flag to false
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, false]);
|
||||
// No access token key set
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
|
||||
// assert that secure storage was not called
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -426,7 +440,16 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("should clear the access token from all storage locations for the specified user id", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should clear the access token from all storage locations for the provided user id",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"should clear the access token from all storage locations for the global active user",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -436,6 +459,13 @@ describe("TokenService", () => {
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
await tokenService.clearAccessToken(userIdFromAccessToken);
|
||||
|
||||
@@ -448,39 +478,7 @@ describe("TokenService", () => {
|
||||
).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(secureStorageService.remove).toHaveBeenCalledWith(
|
||||
accessTokenSecureStorageKey,
|
||||
secureStorageOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("should clear the access token from all storage locations for the global active user", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
await tokenService.clearAccessToken();
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(secureStorageService.remove).toHaveBeenCalledWith(
|
||||
accessTokenSecureStorageKey,
|
||||
accessTokenKeySecureStorageKey,
|
||||
secureStorageOptions,
|
||||
);
|
||||
});
|
||||
@@ -1049,6 +1047,7 @@ describe("TokenService", () => {
|
||||
refreshToken,
|
||||
VaultTimeoutAction.Lock,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("User id not found. Cannot save refresh token.");
|
||||
@@ -1912,7 +1911,7 @@ describe("TokenService", () => {
|
||||
|
||||
// Act
|
||||
// Note: passing a valid access token so that a valid user id can be determined from the access token
|
||||
await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout, [
|
||||
await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken, [
|
||||
clientId,
|
||||
clientSecret,
|
||||
]);
|
||||
@@ -1959,7 +1958,7 @@ describe("TokenService", () => {
|
||||
tokenService.setClientSecret = jest.fn();
|
||||
|
||||
// Act
|
||||
await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout);
|
||||
await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken);
|
||||
|
||||
// Assert
|
||||
expect((tokenService as any)._setAccessToken).toHaveBeenCalledWith(
|
||||
@@ -1991,9 +1990,9 @@ describe("TokenService", () => {
|
||||
// Act
|
||||
const result = tokenService.setTokens(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
refreshToken,
|
||||
);
|
||||
|
||||
// Assert
|
||||
@@ -2010,32 +2009,27 @@ describe("TokenService", () => {
|
||||
// Act
|
||||
const result = tokenService.setTokens(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
refreshToken,
|
||||
);
|
||||
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("Access token and refresh token are required.");
|
||||
await expect(result).rejects.toThrow("Access token is required.");
|
||||
});
|
||||
|
||||
it("should throw an error if the refresh token is missing", async () => {
|
||||
it("should not throw an error if the refresh token is missing and it should just not set it", async () => {
|
||||
// Arrange
|
||||
const accessToken = "accessToken";
|
||||
const refreshToken: string = null;
|
||||
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
||||
const vaultTimeout = 30;
|
||||
(tokenService as any).setRefreshToken = jest.fn();
|
||||
|
||||
// Act
|
||||
const result = tokenService.setTokens(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
);
|
||||
await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken);
|
||||
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("Access token and refresh token are required.");
|
||||
expect((tokenService as any).setRefreshToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2232,6 +2226,9 @@ describe("TokenService", () => {
|
||||
globalStateProvider,
|
||||
supportsSecureStorage,
|
||||
secureStorageService,
|
||||
keyGenerationService,
|
||||
encryptService,
|
||||
logService,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { Observable, combineLatest, firstValueFrom, map } from "rxjs";
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { decodeJwtTokenToJson } from "@bitwarden/auth/common";
|
||||
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
import { EncString, EncryptedString } from "../../platform/models/domain/enc-string";
|
||||
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
GlobalState,
|
||||
GlobalStateProvider,
|
||||
@@ -19,7 +25,6 @@ import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service";
|
||||
import {
|
||||
ACCESS_TOKEN_DISK,
|
||||
ACCESS_TOKEN_MEMORY,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
API_KEY_CLIENT_ID_DISK,
|
||||
API_KEY_CLIENT_ID_MEMORY,
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
@@ -101,8 +106,14 @@ export type DecodedAccessToken = {
|
||||
jti?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A symmetric key for encrypting the access token before the token is stored on disk.
|
||||
* This key should be stored in secure storage.
|
||||
* */
|
||||
type AccessTokenKey = Opaque<SymmetricCryptoKey, "AccessTokenKey">;
|
||||
|
||||
export class TokenService implements TokenServiceAbstraction {
|
||||
private readonly accessTokenSecureStorageKey: string = "_accessToken";
|
||||
private readonly accessTokenKeySecureStorageKey: string = "_accessTokenKey";
|
||||
|
||||
private readonly refreshTokenSecureStorageKey: string = "_refreshToken";
|
||||
|
||||
@@ -117,10 +128,26 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
private readonly platformSupportsSecureStorage: boolean,
|
||||
private secureStorageService: AbstractStorageService,
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private encryptService: EncryptService,
|
||||
private logService: LogService,
|
||||
) {
|
||||
this.initializeState();
|
||||
}
|
||||
|
||||
hasAccessToken$(userId: UserId): Observable<boolean> {
|
||||
// FIXME Once once vault timeout action is observable, we can use it to determine storage location
|
||||
// and avoid the need to check both disk and memory.
|
||||
return combineLatest([
|
||||
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).state$,
|
||||
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).state$,
|
||||
]).pipe(map(([disk, memory]) => Boolean(disk || memory)));
|
||||
}
|
||||
|
||||
// pivoting to an approach where we create a symmetric key we store in secure storage
|
||||
// which is used to protect the data before persisting to disk.
|
||||
// We will also use the same symmetric key to decrypt the data when reading from disk.
|
||||
|
||||
private initializeState(): void {
|
||||
this.emailTwoFactorTokenRecordGlobalState = this.globalStateProvider.get(
|
||||
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
|
||||
@@ -131,13 +158,13 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
|
||||
async setTokens(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
vaultTimeoutAction: VaultTimeoutAction,
|
||||
vaultTimeout: number | null,
|
||||
refreshToken?: string,
|
||||
clientIdClientSecret?: [string, string],
|
||||
): Promise<void> {
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error("Access token and refresh token are required.");
|
||||
if (!accessToken) {
|
||||
throw new Error("Access token is required.");
|
||||
}
|
||||
|
||||
// get user id the access token
|
||||
@@ -148,13 +175,95 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
}
|
||||
|
||||
await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId);
|
||||
await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId);
|
||||
|
||||
if (refreshToken) {
|
||||
await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId);
|
||||
}
|
||||
|
||||
if (clientIdClientSecret != null) {
|
||||
await this.setClientId(clientIdClientSecret[0], vaultTimeoutAction, vaultTimeout, userId);
|
||||
await this.setClientSecret(clientIdClientSecret[1], vaultTimeoutAction, vaultTimeout, userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAccessTokenKey(userId: UserId): Promise<AccessTokenKey | null> {
|
||||
const accessTokenKeyB64 = await this.secureStorageService.get<
|
||||
ReturnType<SymmetricCryptoKey["toJSON"]>
|
||||
>(`${userId}${this.accessTokenKeySecureStorageKey}`, this.getSecureStorageOptions(userId));
|
||||
|
||||
if (!accessTokenKeyB64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accessTokenKey = SymmetricCryptoKey.fromJSON(accessTokenKeyB64) as AccessTokenKey;
|
||||
return accessTokenKey;
|
||||
}
|
||||
|
||||
private async createAndSaveAccessTokenKey(userId: UserId): Promise<AccessTokenKey> {
|
||||
const newAccessTokenKey = (await this.keyGenerationService.createKey(512)) as AccessTokenKey;
|
||||
|
||||
await this.secureStorageService.save<AccessTokenKey>(
|
||||
`${userId}${this.accessTokenKeySecureStorageKey}`,
|
||||
newAccessTokenKey,
|
||||
this.getSecureStorageOptions(userId),
|
||||
);
|
||||
|
||||
return newAccessTokenKey;
|
||||
}
|
||||
|
||||
private async clearAccessTokenKey(userId: UserId): Promise<void> {
|
||||
await this.secureStorageService.remove(
|
||||
`${userId}${this.accessTokenKeySecureStorageKey}`,
|
||||
this.getSecureStorageOptions(userId),
|
||||
);
|
||||
}
|
||||
|
||||
private async getOrCreateAccessTokenKey(userId: UserId): Promise<AccessTokenKey> {
|
||||
if (!this.platformSupportsSecureStorage) {
|
||||
throw new Error("Platform does not support secure storage. Cannot obtain access token key.");
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("User id not found. Cannot obtain access token key.");
|
||||
}
|
||||
|
||||
// First see if we have an accessTokenKey in secure storage and return it if we do
|
||||
let accessTokenKey: AccessTokenKey = await this.getAccessTokenKey(userId);
|
||||
|
||||
if (!accessTokenKey) {
|
||||
// Otherwise, create a new one and save it to secure storage, then return it
|
||||
accessTokenKey = await this.createAndSaveAccessTokenKey(userId);
|
||||
}
|
||||
|
||||
return accessTokenKey;
|
||||
}
|
||||
|
||||
private async encryptAccessToken(accessToken: string, userId: UserId): Promise<EncString> {
|
||||
const accessTokenKey = await this.getOrCreateAccessTokenKey(userId);
|
||||
|
||||
return await this.encryptService.encrypt(accessToken, accessTokenKey);
|
||||
}
|
||||
|
||||
private async decryptAccessToken(
|
||||
encryptedAccessToken: EncString,
|
||||
userId: UserId,
|
||||
): Promise<string | null> {
|
||||
const accessTokenKey = await this.getAccessTokenKey(userId);
|
||||
|
||||
if (!accessTokenKey) {
|
||||
// If we don't have an accessTokenKey, then that means we don't have an access token as it hasn't been set yet
|
||||
// and we have to return null here to properly indicate the the user isn't logged in.
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptedAccessToken = await this.encryptService.decryptToUtf8(
|
||||
encryptedAccessToken,
|
||||
accessTokenKey,
|
||||
);
|
||||
|
||||
return decryptedAccessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper for set access token which always requires user id.
|
||||
* This is useful because setTokens always will have a user id from the access token whereas
|
||||
@@ -173,26 +282,33 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
);
|
||||
|
||||
switch (storageLocation) {
|
||||
case TokenStorageLocation.SecureStorage:
|
||||
await this.saveStringToSecureStorage(userId, this.accessTokenSecureStorageKey, accessToken);
|
||||
case TokenStorageLocation.SecureStorage: {
|
||||
// Secure storage implementations have variable length limitations (Windows), so we cannot
|
||||
// store the access token directly. Instead, we encrypt with accessTokenKey and store that
|
||||
// in secure storage.
|
||||
|
||||
const encryptedAccessToken: EncString = await this.encryptAccessToken(accessToken, userId);
|
||||
|
||||
// Save the encrypted access token to disk
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_DISK)
|
||||
.update((_) => encryptedAccessToken.encryptedString);
|
||||
|
||||
// TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408
|
||||
// 2024-02-20: Remove access token from memory and disk so that we migrate to secure storage over time.
|
||||
// Remove these 2 calls to remove the access token from memory and disk after 3 releases.
|
||||
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null);
|
||||
// 2024-02-20: Remove access token from memory so that we migrate to encrypt the access token over time.
|
||||
// Remove this call to remove the access token from memory after 3 releases.
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
|
||||
|
||||
// Set flag to indicate that the access token has been migrated to secure storage (don't remove this)
|
||||
await this.setAccessTokenMigratedToSecureStorage(userId);
|
||||
|
||||
return;
|
||||
}
|
||||
case TokenStorageLocation.Disk:
|
||||
// Access token stored on disk unencrypted as platform does not support secure storage
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_DISK)
|
||||
.update((_) => accessToken);
|
||||
return;
|
||||
case TokenStorageLocation.Memory:
|
||||
// Access token stored in memory due to vault timeout settings
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_MEMORY)
|
||||
.update((_) => accessToken);
|
||||
@@ -226,15 +342,14 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
throw new Error("User id not found. Cannot clear access token.");
|
||||
}
|
||||
|
||||
// TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
|
||||
// TODO: re-eval this implementation once we get shared key definitions for vault timeout and vault timeout action data.
|
||||
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
|
||||
// but we can simply clear all locations to avoid the need to require those parameters
|
||||
// but we can simply clear all locations to avoid the need to require those parameters.
|
||||
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
await this.secureStorageService.remove(
|
||||
`${userId}${this.accessTokenSecureStorageKey}`,
|
||||
this.getSecureStorageOptions(userId),
|
||||
);
|
||||
// Always clear the access token key when clearing the access token
|
||||
// The next set of the access token will create a new access token key
|
||||
await this.clearAccessTokenKey(userId);
|
||||
}
|
||||
|
||||
// Platform doesn't support secure storage, so use state provider implementation
|
||||
@@ -249,36 +364,48 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const accessTokenMigratedToSecureStorage =
|
||||
await this.getAccessTokenMigratedToSecureStorage(userId);
|
||||
if (this.platformSupportsSecureStorage && accessTokenMigratedToSecureStorage) {
|
||||
return await this.getStringFromSecureStorage(userId, this.accessTokenSecureStorageKey);
|
||||
}
|
||||
|
||||
// Try to get the access token from memory
|
||||
const accessTokenMemory = await this.getStateValueByUserIdAndKeyDef(
|
||||
userId,
|
||||
ACCESS_TOKEN_MEMORY,
|
||||
);
|
||||
|
||||
if (accessTokenMemory != null) {
|
||||
return accessTokenMemory;
|
||||
}
|
||||
|
||||
// If memory is null, read from disk
|
||||
return await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK);
|
||||
}
|
||||
const accessTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK);
|
||||
if (!accessTokenDisk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getAccessTokenMigratedToSecureStorage(userId: UserId): Promise<boolean> {
|
||||
return await firstValueFrom(
|
||||
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$,
|
||||
);
|
||||
}
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
const accessTokenKey = await this.getAccessTokenKey(userId);
|
||||
|
||||
private async setAccessTokenMigratedToSecureStorage(userId: UserId): Promise<void> {
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.update((_) => true);
|
||||
if (!accessTokenKey) {
|
||||
// We know this is an unencrypted access token because we don't have an access token key
|
||||
return accessTokenDisk;
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedAccessTokenEncString = new EncString(accessTokenDisk as EncryptedString);
|
||||
|
||||
const decryptedAccessToken = await this.decryptAccessToken(
|
||||
encryptedAccessTokenEncString,
|
||||
userId,
|
||||
);
|
||||
return decryptedAccessToken;
|
||||
} catch (error) {
|
||||
// If an error occurs during decryption, return null for logout.
|
||||
// We don't try to recover here since we'd like to know
|
||||
// if access token and key are getting out of sync.
|
||||
this.logService.error(
|
||||
`Failed to decrypt access token: ${error?.message ?? "Unknown error."}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return accessTokenDisk;
|
||||
}
|
||||
|
||||
// Private because we only ever set the refresh token when also setting the access token
|
||||
@@ -417,7 +544,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
const storageLocation = await this.determineStorageLocation(
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
false,
|
||||
false, // don't use secure storage for client id
|
||||
);
|
||||
|
||||
if (storageLocation === TokenStorageLocation.Disk) {
|
||||
@@ -484,7 +611,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
const storageLocation = await this.determineStorageLocation(
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
false,
|
||||
false, // don't use secure storage for client secret
|
||||
);
|
||||
|
||||
if (storageLocation === TokenStorageLocation.Disk) {
|
||||
@@ -567,6 +694,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: stop accepting optional userIds
|
||||
async clearTokens(userId?: UserId): Promise<void> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { KeyDefinition } from "../../platform/state";
|
||||
import {
|
||||
ACCESS_TOKEN_DISK,
|
||||
ACCESS_TOKEN_MEMORY,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
API_KEY_CLIENT_ID_DISK,
|
||||
API_KEY_CLIENT_ID_MEMORY,
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
describe.each([
|
||||
[ACCESS_TOKEN_DISK, "accessTokenDisk"],
|
||||
[ACCESS_TOKEN_MEMORY, "accessTokenMemory"],
|
||||
[ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, true],
|
||||
[REFRESH_TOKEN_DISK, "refreshTokenDisk"],
|
||||
[REFRESH_TOKEN_MEMORY, "refreshTokenMemory"],
|
||||
[REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, true],
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { KeyDefinition, TOKEN_DISK, TOKEN_DISK_LOCAL, TOKEN_MEMORY } from "../../platform/state";
|
||||
|
||||
// Note: all tokens / API key information must be cleared on logout.
|
||||
// because we are using secure storage, we must manually call to clean up our tokens.
|
||||
// See stateService.deAuthenticateAccount for where we call clearTokens(...)
|
||||
|
||||
export const ACCESS_TOKEN_DISK = new KeyDefinition<string>(TOKEN_DISK, "accessToken", {
|
||||
deserializer: (accessToken) => accessToken,
|
||||
});
|
||||
@@ -8,14 +12,6 @@ export const ACCESS_TOKEN_MEMORY = new KeyDefinition<string>(TOKEN_MEMORY, "acce
|
||||
deserializer: (accessToken) => accessToken,
|
||||
});
|
||||
|
||||
export const ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition<boolean>(
|
||||
TOKEN_DISK,
|
||||
"accessTokenMigratedToSecureStorage",
|
||||
{
|
||||
deserializer: (accessTokenMigratedToSecureStorage) => accessTokenMigratedToSecureStorage,
|
||||
},
|
||||
);
|
||||
|
||||
export const REFRESH_TOKEN_DISK = new KeyDefinition<string>(TOKEN_DISK, "refreshToken", {
|
||||
deserializer: (refreshToken) => refreshToken,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
|
||||
import { PinCryptoServiceAbstraction } from "../../../../../auth/src/common/abstractions/pin-crypto.service.abstraction";
|
||||
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { CryptoService } from "../../../platform/abstractions/crypto.service";
|
||||
@@ -33,6 +37,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
private cryptoService: CryptoService,
|
||||
private i18nService: I18nService,
|
||||
private userVerificationApiService: UserVerificationApiServiceAbstraction,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private pinCryptoService: PinCryptoServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction,
|
||||
@@ -136,7 +141,6 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
return this.verifyUserByMasterPassword(verification);
|
||||
case VerificationType.PIN:
|
||||
return this.verifyUserByPIN(verification);
|
||||
break;
|
||||
case VerificationType.Biometrics:
|
||||
return this.verifyUserByBiometrics();
|
||||
default: {
|
||||
@@ -210,16 +214,19 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
* Note: This only checks the server, not the local state
|
||||
* @param userId The user id to check. If not provided, the current user is used
|
||||
* @returns True if the user has a master password
|
||||
* @deprecated Use UserDecryptionOptionsService.hasMasterPassword$ instead
|
||||
*/
|
||||
async hasMasterPassword(userId?: string): Promise<boolean> {
|
||||
const decryptionOptions = await this.stateService.getAccountDecryptionOptions({ userId });
|
||||
if (userId) {
|
||||
const decryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
);
|
||||
|
||||
if (decryptionOptions?.hasMasterPassword != undefined) {
|
||||
return decryptionOptions.hasMasterPassword;
|
||||
if (decryptionOptions?.hasMasterPassword != undefined) {
|
||||
return decryptionOptions.hasMasterPassword;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: PM-3518 - Left for backwards compatibility, remove after 2023.12.0
|
||||
return !(await this.stateService.getUsesKeyConnector({ userId }));
|
||||
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
|
||||
}
|
||||
|
||||
async hasMasterPasswordAndMasterKeyHash(userId?: string): Promise<boolean> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { EnvironmentService } from "../../../platform/abstractions/environment.service";
|
||||
import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction";
|
||||
@@ -11,13 +13,14 @@ export class WebAuthnLoginApiService implements WebAuthnLoginApiServiceAbstracti
|
||||
) {}
|
||||
|
||||
async getCredentialAssertionOptions(): Promise<CredentialAssertionOptionsResponse> {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
`/accounts/webauthn/assertion-options`,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
this.environmentService.getIdentityUrl(),
|
||||
env.getIdentityUrl(),
|
||||
);
|
||||
return new CredentialAssertionOptionsResponse(response);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
FakeActiveUserStateProvider,
|
||||
mockAccountServiceWith,
|
||||
FakeActiveUserState,
|
||||
trackEmissions,
|
||||
FakeStateProvider,
|
||||
FakeSingleUserState,
|
||||
} from "../../../../spec";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { BillingAccountProfile } from "../../abstractions/account/billing-account-profile-state.service";
|
||||
@@ -16,20 +16,26 @@ import {
|
||||
} from "./billing-account-profile-state.service";
|
||||
|
||||
describe("BillingAccountProfileStateService", () => {
|
||||
let activeUserStateProvider: FakeActiveUserStateProvider;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let sut: DefaultBillingAccountProfileStateService;
|
||||
let billingAccountProfileState: FakeActiveUserState<BillingAccountProfile>;
|
||||
let userBillingAccountProfileState: FakeSingleUserState<BillingAccountProfile>;
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
const userId = "fakeUserId" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
activeUserStateProvider = new FakeActiveUserStateProvider(accountService);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
sut = new DefaultBillingAccountProfileStateService(activeUserStateProvider);
|
||||
sut = new DefaultBillingAccountProfileStateService(stateProvider);
|
||||
|
||||
billingAccountProfileState = activeUserStateProvider.getFake(
|
||||
billingAccountProfileState = stateProvider.activeUser.getFake(
|
||||
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
|
||||
);
|
||||
|
||||
userBillingAccountProfileState = stateProvider.singleUser.getFake(
|
||||
userId,
|
||||
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
|
||||
);
|
||||
});
|
||||
@@ -38,9 +44,9 @@ describe("BillingAccountProfileStateService", () => {
|
||||
return jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("accountHasPremiumFromAnyOrganization$", () => {
|
||||
it("should emit changes in hasPremiumFromAnyOrganization", async () => {
|
||||
billingAccountProfileState.nextState({
|
||||
describe("hasPremiumFromAnyOrganization$", () => {
|
||||
it("returns true when they have premium from an organization", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: false,
|
||||
hasPremiumFromAnyOrganization: true,
|
||||
});
|
||||
@@ -48,118 +54,91 @@ describe("BillingAccountProfileStateService", () => {
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit once when calling setHasPremium once", async () => {
|
||||
const emissions = trackEmissions(sut.hasPremiumFromAnyOrganization$);
|
||||
const startingEmissionCount = emissions.length;
|
||||
|
||||
await sut.setHasPremium(true, true);
|
||||
|
||||
const endingEmissionCount = emissions.length;
|
||||
expect(endingEmissionCount - startingEmissionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPremiumPersonally$", () => {
|
||||
it("should emit changes in hasPremiumPersonally", async () => {
|
||||
billingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: true,
|
||||
hasPremiumFromAnyOrganization: false,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit once when calling setHasPremium once", async () => {
|
||||
const emissions = trackEmissions(sut.hasPremiumPersonally$);
|
||||
const startingEmissionCount = emissions.length;
|
||||
|
||||
await sut.setHasPremium(true, true);
|
||||
|
||||
const endingEmissionCount = emissions.length;
|
||||
expect(endingEmissionCount - startingEmissionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessPremium$", () => {
|
||||
it("should emit changes in hasPremiumPersonally", async () => {
|
||||
billingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: true,
|
||||
hasPremiumFromAnyOrganization: false,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit changes in hasPremiumFromAnyOrganization", async () => {
|
||||
billingAccountProfileState.nextState({
|
||||
it("return false when they do not have premium from an organization", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: false,
|
||||
hasPremiumFromAnyOrganization: true,
|
||||
hasPremiumFromAnyOrganization: false,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit changes in both hasPremiumPersonally and hasPremiumFromAnyOrganization", async () => {
|
||||
billingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: true,
|
||||
hasPremiumFromAnyOrganization: true,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit once when calling setHasPremium once", async () => {
|
||||
const emissions = trackEmissions(sut.hasPremiumFromAnySource$);
|
||||
const startingEmissionCount = emissions.length;
|
||||
|
||||
await sut.setHasPremium(true, true);
|
||||
|
||||
const endingEmissionCount = emissions.length;
|
||||
expect(endingEmissionCount - startingEmissionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setHasPremium", () => {
|
||||
it("should have `hasPremiumPersonally$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => {
|
||||
await sut.setHasPremium(true, false);
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should have `hasPremiumFromAnyOrganization$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => {
|
||||
await sut.setHasPremium(false, true);
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should have `hasPremiumPersonally$` emit `false` when passing `false` as an argument for hasPremiumPersonally", async () => {
|
||||
await sut.setHasPremium(false, false);
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false);
|
||||
});
|
||||
|
||||
it("should have `hasPremiumFromAnyOrganization$` emit `false` when passing `false` as an argument for hasPremiumFromAnyOrganization", async () => {
|
||||
await sut.setHasPremium(false, false);
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false);
|
||||
});
|
||||
|
||||
it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => {
|
||||
await sut.setHasPremium(true, false);
|
||||
it("returns false when there is no active user", async () => {
|
||||
await accountService.switchAccount(null);
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPremiumPersonally$", () => {
|
||||
it("returns true when the user has premium personally", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: true,
|
||||
hasPremiumFromAnyOrganization: false,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the user does not have premium personally", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: false,
|
||||
hasPremiumFromAnyOrganization: false,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when there is no active user", async () => {
|
||||
await accountService.switchAccount(null);
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPremiumFromAnySource$", () => {
|
||||
it("returns true when the user has premium personally", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: true,
|
||||
hasPremiumFromAnyOrganization: false,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => {
|
||||
await sut.setHasPremium(false, true);
|
||||
it("returns true when the user has premium from an organization", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: false,
|
||||
hasPremiumFromAnyOrganization: true,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should have `canAccessPremium$` emit `false` when passing `false` for all arguments", async () => {
|
||||
await sut.setHasPremium(false, false);
|
||||
it("returns true when they have premium personally AND from an organization", async () => {
|
||||
userBillingAccountProfileState.nextState({
|
||||
hasPremiumPersonally: true,
|
||||
hasPremiumFromAnyOrganization: true,
|
||||
});
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when there is no active user", async () => {
|
||||
await accountService.switchAccount(null);
|
||||
|
||||
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setHasPremium", () => {
|
||||
it("should update the active users state when called", async () => {
|
||||
await sut.setHasPremium(true, false);
|
||||
|
||||
expect(billingAccountProfileState.nextMock).toHaveBeenCalledWith([
|
||||
userId,
|
||||
{ hasPremiumPersonally: true, hasPremiumFromAnyOrganization: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
import { map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import {
|
||||
ActiveUserState,
|
||||
ActiveUserStateProvider,
|
||||
BILLING_DISK,
|
||||
KeyDefinition,
|
||||
StateProvider,
|
||||
} from "../../../platform/state";
|
||||
import {
|
||||
BillingAccountProfile,
|
||||
@@ -26,24 +26,34 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP
|
||||
hasPremiumPersonally$: Observable<boolean>;
|
||||
hasPremiumFromAnySource$: Observable<boolean>;
|
||||
|
||||
constructor(activeUserStateProvider: ActiveUserStateProvider) {
|
||||
this.billingAccountProfileState = activeUserStateProvider.get(
|
||||
constructor(stateProvider: StateProvider) {
|
||||
this.billingAccountProfileState = stateProvider.getActive(
|
||||
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
|
||||
);
|
||||
|
||||
this.hasPremiumFromAnyOrganization$ = this.billingAccountProfileState.state$.pipe(
|
||||
// Setup an observable that will always track the currently active user
|
||||
// but will fallback to emitting null when there is no active user.
|
||||
const billingAccountProfileOrNull = stateProvider.activeUserId$.pipe(
|
||||
switchMap((userId) =>
|
||||
userId != null
|
||||
? stateProvider.getUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION).state$
|
||||
: of(null),
|
||||
),
|
||||
);
|
||||
|
||||
this.hasPremiumFromAnyOrganization$ = billingAccountProfileOrNull.pipe(
|
||||
map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumFromAnyOrganization),
|
||||
);
|
||||
|
||||
this.hasPremiumPersonally$ = this.billingAccountProfileState.state$.pipe(
|
||||
this.hasPremiumPersonally$ = billingAccountProfileOrNull.pipe(
|
||||
map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumPersonally),
|
||||
);
|
||||
|
||||
this.hasPremiumFromAnySource$ = this.billingAccountProfileState.state$.pipe(
|
||||
this.hasPremiumFromAnySource$ = billingAccountProfileOrNull.pipe(
|
||||
map(
|
||||
(billingAccountProfile) =>
|
||||
billingAccountProfile?.hasPremiumFromAnyOrganization ||
|
||||
billingAccountProfile?.hasPremiumPersonally,
|
||||
billingAccountProfile?.hasPremiumFromAnyOrganization === true ||
|
||||
billingAccountProfile?.hasPremiumPersonally === true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
): Promise<void> {
|
||||
return this.apiService.send(
|
||||
"POST",
|
||||
"/organizations/" + organizationId + "/churn",
|
||||
"/organizations/" + organizationId + "/cancel",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
@@ -20,7 +20,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
}
|
||||
|
||||
cancelPremiumUserSubscription(request: SubscriptionCancellationRequest): Promise<void> {
|
||||
return this.apiService.send("POST", "/accounts/churn-premium", request, true, false);
|
||||
return this.apiService.send("POST", "/accounts/cancel", request, true, false);
|
||||
}
|
||||
|
||||
async getBillingStatus(id: string): Promise<OrganizationBillingStatusResponse> {
|
||||
|
||||
@@ -2,12 +2,10 @@ export enum FeatureFlag {
|
||||
BrowserFilelessImport = "browser-fileless-import",
|
||||
ItemShare = "item-share",
|
||||
FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional
|
||||
BulkCollectionAccess = "bulk-collection-access",
|
||||
VaultOnboarding = "vault-onboarding",
|
||||
GeneratorToolsModernization = "generator-tools-modernization",
|
||||
KeyRotationImprovements = "key-rotation-improvements",
|
||||
FlexibleCollectionsMigration = "flexible-collections-migration",
|
||||
AC1607_PresentUserOffboardingSurvey = "AC-1607_present-user-offboarding-survey",
|
||||
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export class WindowState {
|
||||
width?: number;
|
||||
height?: number;
|
||||
isMaximized?: boolean;
|
||||
// TODO: displayBounds is an Electron.Rectangle.
|
||||
// We need to establish some kind of client-specific global state, similar to the way we already extend a base Account.
|
||||
displayBounds: any;
|
||||
x?: number;
|
||||
y?: number;
|
||||
zoomFactor?: number;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class AppIdService {
|
||||
appId$: Observable<string>;
|
||||
anonymousAppId$: Observable<string>;
|
||||
getAppId: () => Promise<string>;
|
||||
getAnonymousAppId: () => Promise<string>;
|
||||
abstract appId$: Observable<string>;
|
||||
abstract anonymousAppId$: Observable<string>;
|
||||
abstract getAppId(): Promise<string>;
|
||||
abstract getAnonymousAppId(): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ export abstract class BroadcasterService {
|
||||
/**
|
||||
* @deprecated Use the observable from the appropriate service instead.
|
||||
*/
|
||||
send: (message: MessageBase, id?: string) => void;
|
||||
abstract send(message: MessageBase, id?: string): void;
|
||||
/**
|
||||
* @deprecated Use the observable from the appropriate service instead.
|
||||
*/
|
||||
subscribe: (id: string, messageCallback: (message: MessageBase) => void) => void;
|
||||
abstract subscribe(id: string, messageCallback: (message: MessageBase) => void): void;
|
||||
/**
|
||||
* @deprecated Use the observable from the appropriate service instead.
|
||||
*/
|
||||
unsubscribe: (id: string) => void;
|
||||
abstract unsubscribe(id: string): void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ServerConfigResponse } from "../../models/response/server-config.response";
|
||||
|
||||
export abstract class ConfigApiServiceAbstraction {
|
||||
get: () => Promise<ServerConfigResponse>;
|
||||
/**
|
||||
* Fetches the server configuration for the given user. If no user is provided, the configuration will not contain user-specific context.
|
||||
*/
|
||||
abstract get(userId: UserId | undefined): Promise<ServerConfigResponse>;
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { Region } from "../environment.service";
|
||||
|
||||
import { ServerConfig } from "./server-config";
|
||||
|
||||
export abstract class ConfigServiceAbstraction {
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
cloudRegion$: Observable<Region>;
|
||||
getFeatureFlag$: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T,
|
||||
) => Observable<T>;
|
||||
getFeatureFlag: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T,
|
||||
) => Promise<T>;
|
||||
checkServerMeetsVersionRequirement$: (
|
||||
minimumRequiredServerVersion: SemVer,
|
||||
) => Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Force ConfigService to fetch an updated config from the server and emit it from serverConfig$
|
||||
* @deprecated The service implementation should subscribe to an observable and use that to trigger a new fetch from
|
||||
* server instead
|
||||
*/
|
||||
triggerServerConfigFetch: () => void;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { Region } from "../environment.service";
|
||||
|
||||
import { ServerConfig } from "./server-config";
|
||||
|
||||
export abstract class ConfigService {
|
||||
/** The server config of the currently active user */
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
/** The cloud region of the currently active user */
|
||||
cloudRegion$: Observable<Region>;
|
||||
/**
|
||||
* Retrieves the value of a feature flag for the currently active user
|
||||
* @param key The feature flag to retrieve
|
||||
* @param defaultValue The default value to return if the feature flag is not set or the server's config is irretrievable
|
||||
* @returns An observable that emits the value of the feature flag, updates as the server config changes
|
||||
*/
|
||||
getFeatureFlag$: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T,
|
||||
) => Observable<T>;
|
||||
/**
|
||||
* Retrieves the value of a feature flag for the currently active user
|
||||
* @param key The feature flag to retrieve
|
||||
* @param defaultValue The default value to return if the feature flag is not set or the server's config is irretrievable
|
||||
* @returns The value of the feature flag
|
||||
*/
|
||||
getFeatureFlag: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T,
|
||||
) => Promise<T>;
|
||||
/**
|
||||
* Verifies whether the server version meets the minimum required version
|
||||
* @param minimumRequiredServerVersion The minimum version required
|
||||
* @returns True if the server version is greater than or equal to the minimum required version
|
||||
*/
|
||||
checkServerMeetsVersionRequirement$: (
|
||||
minimumRequiredServerVersion: SemVer,
|
||||
) => Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Triggers a check that the config for the currently active user is up-to-date. If it is not, it will be fetched from the server and stored.
|
||||
*/
|
||||
abstract ensureConfigFetched(): Promise<void>;
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "../../models/data/server-config.data";
|
||||
|
||||
const dayInMilliseconds = 24 * 3600 * 1000;
|
||||
const eighteenHoursInMilliseconds = 18 * 3600 * 1000;
|
||||
|
||||
export class ServerConfig {
|
||||
version: string;
|
||||
@@ -38,10 +37,6 @@ export class ServerConfig {
|
||||
return this.getAgeInMilliseconds() <= dayInMilliseconds;
|
||||
}
|
||||
|
||||
expiresSoon(): boolean {
|
||||
return this.getAgeInMilliseconds() >= eighteenHoursInMilliseconds;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<ServerConfig>): ServerConfig {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
|
||||
@@ -3,85 +3,85 @@ import { DecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class CryptoFunctionService {
|
||||
pbkdf2: (
|
||||
abstract pbkdf2(
|
||||
password: string | Uint8Array,
|
||||
salt: string | Uint8Array,
|
||||
algorithm: "sha256" | "sha512",
|
||||
iterations: number,
|
||||
) => Promise<Uint8Array>;
|
||||
argon2: (
|
||||
): Promise<Uint8Array>;
|
||||
abstract argon2(
|
||||
password: string | Uint8Array,
|
||||
salt: string | Uint8Array,
|
||||
iterations: number,
|
||||
memory: number,
|
||||
parallelism: number,
|
||||
) => Promise<Uint8Array>;
|
||||
hkdf: (
|
||||
): Promise<Uint8Array>;
|
||||
abstract hkdf(
|
||||
ikm: Uint8Array,
|
||||
salt: string | Uint8Array,
|
||||
info: string | Uint8Array,
|
||||
outputByteSize: number,
|
||||
algorithm: "sha256" | "sha512",
|
||||
) => Promise<Uint8Array>;
|
||||
hkdfExpand: (
|
||||
): Promise<Uint8Array>;
|
||||
abstract hkdfExpand(
|
||||
prk: Uint8Array,
|
||||
info: string | Uint8Array,
|
||||
outputByteSize: number,
|
||||
algorithm: "sha256" | "sha512",
|
||||
) => Promise<Uint8Array>;
|
||||
hash: (
|
||||
): Promise<Uint8Array>;
|
||||
abstract hash(
|
||||
value: string | Uint8Array,
|
||||
algorithm: "sha1" | "sha256" | "sha512" | "md5",
|
||||
) => Promise<Uint8Array>;
|
||||
hmac: (
|
||||
): Promise<Uint8Array>;
|
||||
abstract hmac(
|
||||
value: Uint8Array,
|
||||
key: Uint8Array,
|
||||
algorithm: "sha1" | "sha256" | "sha512",
|
||||
) => Promise<Uint8Array>;
|
||||
compare: (a: Uint8Array, b: Uint8Array) => Promise<boolean>;
|
||||
hmacFast: (
|
||||
): Promise<Uint8Array>;
|
||||
abstract compare(a: Uint8Array, b: Uint8Array): Promise<boolean>;
|
||||
abstract hmacFast(
|
||||
value: Uint8Array | string,
|
||||
key: Uint8Array | string,
|
||||
algorithm: "sha1" | "sha256" | "sha512",
|
||||
) => Promise<Uint8Array | string>;
|
||||
compareFast: (a: Uint8Array | string, b: Uint8Array | string) => Promise<boolean>;
|
||||
aesEncrypt: (data: Uint8Array, iv: Uint8Array, key: Uint8Array) => Promise<Uint8Array>;
|
||||
aesDecryptFastParameters: (
|
||||
): Promise<Uint8Array | string>;
|
||||
abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise<boolean>;
|
||||
abstract aesEncrypt(data: Uint8Array, iv: Uint8Array, key: Uint8Array): Promise<Uint8Array>;
|
||||
abstract aesDecryptFastParameters(
|
||||
data: string,
|
||||
iv: string,
|
||||
mac: string,
|
||||
key: SymmetricCryptoKey,
|
||||
) => DecryptParameters<Uint8Array | string>;
|
||||
aesDecryptFast: (
|
||||
): DecryptParameters<Uint8Array | string>;
|
||||
abstract aesDecryptFast(
|
||||
parameters: DecryptParameters<Uint8Array | string>,
|
||||
mode: "cbc" | "ecb",
|
||||
) => Promise<string>;
|
||||
aesDecrypt: (
|
||||
): Promise<string>;
|
||||
abstract aesDecrypt(
|
||||
data: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
key: Uint8Array,
|
||||
mode: "cbc" | "ecb",
|
||||
) => Promise<Uint8Array>;
|
||||
rsaEncrypt: (
|
||||
): Promise<Uint8Array>;
|
||||
abstract rsaEncrypt(
|
||||
data: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
) => Promise<Uint8Array>;
|
||||
rsaDecrypt: (
|
||||
): Promise<Uint8Array>;
|
||||
abstract rsaDecrypt(
|
||||
data: Uint8Array,
|
||||
privateKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
) => Promise<Uint8Array>;
|
||||
rsaExtractPublicKey: (privateKey: Uint8Array) => Promise<Uint8Array>;
|
||||
rsaGenerateKeyPair: (length: 1024 | 2048 | 4096) => Promise<[Uint8Array, Uint8Array]>;
|
||||
): Promise<Uint8Array>;
|
||||
abstract rsaExtractPublicKey(privateKey: Uint8Array): Promise<Uint8Array>;
|
||||
abstract rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]>;
|
||||
/**
|
||||
* Generates a key of the given length suitable for use in AES encryption
|
||||
*/
|
||||
aesGenerateKey: (bitLength: 128 | 192 | 256 | 512) => Promise<CsprngArray>;
|
||||
abstract aesGenerateKey(bitLength: 128 | 192 | 256 | 512): Promise<CsprngArray>;
|
||||
/**
|
||||
* Generates a random array of bytes of the given length. Uses a cryptographically secure random number generator.
|
||||
*
|
||||
* Do not use this for generating encryption keys. Use aesGenerateKey or rsaGenerateKeyPair instead.
|
||||
*/
|
||||
randomBytes: (length: number) => Promise<CsprngArray>;
|
||||
abstract randomBytes(length: number): Promise<CsprngArray>;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,15 @@ import { EncString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class CryptoService {
|
||||
activeUserKey$: Observable<UserKey>;
|
||||
abstract activeUserKey$: Observable<UserKey>;
|
||||
|
||||
/**
|
||||
* Returns the an observable key for the given user id.
|
||||
*
|
||||
* @note this observable represents only user keys stored in memory. A null value does not indicate that we cannot load a user key from storage.
|
||||
* @param userId The desired user
|
||||
*/
|
||||
abstract getInMemoryUserKeyFor$(userId: UserId): Observable<UserKey>;
|
||||
/**
|
||||
* Sets the provided user key and stores
|
||||
* any other necessary versions (such as auto, biometrics,
|
||||
@@ -22,105 +30,105 @@ export abstract class CryptoService {
|
||||
* @param key The user key to set
|
||||
* @param userId The desired user
|
||||
*/
|
||||
setUserKey: (key: UserKey, userId?: string) => Promise<void>;
|
||||
abstract setUserKey(key: UserKey, userId?: string): Promise<void>;
|
||||
/**
|
||||
* Gets the user key from memory and sets it again,
|
||||
* kicking off a refresh of any additional keys
|
||||
* (such as auto, biometrics, or pin)
|
||||
*/
|
||||
refreshAdditionalKeys: () => Promise<void>;
|
||||
abstract refreshAdditionalKeys(): Promise<void>;
|
||||
/**
|
||||
* Observable value that returns whether or not the currently active user has ever had auser key,
|
||||
* i.e. has ever been unlocked/decrypted. This is key for differentiating between TDE locked and standard locked states.
|
||||
*/
|
||||
everHadUserKey$: Observable<boolean>;
|
||||
abstract everHadUserKey$: Observable<boolean>;
|
||||
/**
|
||||
* Retrieves the user key
|
||||
* @param userId The desired user
|
||||
* @returns The user key
|
||||
*/
|
||||
getUserKey: (userId?: string) => Promise<UserKey>;
|
||||
abstract getUserKey(userId?: string): Promise<UserKey>;
|
||||
|
||||
/**
|
||||
* Checks if the user is using an old encryption scheme that used the master key
|
||||
* for encryption of data instead of the user key.
|
||||
*/
|
||||
isLegacyUser: (masterKey?: MasterKey, userId?: string) => Promise<boolean>;
|
||||
abstract isLegacyUser(masterKey?: MasterKey, userId?: string): Promise<boolean>;
|
||||
/**
|
||||
* Use for encryption/decryption of data in order to support legacy
|
||||
* encryption models. It will return the user key if available,
|
||||
* if not it will return the master key.
|
||||
* @param userId The desired user
|
||||
*/
|
||||
getUserKeyWithLegacySupport: (userId?: string) => Promise<UserKey>;
|
||||
abstract getUserKeyWithLegacySupport(userId?: string): Promise<UserKey>;
|
||||
/**
|
||||
* Retrieves the user key from storage
|
||||
* @param keySuffix The desired version of the user's key to retrieve
|
||||
* @param userId The desired user
|
||||
* @returns The user key
|
||||
*/
|
||||
getUserKeyFromStorage: (keySuffix: KeySuffixOptions, userId?: string) => Promise<UserKey>;
|
||||
abstract getUserKeyFromStorage(keySuffix: KeySuffixOptions, userId?: string): Promise<UserKey>;
|
||||
|
||||
/**
|
||||
* Determines whether the user key is available for the given user.
|
||||
* @param userId The desired user. If not provided, the active user will be used. If no active user exists, the method will return false.
|
||||
* @returns True if the user key is available
|
||||
*/
|
||||
hasUserKey: (userId?: UserId) => Promise<boolean>;
|
||||
abstract hasUserKey(userId?: UserId): Promise<boolean>;
|
||||
/**
|
||||
* Determines whether the user key is available for the given user in memory.
|
||||
* @param userId The desired user. If not provided, the active user will be used. If no active user exists, the method will return false.
|
||||
* @returns True if the user key is available
|
||||
*/
|
||||
hasUserKeyInMemory: (userId?: string) => Promise<boolean>;
|
||||
abstract hasUserKeyInMemory(userId?: string): Promise<boolean>;
|
||||
/**
|
||||
* @param keySuffix The desired version of the user's key to check
|
||||
* @param userId The desired user
|
||||
* @returns True if the provided version of the user key is stored
|
||||
*/
|
||||
hasUserKeyStored: (keySuffix: KeySuffixOptions, userId?: string) => Promise<boolean>;
|
||||
abstract hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: string): Promise<boolean>;
|
||||
/**
|
||||
* Generates a new user key
|
||||
* @param masterKey The user's master key
|
||||
* @returns A new user key and the master key protected version of it
|
||||
*/
|
||||
makeUserKey: (key: MasterKey) => Promise<[UserKey, EncString]>;
|
||||
abstract makeUserKey(key: MasterKey): Promise<[UserKey, EncString]>;
|
||||
/**
|
||||
* Clears the user key
|
||||
* @param clearStoredKeys Clears all stored versions of the user keys as well,
|
||||
* such as the biometrics key
|
||||
* @param userId The desired user
|
||||
*/
|
||||
clearUserKey: (clearSecretStorage?: boolean, userId?: string) => Promise<void>;
|
||||
abstract clearUserKey(clearSecretStorage?: boolean, userId?: string): Promise<void>;
|
||||
/**
|
||||
* Clears the user's stored version of the user key
|
||||
* @param keySuffix The desired version of the key to clear
|
||||
* @param userId The desired user
|
||||
*/
|
||||
clearStoredUserKey: (keySuffix: KeySuffixOptions, userId?: string) => Promise<void>;
|
||||
abstract clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: string): Promise<void>;
|
||||
/**
|
||||
* Stores the master key encrypted user key
|
||||
* @param userKeyMasterKey The master key encrypted user key to set
|
||||
* @param userId The desired user
|
||||
*/
|
||||
setMasterKeyEncryptedUserKey: (UserKeyMasterKey: string, userId?: string) => Promise<void>;
|
||||
abstract setMasterKeyEncryptedUserKey(UserKeyMasterKey: string, userId?: string): Promise<void>;
|
||||
/**
|
||||
* Sets the user's master key
|
||||
* @param key The user's master key to set
|
||||
* @param userId The desired user
|
||||
*/
|
||||
setMasterKey: (key: MasterKey, userId?: string) => Promise<void>;
|
||||
abstract setMasterKey(key: MasterKey, userId?: string): Promise<void>;
|
||||
/**
|
||||
* @param userId The desired user
|
||||
* @returns The user's master key
|
||||
*/
|
||||
getMasterKey: (userId?: string) => Promise<MasterKey>;
|
||||
abstract getMasterKey(userId?: string): Promise<MasterKey>;
|
||||
|
||||
/**
|
||||
* @param password The user's master password that will be used to derive a master key if one isn't found
|
||||
* @param userId The desired user
|
||||
*/
|
||||
getOrDeriveMasterKey: (password: string, userId?: string) => Promise<MasterKey>;
|
||||
abstract getOrDeriveMasterKey(password: string, userId?: string): Promise<MasterKey>;
|
||||
/**
|
||||
* Generates a master key from the provided password
|
||||
* @param password The user's master password
|
||||
@@ -129,17 +137,17 @@ export abstract class CryptoService {
|
||||
* @param KdfConfig The user's key derivation function configuration
|
||||
* @returns A master key derived from the provided password
|
||||
*/
|
||||
makeMasterKey: (
|
||||
abstract makeMasterKey(
|
||||
password: string,
|
||||
email: string,
|
||||
kdf: KdfType,
|
||||
KdfConfig: KdfConfig,
|
||||
) => Promise<MasterKey>;
|
||||
): Promise<MasterKey>;
|
||||
/**
|
||||
* Clears the user's master key
|
||||
* @param userId The desired user
|
||||
*/
|
||||
clearMasterKey: (userId?: string) => Promise<void>;
|
||||
abstract clearMasterKey(userId?: string): Promise<void>;
|
||||
/**
|
||||
* Encrypts the existing (or provided) user key with the
|
||||
* provided master key
|
||||
@@ -147,10 +155,10 @@ export abstract class CryptoService {
|
||||
* @param userKey The user key
|
||||
* @returns The user key and the master key protected version of it
|
||||
*/
|
||||
encryptUserKeyWithMasterKey: (
|
||||
abstract encryptUserKeyWithMasterKey(
|
||||
masterKey: MasterKey,
|
||||
userKey?: UserKey,
|
||||
) => Promise<[UserKey, EncString]>;
|
||||
): Promise<[UserKey, EncString]>;
|
||||
/**
|
||||
* Decrypts the user key with the provided master key
|
||||
* @param masterKey The user's master key
|
||||
@@ -158,11 +166,11 @@ export abstract class CryptoService {
|
||||
* @param userId The desired user
|
||||
* @returns The user key
|
||||
*/
|
||||
decryptUserKeyWithMasterKey: (
|
||||
abstract decryptUserKeyWithMasterKey(
|
||||
masterKey: MasterKey,
|
||||
userKey?: EncString,
|
||||
userId?: string,
|
||||
) => Promise<UserKey>;
|
||||
): Promise<UserKey>;
|
||||
/**
|
||||
* Creates a master password hash from the user's master password. Can
|
||||
* be used for local authentication or for server authentication depending
|
||||
@@ -172,21 +180,25 @@ export abstract class CryptoService {
|
||||
* @param hashPurpose The iterations to use for the hash
|
||||
* @returns The user's master password hash
|
||||
*/
|
||||
hashMasterKey: (password: string, key: MasterKey, hashPurpose?: HashPurpose) => Promise<string>;
|
||||
abstract hashMasterKey(
|
||||
password: string,
|
||||
key: MasterKey,
|
||||
hashPurpose?: HashPurpose,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Sets the user's master password hash
|
||||
* @param keyHash The user's master password hash to set
|
||||
*/
|
||||
setMasterKeyHash: (keyHash: string) => Promise<void>;
|
||||
abstract setMasterKeyHash(keyHash: string): Promise<void>;
|
||||
/**
|
||||
* @returns The user's master password hash
|
||||
*/
|
||||
getMasterKeyHash: () => Promise<string>;
|
||||
abstract getMasterKeyHash(): Promise<string>;
|
||||
/**
|
||||
* Clears the user's stored master password hash
|
||||
* @param userId The desired user
|
||||
*/
|
||||
clearMasterKeyHash: (userId?: string) => Promise<void>;
|
||||
abstract clearMasterKeyHash(userId?: string): Promise<void>;
|
||||
/**
|
||||
* Compares the provided master password to the stored password hash and server password hash.
|
||||
* Updates the stored hash if outdated.
|
||||
@@ -195,107 +207,109 @@ export abstract class CryptoService {
|
||||
* @returns True if the provided master password matches either the stored
|
||||
* key hash or the server key hash
|
||||
*/
|
||||
compareAndUpdateKeyHash: (masterPassword: string, masterKey: MasterKey) => Promise<boolean>;
|
||||
abstract compareAndUpdateKeyHash(masterPassword: string, masterKey: MasterKey): Promise<boolean>;
|
||||
/**
|
||||
* Stores the encrypted organization keys and clears any decrypted
|
||||
* organization keys currently in memory
|
||||
* @param orgs The organizations to set keys for
|
||||
* @param providerOrgs The provider organizations to set keys for
|
||||
*/
|
||||
setOrgKeys: (
|
||||
abstract setOrgKeys(
|
||||
orgs: ProfileOrganizationResponse[],
|
||||
providerOrgs: ProfileProviderOrganizationResponse[],
|
||||
) => Promise<void>;
|
||||
activeUserOrgKeys$: Observable<Record<OrganizationId, OrgKey>>;
|
||||
): Promise<void>;
|
||||
abstract activeUserOrgKeys$: Observable<Record<OrganizationId, OrgKey>>;
|
||||
/**
|
||||
* Returns the organization's symmetric key
|
||||
* @deprecated Use the observable activeUserOrgKeys$ and `map` to the desired orgKey instead
|
||||
* @param orgId The desired organization
|
||||
* @returns The organization's symmetric key
|
||||
*/
|
||||
getOrgKey: (orgId: string) => Promise<OrgKey>;
|
||||
abstract getOrgKey(orgId: string): Promise<OrgKey>;
|
||||
/**
|
||||
* @deprecated Use the observable activeUserOrgKeys$ instead
|
||||
* @returns A record of the organization Ids to their symmetric keys
|
||||
*/
|
||||
getOrgKeys: () => Promise<Record<string, SymmetricCryptoKey>>;
|
||||
abstract getOrgKeys(): Promise<Record<string, SymmetricCryptoKey>>;
|
||||
/**
|
||||
* Uses the org key to derive a new symmetric key for encrypting data
|
||||
* @param orgKey The organization's symmetric key
|
||||
*/
|
||||
makeDataEncKey: <T extends UserKey | OrgKey>(key: T) => Promise<[SymmetricCryptoKey, EncString]>;
|
||||
abstract makeDataEncKey<T extends UserKey | OrgKey>(
|
||||
key: T,
|
||||
): Promise<[SymmetricCryptoKey, EncString]>;
|
||||
/**
|
||||
* Clears the user's stored organization keys
|
||||
* @param memoryOnly Clear only the in-memory keys
|
||||
* @param userId The desired user
|
||||
*/
|
||||
clearOrgKeys: (memoryOnly?: boolean, userId?: string) => Promise<void>;
|
||||
abstract clearOrgKeys(memoryOnly?: boolean, userId?: string): Promise<void>;
|
||||
/**
|
||||
* Stores the encrypted provider keys and clears any decrypted
|
||||
* provider keys currently in memory
|
||||
* @param providers The providers to set keys for
|
||||
*/
|
||||
activeUserProviderKeys$: Observable<Record<ProviderId, ProviderKey>>;
|
||||
setProviderKeys: (orgs: ProfileProviderResponse[]) => Promise<void>;
|
||||
abstract activeUserProviderKeys$: Observable<Record<ProviderId, ProviderKey>>;
|
||||
abstract setProviderKeys(orgs: ProfileProviderResponse[]): Promise<void>;
|
||||
/**
|
||||
* @param providerId The desired provider
|
||||
* @returns The provider's symmetric key
|
||||
*/
|
||||
getProviderKey: (providerId: string) => Promise<ProviderKey>;
|
||||
abstract getProviderKey(providerId: string): Promise<ProviderKey>;
|
||||
/**
|
||||
* @returns A record of the provider Ids to their symmetric keys
|
||||
*/
|
||||
getProviderKeys: () => Promise<Record<ProviderId, ProviderKey>>;
|
||||
abstract getProviderKeys(): Promise<Record<ProviderId, ProviderKey>>;
|
||||
/**
|
||||
* @param memoryOnly Clear only the in-memory keys
|
||||
* @param userId The desired user
|
||||
*/
|
||||
clearProviderKeys: (memoryOnly?: boolean, userId?: string) => Promise<void>;
|
||||
abstract clearProviderKeys(memoryOnly?: boolean, userId?: string): Promise<void>;
|
||||
/**
|
||||
* Returns the public key from memory. If not available, extracts it
|
||||
* from the private key and stores it in memory
|
||||
* @returns The user's public key
|
||||
*/
|
||||
getPublicKey: () => Promise<Uint8Array>;
|
||||
abstract getPublicKey(): Promise<Uint8Array>;
|
||||
/**
|
||||
* Creates a new organization key and encrypts it with the user's public key.
|
||||
* This method can also return Provider keys for creating new Provider users.
|
||||
* @returns The new encrypted org key and the decrypted key itself
|
||||
*/
|
||||
makeOrgKey: <T extends OrgKey | ProviderKey>() => Promise<[EncString, T]>;
|
||||
abstract makeOrgKey<T extends OrgKey | ProviderKey>(): Promise<[EncString, T]>;
|
||||
/**
|
||||
* Sets the the user's encrypted private key in storage and
|
||||
* clears the decrypted private key from memory
|
||||
* Note: does not clear the private key if null is provided
|
||||
* @param encPrivateKey An encrypted private key
|
||||
*/
|
||||
setPrivateKey: (encPrivateKey: string) => Promise<void>;
|
||||
abstract setPrivateKey(encPrivateKey: string): Promise<void>;
|
||||
/**
|
||||
* Returns the private key from memory. If not available, decrypts it
|
||||
* from storage and stores it in memory
|
||||
* @returns The user's private key
|
||||
*/
|
||||
getPrivateKey: () => Promise<Uint8Array>;
|
||||
abstract getPrivateKey(): Promise<Uint8Array>;
|
||||
/**
|
||||
* Generates a fingerprint phrase for the user based on their public key
|
||||
* @param fingerprintMaterial Fingerprint material
|
||||
* @param publicKey The user's public key
|
||||
* @returns The user's fingerprint phrase
|
||||
*/
|
||||
getFingerprint: (fingerprintMaterial: string, publicKey?: Uint8Array) => Promise<string[]>;
|
||||
abstract getFingerprint(fingerprintMaterial: string, publicKey?: Uint8Array): Promise<string[]>;
|
||||
/**
|
||||
* Generates a new keypair
|
||||
* @param key A key to encrypt the private key with. If not provided,
|
||||
* defaults to the user key
|
||||
* @returns A new keypair: [publicKey in Base64, encrypted privateKey]
|
||||
*/
|
||||
makeKeyPair: (key?: SymmetricCryptoKey) => Promise<[string, EncString]>;
|
||||
abstract makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]>;
|
||||
/**
|
||||
* Clears the user's key pair
|
||||
* @param memoryOnly Clear only the in-memory keys
|
||||
* @param userId The desired user
|
||||
*/
|
||||
clearKeyPair: (memoryOnly?: boolean, userId?: string) => Promise<void[]>;
|
||||
abstract clearKeyPair(memoryOnly?: boolean, userId?: string): Promise<void[]>;
|
||||
/**
|
||||
* @param pin The user's pin
|
||||
* @param salt The user's salt
|
||||
@@ -303,14 +317,19 @@ export abstract class CryptoService {
|
||||
* @param kdfConfig The user's kdf config
|
||||
* @returns A key derived from the user's pin
|
||||
*/
|
||||
makePinKey: (pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig) => Promise<PinKey>;
|
||||
abstract makePinKey(
|
||||
pin: string,
|
||||
salt: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig,
|
||||
): Promise<PinKey>;
|
||||
/**
|
||||
* Clears the user's pin keys from storage
|
||||
* Note: This will remove the stored pin and as a result,
|
||||
* disable pin protection for the user
|
||||
* @param userId The desired user
|
||||
*/
|
||||
clearPinKeys: (userId?: string) => Promise<void>;
|
||||
abstract clearPinKeys(userId?: string): Promise<void>;
|
||||
/**
|
||||
* Decrypts the user key with their pin
|
||||
* @param pin The user's PIN
|
||||
@@ -321,13 +340,13 @@ export abstract class CryptoService {
|
||||
* it will be retrieved from storage
|
||||
* @returns The decrypted user key
|
||||
*/
|
||||
decryptUserKeyWithPin: (
|
||||
abstract decryptUserKeyWithPin(
|
||||
pin: string,
|
||||
salt: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig,
|
||||
protectedKeyCs?: EncString,
|
||||
) => Promise<UserKey>;
|
||||
): Promise<UserKey>;
|
||||
/**
|
||||
* Creates a new Pin key that encrypts the user key instead of the
|
||||
* master key. Clears the old Pin key from state.
|
||||
@@ -340,55 +359,55 @@ export abstract class CryptoService {
|
||||
* places depending on if Master Password on Restart was enabled)
|
||||
* @returns The user key
|
||||
*/
|
||||
decryptAndMigrateOldPinKey: (
|
||||
abstract decryptAndMigrateOldPinKey(
|
||||
masterPasswordOnRestart: boolean,
|
||||
pin: string,
|
||||
email: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig,
|
||||
oldPinKey: EncString,
|
||||
) => Promise<UserKey>;
|
||||
): Promise<UserKey>;
|
||||
/**
|
||||
* Replaces old master auto keys with new user auto keys
|
||||
*/
|
||||
migrateAutoKeyIfNeeded: (userId?: string) => Promise<void>;
|
||||
abstract migrateAutoKeyIfNeeded(userId?: string): Promise<void>;
|
||||
/**
|
||||
* @param keyMaterial The key material to derive the send key from
|
||||
* @returns A new send key
|
||||
*/
|
||||
makeSendKey: (keyMaterial: Uint8Array) => Promise<SymmetricCryptoKey>;
|
||||
abstract makeSendKey(keyMaterial: Uint8Array): Promise<SymmetricCryptoKey>;
|
||||
/**
|
||||
* Clears all of the user's keys from storage
|
||||
* @param userId The user's Id
|
||||
*/
|
||||
clearKeys: (userId?: string) => Promise<any>;
|
||||
abstract clearKeys(userId?: string): Promise<any>;
|
||||
/**
|
||||
* RSA encrypts a value.
|
||||
* @param data The data to encrypt
|
||||
* @param publicKey The public key to use for encryption, if not provided, the user's public key will be used
|
||||
* @returns The encrypted data
|
||||
*/
|
||||
rsaEncrypt: (data: Uint8Array, publicKey?: Uint8Array) => Promise<EncString>;
|
||||
abstract rsaEncrypt(data: Uint8Array, publicKey?: Uint8Array): Promise<EncString>;
|
||||
/**
|
||||
* Decrypts a value using RSA.
|
||||
* @param encValue The encrypted value to decrypt
|
||||
* @param privateKeyValue The private key to use for decryption
|
||||
* @returns The decrypted value
|
||||
*/
|
||||
rsaDecrypt: (encValue: string, privateKeyValue?: Uint8Array) => Promise<Uint8Array>;
|
||||
randomNumber: (min: number, max: number) => Promise<number>;
|
||||
abstract rsaDecrypt(encValue: string, privateKeyValue?: Uint8Array): Promise<Uint8Array>;
|
||||
abstract randomNumber(min: number, max: number): Promise<number>;
|
||||
/**
|
||||
* Generates a new cipher key
|
||||
* @returns A new cipher key
|
||||
*/
|
||||
makeCipherKey: () => Promise<CipherKey>;
|
||||
abstract makeCipherKey(): Promise<CipherKey>;
|
||||
|
||||
/**
|
||||
* Initialize all necessary crypto keys needed for a new account.
|
||||
* Warning! This completely replaces any existing keys!
|
||||
* @returns The user's newly created public key, private key, and encrypted private key
|
||||
*/
|
||||
initAccount: () => Promise<{
|
||||
abstract initAccount(): Promise<{
|
||||
userKey: UserKey;
|
||||
publicKey: string;
|
||||
privateKey: EncString;
|
||||
@@ -400,18 +419,18 @@ export abstract class CryptoService {
|
||||
* @remarks
|
||||
* Should always be called before updating a users KDF config.
|
||||
*/
|
||||
validateKdfConfig: (kdf: KdfType, kdfConfig: KdfConfig) => void;
|
||||
abstract validateKdfConfig(kdf: KdfType, kdfConfig: KdfConfig): void;
|
||||
|
||||
/**
|
||||
* @deprecated Left for migration purposes. Use decryptUserKeyWithPin instead.
|
||||
*/
|
||||
decryptMasterKeyWithPin: (
|
||||
abstract decryptMasterKeyWithPin(
|
||||
pin: string,
|
||||
salt: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig,
|
||||
protectedKeyCs?: EncString,
|
||||
) => Promise<MasterKey>;
|
||||
): Promise<MasterKey>;
|
||||
/**
|
||||
* Previously, the master key was used for any additional key like the biometrics or pin key.
|
||||
* We have switched to using the user key for these purposes. This method is for clearing the state
|
||||
@@ -419,30 +438,36 @@ export abstract class CryptoService {
|
||||
* @param keySuffix The desired type of key to clear
|
||||
* @param userId The desired user
|
||||
*/
|
||||
clearDeprecatedKeys: (keySuffix: KeySuffixOptions, userId?: string) => Promise<void>;
|
||||
abstract clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: string): Promise<void>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.encrypt
|
||||
*/
|
||||
encrypt: (plainValue: string | Uint8Array, key?: SymmetricCryptoKey) => Promise<EncString>;
|
||||
abstract encrypt(plainValue: string | Uint8Array, key?: SymmetricCryptoKey): Promise<EncString>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.encryptToBytes
|
||||
*/
|
||||
encryptToBytes: (plainValue: Uint8Array, key?: SymmetricCryptoKey) => Promise<EncArrayBuffer>;
|
||||
abstract encryptToBytes(
|
||||
plainValue: Uint8Array,
|
||||
key?: SymmetricCryptoKey,
|
||||
): Promise<EncArrayBuffer>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToBytes
|
||||
*/
|
||||
decryptToBytes: (encString: EncString, key?: SymmetricCryptoKey) => Promise<Uint8Array>;
|
||||
abstract decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToUtf8
|
||||
*/
|
||||
decryptToUtf8: (encString: EncString, key?: SymmetricCryptoKey) => Promise<string>;
|
||||
abstract decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise<string>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToBytes
|
||||
*/
|
||||
decryptFromBytes: (encBuffer: EncArrayBuffer, key: SymmetricCryptoKey) => Promise<Uint8Array>;
|
||||
abstract decryptFromBytes(
|
||||
encBuffer: EncArrayBuffer,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
@@ -7,23 +7,26 @@ import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class EncryptService {
|
||||
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
|
||||
abstract encryptToBytes: (
|
||||
abstract encryptToBytes(
|
||||
plainValue: Uint8Array,
|
||||
key?: SymmetricCryptoKey,
|
||||
) => Promise<EncArrayBuffer>;
|
||||
abstract decryptToUtf8: (encString: EncString, key: SymmetricCryptoKey) => Promise<string>;
|
||||
abstract decryptToBytes: (encThing: Encrypted, key: SymmetricCryptoKey) => Promise<Uint8Array>;
|
||||
abstract rsaEncrypt: (data: Uint8Array, publicKey: Uint8Array) => Promise<EncString>;
|
||||
abstract rsaDecrypt: (data: EncString, privateKey: Uint8Array) => Promise<Uint8Array>;
|
||||
abstract resolveLegacyKey: (key: SymmetricCryptoKey, encThing: Encrypted) => SymmetricCryptoKey;
|
||||
abstract decryptItems: <T extends InitializerMetadata>(
|
||||
): Promise<EncArrayBuffer>;
|
||||
abstract decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise<string>;
|
||||
abstract decryptToBytes(encThing: Encrypted, key: SymmetricCryptoKey): Promise<Uint8Array>;
|
||||
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
|
||||
abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array>;
|
||||
abstract resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey;
|
||||
abstract decryptItems<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
) => Promise<T[]>;
|
||||
): Promise<T[]>;
|
||||
/**
|
||||
* Generates a base64-encoded hash of the given value
|
||||
* @param value The value to hash
|
||||
* @param algorithm The hashing algorithm to use
|
||||
*/
|
||||
hash: (value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512") => Promise<string>;
|
||||
abstract hash(
|
||||
value: string | Uint8Array,
|
||||
algorithm: "sha1" | "sha256" | "sha512",
|
||||
): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -14,64 +14,119 @@ export type Urls = {
|
||||
scim?: string;
|
||||
};
|
||||
|
||||
export type PayPalConfig = {
|
||||
businessId?: string;
|
||||
buttonAction?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A subset of available regions, additional regions can be loaded through configuration.
|
||||
*/
|
||||
export enum Region {
|
||||
US = "US",
|
||||
EU = "EU",
|
||||
SelfHosted = "Self-hosted",
|
||||
}
|
||||
|
||||
export enum RegionDomain {
|
||||
US = "bitwarden.com",
|
||||
EU = "bitwarden.eu",
|
||||
USQA = "bitwarden.pw",
|
||||
/**
|
||||
* The possible cloud regions.
|
||||
*/
|
||||
export type CloudRegion = Exclude<Region, Region.SelfHosted>;
|
||||
|
||||
export type RegionConfig = {
|
||||
// Beware this isn't completely true, it's actually a string for custom environments,
|
||||
// which are currently only supported in web where it doesn't matter.
|
||||
key: Region;
|
||||
domain: string;
|
||||
urls: Urls;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Environment interface represents a server environment.
|
||||
*
|
||||
* It provides methods to retrieve the URLs of the different services.
|
||||
*/
|
||||
export interface Environment {
|
||||
/**
|
||||
* Retrieve the current region.
|
||||
*/
|
||||
getRegion(): Region;
|
||||
/**
|
||||
* Retrieve the urls, should only be used when configuring the environment.
|
||||
*/
|
||||
getUrls(): Urls;
|
||||
|
||||
/**
|
||||
* Identify if the region is a cloud environment.
|
||||
*
|
||||
* @returns true if the environment is a cloud environment, false otherwise.
|
||||
*/
|
||||
isCloud(): boolean;
|
||||
|
||||
getApiUrl(): string;
|
||||
getEventsUrl(): string;
|
||||
getIconsUrl(): string;
|
||||
getIdentityUrl(): string;
|
||||
|
||||
/**
|
||||
* @deprecated This is currently only used by the CLI. This functionality should be extracted since
|
||||
* the CLI relies on changing environment mid-login.
|
||||
*
|
||||
* @remarks
|
||||
* Expect this to be null unless the CLI has explicitly set it during the login flow.
|
||||
*/
|
||||
getKeyConnectorUrl(): string | null;
|
||||
getNotificationsUrl(): string;
|
||||
getScimUrl(): string;
|
||||
getSendUrl(): string;
|
||||
getWebVaultUrl(): string;
|
||||
|
||||
/**
|
||||
* Get a friendly hostname for the environment.
|
||||
*
|
||||
* - For self-hosted this is the web vault url without protocol prefix.
|
||||
* - For cloud environments it's the domain key.
|
||||
*/
|
||||
getHostname(): string;
|
||||
|
||||
// Not sure why we provide this, evaluate if we can remove it.
|
||||
hasBaseUrl(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment service. Provides access to set the current environment urls and region.
|
||||
*/
|
||||
export abstract class EnvironmentService {
|
||||
urls: Observable<void>;
|
||||
usUrls: Urls;
|
||||
euUrls: Urls;
|
||||
selectedRegion?: Region;
|
||||
initialized = true;
|
||||
abstract environment$: Observable<Environment>;
|
||||
abstract cloudWebVaultUrl$: Observable<string>;
|
||||
|
||||
hasBaseUrl: () => boolean;
|
||||
getNotificationsUrl: () => string;
|
||||
getWebVaultUrl: () => string;
|
||||
/**
|
||||
* Retrieves the URL of the cloud web vault app.
|
||||
* Retrieve all the available regions for environment selectors.
|
||||
*
|
||||
* @returns {string} The URL of the cloud web vault app.
|
||||
* @remarks Use this method only in views exclusive to self-host instances.
|
||||
* This currently relies on compile time provided constants, and will not change at runtime.
|
||||
* Expect all builds to include production environments, QA builds to also include QA
|
||||
* environments and dev builds to include localhost.
|
||||
*/
|
||||
getCloudWebVaultUrl: () => string;
|
||||
abstract availableRegions(): RegionConfig[];
|
||||
|
||||
/**
|
||||
* Set the global environment.
|
||||
*/
|
||||
abstract setEnvironment(region: Region, urls?: Urls): Promise<Urls>;
|
||||
|
||||
/**
|
||||
* Seed the environment state for a given user based on the global environment.
|
||||
*
|
||||
* @remarks
|
||||
* Expected to be called only by the StateService when adding a new account.
|
||||
*/
|
||||
abstract seedUserEnvironment(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the URL of the cloud web vault app based on the region parameter.
|
||||
*
|
||||
* @param {Region} region - The region of the cloud web vault app.
|
||||
* @param userId - The user id to set the cloud web vault app URL for. If null or undefined the global environment is set.
|
||||
* @param region - The region of the cloud web vault app.
|
||||
*/
|
||||
setCloudWebVaultUrl: (region: Region) => void;
|
||||
abstract setCloudRegion(userId: UserId, region: Region): Promise<void>;
|
||||
|
||||
/**
|
||||
* Seed the environment for a given user based on the globally set defaults.
|
||||
* Get the environment from state. Useful if you need to get the environment for another user.
|
||||
*/
|
||||
seedUserEnvironment: (userId: UserId) => Promise<void>;
|
||||
|
||||
getSendUrl: () => string;
|
||||
getIconsUrl: () => string;
|
||||
getApiUrl: () => string;
|
||||
getIdentityUrl: () => string;
|
||||
getEventsUrl: () => string;
|
||||
getKeyConnectorUrl: () => string;
|
||||
getScimUrl: () => string;
|
||||
setUrlsFromStorage: () => Promise<void>;
|
||||
setUrls: (urls: Urls) => Promise<Urls>;
|
||||
getHost: (userId?: string) => Promise<string>;
|
||||
setRegion: (region: Region) => Promise<void>;
|
||||
getUrls: () => Urls;
|
||||
isCloud: () => boolean;
|
||||
isEmpty: () => boolean;
|
||||
abstract getEnvironment(userId?: string): Promise<Environment | undefined>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FileDownloadRequest } from "./file-download.request";
|
||||
|
||||
export abstract class FileDownloadService {
|
||||
download: (request: FileDownloadRequest) => void;
|
||||
abstract download(request: FileDownloadRequest): void;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { EncArrayBuffer } from "../../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../models/domain/enc-string";
|
||||
|
||||
export abstract class FileUploadService {
|
||||
upload: (
|
||||
abstract upload(
|
||||
uploadData: { url: string; fileUploadType: FileUploadType },
|
||||
fileName: EncString,
|
||||
encryptedFileData: EncArrayBuffer,
|
||||
fileUploadMethods: FileUploadApiMethods,
|
||||
) => Promise<void>;
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export type FileUploadApiMethods = {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Observable } from "rxjs";
|
||||
import { TranslationService } from "./translation.service";
|
||||
|
||||
export abstract class I18nService extends TranslationService {
|
||||
userSetLocale$: Observable<string | undefined>;
|
||||
locale$: Observable<string>;
|
||||
abstract userSetLocale$: Observable<string | undefined>;
|
||||
abstract locale$: Observable<string>;
|
||||
abstract setLocale(locale: string): Promise<void>;
|
||||
abstract init(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export abstract class KeyGenerationService {
|
||||
* 512 bits = 64 bytes
|
||||
* @returns Generated key.
|
||||
*/
|
||||
createKey: (bitLength: 256 | 512) => Promise<SymmetricCryptoKey>;
|
||||
abstract createKey(bitLength: 256 | 512): Promise<SymmetricCryptoKey>;
|
||||
/**
|
||||
* Generates key material from CSPRNG and derives a 64 byte key from it.
|
||||
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869}
|
||||
@@ -22,11 +22,11 @@ export abstract class KeyGenerationService {
|
||||
* @param salt Optional. If not provided will be generated from CSPRNG.
|
||||
* @returns An object containing the salt, key material, and derived key.
|
||||
*/
|
||||
createKeyWithPurpose: (
|
||||
abstract createKeyWithPurpose(
|
||||
bitLength: 128 | 192 | 256 | 512,
|
||||
purpose: string,
|
||||
salt?: string,
|
||||
) => Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>;
|
||||
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>;
|
||||
/**
|
||||
* Derives a 64 byte key from key material.
|
||||
* @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}.
|
||||
@@ -37,11 +37,11 @@ export abstract class KeyGenerationService {
|
||||
* Different purposes results in different keys, even with the same material.
|
||||
* @returns 64 byte derived key.
|
||||
*/
|
||||
deriveKeyFromMaterial: (
|
||||
abstract deriveKeyFromMaterial(
|
||||
material: CsprngArray,
|
||||
salt: string,
|
||||
purpose: string,
|
||||
) => Promise<SymmetricCryptoKey>;
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
/**
|
||||
* Derives a 32 byte key from a password using a key derivation function.
|
||||
* @param password Password to derive the key from.
|
||||
@@ -50,10 +50,10 @@ export abstract class KeyGenerationService {
|
||||
* @param kdfConfig Configuration for the key derivation function.
|
||||
* @returns 32 byte derived key.
|
||||
*/
|
||||
deriveKeyFromPassword: (
|
||||
abstract deriveKeyFromPassword(
|
||||
password: string | Uint8Array,
|
||||
salt: string | Uint8Array,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig,
|
||||
) => Promise<SymmetricCryptoKey>;
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { LogLevelType } from "../enums/log-level-type.enum";
|
||||
|
||||
export abstract class LogService {
|
||||
debug: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
write: (level: LogLevelType, message: string) => void;
|
||||
abstract debug(message: string): void;
|
||||
abstract info(message: string): void;
|
||||
abstract warning(message: string): void;
|
||||
abstract error(message: string): void;
|
||||
abstract write(level: LogLevelType, message: string): void;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export abstract class MessagingService {
|
||||
send: (subscriber: string, arg?: any) => void;
|
||||
abstract send(subscriber: string, arg?: any): void;
|
||||
}
|
||||
|
||||
@@ -12,34 +12,34 @@ export type ClipboardOptions = {
|
||||
};
|
||||
|
||||
export abstract class PlatformUtilsService {
|
||||
getDevice: () => DeviceType;
|
||||
getDeviceString: () => string;
|
||||
getClientType: () => ClientType;
|
||||
isFirefox: () => boolean;
|
||||
isChrome: () => boolean;
|
||||
isEdge: () => boolean;
|
||||
isOpera: () => boolean;
|
||||
isVivaldi: () => boolean;
|
||||
isSafari: () => boolean;
|
||||
isMacAppStore: () => boolean;
|
||||
isViewOpen: () => Promise<boolean>;
|
||||
launchUri: (uri: string, options?: any) => void;
|
||||
getApplicationVersion: () => Promise<string>;
|
||||
getApplicationVersionNumber: () => Promise<string>;
|
||||
supportsWebAuthn: (win: Window) => boolean;
|
||||
supportsDuo: () => boolean;
|
||||
showToast: (
|
||||
abstract getDevice(): DeviceType;
|
||||
abstract getDeviceString(): string;
|
||||
abstract getClientType(): ClientType;
|
||||
abstract isFirefox(): boolean;
|
||||
abstract isChrome(): boolean;
|
||||
abstract isEdge(): boolean;
|
||||
abstract isOpera(): boolean;
|
||||
abstract isVivaldi(): boolean;
|
||||
abstract isSafari(): boolean;
|
||||
abstract isMacAppStore(): boolean;
|
||||
abstract isViewOpen(): Promise<boolean>;
|
||||
abstract launchUri(uri: string, options?: any): void;
|
||||
abstract getApplicationVersion(): Promise<string>;
|
||||
abstract getApplicationVersionNumber(): Promise<string>;
|
||||
abstract supportsWebAuthn(win: Window): boolean;
|
||||
abstract supportsDuo(): boolean;
|
||||
abstract showToast(
|
||||
type: "error" | "success" | "warning" | "info",
|
||||
title: string,
|
||||
text: string | string[],
|
||||
options?: ToastOptions,
|
||||
) => void;
|
||||
isDev: () => boolean;
|
||||
isSelfHost: () => boolean;
|
||||
copyToClipboard: (text: string, options?: ClipboardOptions) => void | boolean;
|
||||
readFromClipboard: () => Promise<string>;
|
||||
supportsBiometric: () => Promise<boolean>;
|
||||
authenticateBiometric: () => Promise<boolean>;
|
||||
supportsSecureStorage: () => boolean;
|
||||
getAutofillKeyboardShortcut: () => Promise<string>;
|
||||
): void;
|
||||
abstract isDev(): boolean;
|
||||
abstract isSelfHost(): boolean;
|
||||
abstract copyToClipboard(text: string, options?: ClipboardOptions): void | boolean;
|
||||
abstract readFromClipboard(): Promise<string>;
|
||||
abstract supportsBiometric(): Promise<boolean>;
|
||||
abstract authenticateBiometric(): Promise<boolean>;
|
||||
abstract supportsSecureStorage(): boolean;
|
||||
abstract getAutofillKeyboardShortcut(): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,15 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re
|
||||
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
|
||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||
import { BiometricKey } from "../../auth/types/biometric-key";
|
||||
import { WindowState } from "../../models/domain/window-state";
|
||||
import { GeneratorOptions } from "../../tools/generator/generator-options";
|
||||
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
|
||||
import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, MasterKey } from "../../types/key";
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { KdfType } from "../enums";
|
||||
import { ServerConfigData } from "../models/data/server-config.data";
|
||||
import { Account, AccountDecryptionOptions } from "../models/domain/account";
|
||||
import { Account } from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { StorageOptions } from "../models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
@@ -46,12 +44,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
clean: (options?: StorageOptions) => Promise<UserId>;
|
||||
init: (initOptions?: InitOptions) => Promise<void>;
|
||||
|
||||
getAlwaysShowDock: (options?: StorageOptions) => Promise<boolean>;
|
||||
setAlwaysShowDock: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
|
||||
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
|
||||
setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* Gets the user's master key
|
||||
*/
|
||||
@@ -159,32 +151,17 @@ export abstract class StateService<T extends Account = Account> {
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
|
||||
getDisableGa: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableGa: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
|
||||
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getDeviceKey: (options?: StorageOptions) => Promise<DeviceKey | null>;
|
||||
setDeviceKey: (value: DeviceKey | null, options?: StorageOptions) => Promise<void>;
|
||||
getAdminAuthRequest: (options?: StorageOptions) => Promise<AdminAuthRequestStorable | null>;
|
||||
setAdminAuthRequest: (
|
||||
adminAuthRequest: AdminAuthRequestStorable,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
getShouldTrustDevice: (options?: StorageOptions) => Promise<boolean | null>;
|
||||
setShouldTrustDevice: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getAccountDecryptionOptions: (
|
||||
options?: StorageOptions,
|
||||
) => Promise<AccountDecryptionOptions | null>;
|
||||
setAccountDecryptionOptions: (
|
||||
value: AccountDecryptionOptions,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
getEmail: (options?: StorageOptions) => Promise<string>;
|
||||
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableAlwaysOnTop: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
|
||||
@@ -192,19 +169,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
value: boolean,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
getEnableCloseToTray: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableCloseToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableDuckDuckGoBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableDuckDuckGoBrowserIntegration: (
|
||||
value: boolean,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
getEnableMinimizeToTray: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableMinimizeToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableStartToTray: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableStartToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableTray: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEncryptedPasswordGenerationHistory: (
|
||||
options?: StorageOptions,
|
||||
) => Promise<GeneratedPasswordHistory[]>;
|
||||
@@ -235,8 +199,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
value: ForceSetPasswordReason,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
getInstalledVersion: (options?: StorageOptions) => Promise<string>;
|
||||
setInstalledVersion: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
|
||||
getKdfConfig: (options?: StorageOptions) => Promise<KdfConfig>;
|
||||
setKdfConfig: (kdfConfig: KdfConfig, options?: StorageOptions) => Promise<void>;
|
||||
@@ -248,14 +210,8 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getLastSync: (options?: StorageOptions) => Promise<string>;
|
||||
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getLocale: (options?: StorageOptions) => Promise<string>;
|
||||
setLocale: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getMainWindowSize: (options?: StorageOptions) => Promise<number>;
|
||||
setMainWindowSize: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
|
||||
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getOpenAtLogin: (options?: StorageOptions) => Promise<boolean>;
|
||||
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
|
||||
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
|
||||
getPasswordGenerationOptions: (options?: StorageOptions) => Promise<PasswordGeneratorOptions>;
|
||||
@@ -278,29 +234,15 @@ export abstract class StateService<T extends Account = Account> {
|
||||
* Sets the user's Pin, encrypted by the user key
|
||||
*/
|
||||
setProtectedPin: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getRememberedEmail: (options?: StorageOptions) => Promise<string>;
|
||||
setRememberedEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getSecurityStamp: (options?: StorageOptions) => Promise<string>;
|
||||
setSecurityStamp: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getUserId: (options?: StorageOptions) => Promise<string>;
|
||||
getUsesKeyConnector: (options?: StorageOptions) => Promise<boolean>;
|
||||
setUsesKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getVaultTimeout: (options?: StorageOptions) => Promise<number>;
|
||||
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
|
||||
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
|
||||
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getWindow: () => Promise<WindowState>;
|
||||
setWindow: (value: WindowState) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use ConfigService
|
||||
*/
|
||||
getServerConfig: (options?: StorageOptions) => Promise<ServerConfigData>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use ConfigService
|
||||
*/
|
||||
setServerConfig: (value: ServerConfigData, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* fetches string value of URL user tried to navigate to while unauthenticated.
|
||||
* @param options Defines the storage options for the URL; Defaults to session Storage.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
|
||||
export abstract class SystemService {
|
||||
startProcessReload: (authService: AuthService) => Promise<void>;
|
||||
cancelProcessReload: () => void;
|
||||
clearClipboard: (clipboardValue: string, timeoutMs?: number) => Promise<void>;
|
||||
clearPendingClipboard: () => Promise<any>;
|
||||
abstract startProcessReload(authService: AuthService): Promise<void>;
|
||||
abstract cancelProcessReload(): void;
|
||||
abstract clearClipboard(clipboardValue: string, timeoutMs?: number): Promise<void>;
|
||||
abstract clearPendingClipboard(): Promise<any>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export abstract class TranslationService {
|
||||
supportedTranslationLocales: string[];
|
||||
translationLocale: string;
|
||||
collator: Intl.Collator;
|
||||
localeNames: Map<string, string>;
|
||||
t: (id: string, p1?: string | number, p2?: string | number, p3?: string | number) => string;
|
||||
translate: (id: string, p1?: string, p2?: string, p3?: string) => string;
|
||||
abstract supportedTranslationLocales: string[];
|
||||
abstract translationLocale: string;
|
||||
abstract collator: Intl.Collator;
|
||||
abstract localeNames: Map<string, string>;
|
||||
abstract t(id: string, p1?: string | number, p2?: string | number, p3?: string | number): string;
|
||||
abstract translate(id: string, p1?: string, p2?: string, p3?: string): string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export abstract class ValidationService {
|
||||
showError: (data: any) => string[];
|
||||
abstract showError(data: any): string[];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { makeEncString } from "../../../spec";
|
||||
import { mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeSingleUserState } from "../../../spec/fake-state";
|
||||
import { makeEncString, trackEmissions } from "../../../spec";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeGlobalState, FakeSingleUserState } from "../../../spec/fake-state";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { EncryptedString } from "../models/domain/enc-string";
|
||||
@@ -23,10 +23,11 @@ describe("BiometricStateService", () => {
|
||||
const userId = "userId" as UserId;
|
||||
const encClientKeyHalf = makeEncString();
|
||||
const encryptedClientKeyHalf = encClientKeyHalf.encryptedString;
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
let accountService: FakeAccountService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
sut = new DefaultBiometricStateService(stateProvider);
|
||||
@@ -145,19 +146,89 @@ describe("BiometricStateService", () => {
|
||||
});
|
||||
|
||||
describe("setPromptCancelled", () => {
|
||||
let existingState: Record<UserId, boolean>;
|
||||
|
||||
beforeEach(() => {
|
||||
existingState = { ["otherUser" as UserId]: false };
|
||||
stateProvider.global.getFake(PROMPT_CANCELLED).stateSubject.next(existingState);
|
||||
});
|
||||
|
||||
test("observable is updated", async () => {
|
||||
await sut.setPromptCancelled();
|
||||
await sut.setUserPromptCancelled();
|
||||
|
||||
expect(await firstValueFrom(sut.promptCancelled$)).toBe(true);
|
||||
});
|
||||
|
||||
it("updates state", async () => {
|
||||
await sut.setPromptCancelled();
|
||||
await sut.setUserPromptCancelled();
|
||||
|
||||
const nextMock = stateProvider.activeUser.getFake(PROMPT_CANCELLED).nextMock;
|
||||
expect(nextMock).toHaveBeenCalledWith([userId, true]);
|
||||
const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock;
|
||||
expect(nextMock).toHaveBeenCalledWith({ ...existingState, [userId]: true });
|
||||
expect(nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when called with no active user", async () => {
|
||||
await accountService.switchAccount(null);
|
||||
await expect(sut.setUserPromptCancelled()).rejects.toThrow(
|
||||
"Cannot update biometric prompt cancelled state without an active user",
|
||||
);
|
||||
const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock;
|
||||
expect(nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAllPromptCancelled", () => {
|
||||
it("deletes all prompt cancelled state", async () => {
|
||||
await sut.resetAllPromptCancelled();
|
||||
|
||||
const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock;
|
||||
expect(nextMock).toHaveBeenCalledWith(null);
|
||||
expect(nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates observable to false", async () => {
|
||||
const emissions = trackEmissions(sut.promptCancelled$);
|
||||
|
||||
await sut.setUserPromptCancelled();
|
||||
|
||||
await sut.resetAllPromptCancelled();
|
||||
|
||||
expect(emissions).toEqual([false, true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetUserPromptCancelled", () => {
|
||||
let existingState: Record<UserId, boolean>;
|
||||
let state: FakeGlobalState<Record<UserId, boolean>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await accountService.switchAccount(userId);
|
||||
existingState = { [userId]: true, ["otherUser" as UserId]: false };
|
||||
state = stateProvider.global.getFake(PROMPT_CANCELLED);
|
||||
state.stateSubject.next(existingState);
|
||||
});
|
||||
|
||||
it("deletes specified user prompt cancelled state", async () => {
|
||||
await sut.resetUserPromptCancelled("otherUser" as UserId);
|
||||
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: true });
|
||||
expect(state.nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deletes active user when called with no user", async () => {
|
||||
await sut.resetUserPromptCancelled();
|
||||
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ ["otherUser" as UserId]: false });
|
||||
expect(state.nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates observable to false", async () => {
|
||||
const emissions = trackEmissions(sut.promptCancelled$);
|
||||
|
||||
await sut.resetUserPromptCancelled();
|
||||
|
||||
expect(emissions).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPromptAutomatically", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Observable, firstValueFrom, map } from "rxjs";
|
||||
import { Observable, firstValueFrom, map, combineLatest } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { EncryptedString, EncString } from "../models/domain/enc-string";
|
||||
@@ -18,42 +18,42 @@ export abstract class BiometricStateService {
|
||||
/**
|
||||
* `true` if the currently active user has elected to store a biometric key to unlock their vault.
|
||||
*/
|
||||
biometricUnlockEnabled$: Observable<boolean>; // used to be biometricUnlock
|
||||
abstract biometricUnlockEnabled$: Observable<boolean>; // used to be biometricUnlock
|
||||
/**
|
||||
* If the user has elected to require a password on first unlock of an application instance, this key will store the
|
||||
* encrypted client key half used to unlock the vault.
|
||||
*
|
||||
* Tracks the currently active user
|
||||
*/
|
||||
encryptedClientKeyHalf$: Observable<EncString | undefined>;
|
||||
abstract encryptedClientKeyHalf$: Observable<EncString | undefined>;
|
||||
/**
|
||||
* whether or not a password is required on first unlock after opening the application
|
||||
*
|
||||
* tracks the currently active user
|
||||
*/
|
||||
requirePasswordOnStart$: Observable<boolean>;
|
||||
abstract requirePasswordOnStart$: Observable<boolean>;
|
||||
/**
|
||||
* Indicates the user has been warned about the security implications of using biometrics and, depending on the OS,
|
||||
*
|
||||
* tracks the currently active user.
|
||||
*/
|
||||
dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
|
||||
abstract dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
|
||||
/**
|
||||
* Whether the user has cancelled the biometric prompt.
|
||||
*
|
||||
* tracks the currently active user
|
||||
*/
|
||||
promptCancelled$: Observable<boolean>;
|
||||
abstract promptCancelled$: Observable<boolean>;
|
||||
/**
|
||||
* Whether the user has elected to automatically prompt for biometrics.
|
||||
*
|
||||
* tracks the currently active user
|
||||
*/
|
||||
promptAutomatically$: Observable<boolean>;
|
||||
abstract promptAutomatically$: Observable<boolean>;
|
||||
/**
|
||||
* Whether or not IPC fingerprint has been validated by the user this session.
|
||||
*/
|
||||
fingerprintValidated$: Observable<boolean>;
|
||||
abstract fingerprintValidated$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Updates the require password on start state for the currently active user.
|
||||
@@ -81,13 +81,18 @@ export abstract class BiometricStateService {
|
||||
*/
|
||||
abstract setDismissedRequirePasswordOnStartCallout(): Promise<void>;
|
||||
/**
|
||||
* Updates the active user's state to reflect that they've cancelled the biometric prompt this lock.
|
||||
* Updates the active user's state to reflect that they've cancelled the biometric prompt.
|
||||
*/
|
||||
abstract setPromptCancelled(): Promise<void>;
|
||||
abstract setUserPromptCancelled(): Promise<void>;
|
||||
/**
|
||||
* Resets the active user's state to reflect that they haven't cancelled the biometric prompt this lock.
|
||||
* Resets the given user's state to reflect that they haven't cancelled the biometric prompt.
|
||||
* @param userId the user to reset the prompt cancelled state for. If not provided, the currently active user will be used.
|
||||
*/
|
||||
abstract resetPromptCancelled(): Promise<void>;
|
||||
abstract resetUserPromptCancelled(userId?: UserId): Promise<void>;
|
||||
/**
|
||||
* Resets all user's state to reflect that they haven't cancelled the biometric prompt.
|
||||
*/
|
||||
abstract resetAllPromptCancelled(): Promise<void>;
|
||||
/**
|
||||
* Updates the currently active user's setting for auto prompting for biometrics on application start and lock
|
||||
* @param prompt Whether or not to prompt for biometrics on application start.
|
||||
@@ -107,7 +112,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
private requirePasswordOnStartState: ActiveUserState<boolean>;
|
||||
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
|
||||
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
|
||||
private promptCancelledState: ActiveUserState<boolean>;
|
||||
private promptCancelledState: GlobalState<Record<UserId, boolean>>;
|
||||
private promptAutomaticallyState: ActiveUserState<boolean>;
|
||||
private fingerprintValidatedState: GlobalState<boolean>;
|
||||
biometricUnlockEnabled$: Observable<boolean>;
|
||||
@@ -138,8 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
this.dismissedRequirePasswordOnStartCallout$ =
|
||||
this.dismissedRequirePasswordOnStartCalloutState.state$.pipe(map(Boolean));
|
||||
|
||||
this.promptCancelledState = this.stateProvider.getActive(PROMPT_CANCELLED);
|
||||
this.promptCancelled$ = this.promptCancelledState.state$.pipe(map(Boolean));
|
||||
this.promptCancelledState = this.stateProvider.getGlobal(PROMPT_CANCELLED);
|
||||
this.promptCancelled$ = combineLatest([
|
||||
this.stateProvider.activeUserId$,
|
||||
this.promptCancelledState.state$,
|
||||
]).pipe(
|
||||
map(([userId, record]) => {
|
||||
return record?.[userId] ?? false;
|
||||
}),
|
||||
);
|
||||
this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY);
|
||||
this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map(Boolean));
|
||||
|
||||
@@ -202,7 +214,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
|
||||
async logout(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null);
|
||||
await this.stateProvider.getUser(userId, PROMPT_CANCELLED).update(() => null);
|
||||
await this.resetUserPromptCancelled(userId);
|
||||
// Persist auto prompt setting through logout
|
||||
// Persist dismissed require password on start callout through logout
|
||||
}
|
||||
@@ -211,11 +223,41 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
await this.dismissedRequirePasswordOnStartCalloutState.update(() => true);
|
||||
}
|
||||
|
||||
async setPromptCancelled(): Promise<void> {
|
||||
await this.promptCancelledState.update(() => true);
|
||||
async resetUserPromptCancelled(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getGlobal(PROMPT_CANCELLED).update(
|
||||
(data, activeUserId) => {
|
||||
delete data[userId ?? activeUserId];
|
||||
return data;
|
||||
},
|
||||
{
|
||||
combineLatestWith: this.stateProvider.activeUserId$,
|
||||
shouldUpdate: (data, activeUserId) => data?.[userId ?? activeUserId] != null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async resetPromptCancelled(): Promise<void> {
|
||||
async setUserPromptCancelled(): Promise<void> {
|
||||
await this.promptCancelledState.update(
|
||||
(record, userId) => {
|
||||
record ??= {};
|
||||
record[userId] = true;
|
||||
return record;
|
||||
},
|
||||
{
|
||||
combineLatestWith: this.stateProvider.activeUserId$,
|
||||
shouldUpdate: (_, userId) => {
|
||||
if (userId == null) {
|
||||
throw new Error(
|
||||
"Cannot update biometric prompt cancelled state without an active user",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async resetAllPromptCancelled(): Promise<void> {
|
||||
await this.promptCancelledState.update(() => null);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
describe.each([
|
||||
[ENCRYPTED_CLIENT_KEY_HALF, "encryptedClientKeyHalf"],
|
||||
[DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, true],
|
||||
[PROMPT_CANCELLED, true],
|
||||
[PROMPT_CANCELLED, { userId1: true, userId2: false }],
|
||||
[PROMPT_AUTOMATICALLY, true],
|
||||
[REQUIRE_PASSWORD_ON_START, true],
|
||||
[BIOMETRIC_UNLOCK_ENABLED, true],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UserId } from "../../types/guid";
|
||||
import { EncryptedString } from "../models/domain/enc-string";
|
||||
import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state";
|
||||
|
||||
@@ -56,7 +57,7 @@ export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new KeyDefinition<boo
|
||||
* Stores whether the user has elected to cancel the biometric prompt. This is stored on disk due to process-reload
|
||||
* wiping memory state. We don't want to prompt the user again if they've elected to cancel.
|
||||
*/
|
||||
export const PROMPT_CANCELLED = new KeyDefinition<boolean>(
|
||||
export const PROMPT_CANCELLED = KeyDefinition.record<boolean, UserId>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"promptCancelled",
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { CsprngArray } from "../../../types/csprng";
|
||||
import { DeviceKey } from "../../../types/key";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { AccountKeys, EncryptionPair } from "./account";
|
||||
@@ -24,23 +22,6 @@ describe("AccountKeys", () => {
|
||||
const json = JSON.stringify(keys);
|
||||
expect(json).toContain('"publicKey":"hello"');
|
||||
});
|
||||
|
||||
// As the accountKeys.toJSON doesn't really serialize the device key
|
||||
// this method just checks the persistence of the deviceKey
|
||||
it("should persist deviceKey", () => {
|
||||
// Arrange
|
||||
const accountKeys = new AccountKeys();
|
||||
const deviceKeyBytesLength = 64;
|
||||
accountKeys.deviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
// Act
|
||||
const serializedKeys = accountKeys.toJSON();
|
||||
|
||||
// Assert
|
||||
expect(serializedKeys.deviceKey).toEqual(accountKeys.deviceKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromJSON", () => {
|
||||
@@ -64,24 +45,5 @@ describe("AccountKeys", () => {
|
||||
} as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize deviceKey", () => {
|
||||
// Arrange
|
||||
const expectedKeyB64 =
|
||||
"ZJNnhx9BbJeb2EAq1hlMjqt6GFsg9G/GzoFf6SbPKsaiMhKGDcbHcwcyEg56Lh8lfilpZz4SRM6UA7oFCg+lSg==";
|
||||
|
||||
const symmetricCryptoKeyFromJsonSpy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
|
||||
|
||||
// Act
|
||||
const accountKeys = AccountKeys.fromJSON({
|
||||
deviceKey: {
|
||||
keyB64: expectedKeyB64,
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Assert
|
||||
expect(symmetricCryptoKeyFromJsonSpy).toHaveBeenCalled();
|
||||
expect(accountKeys.deviceKey.keyB64).toEqual(expectedKeyB64);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,6 @@ import { Jsonify } from "type-fest";
|
||||
|
||||
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
|
||||
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
|
||||
import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option";
|
||||
import { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
|
||||
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
|
||||
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { GeneratorOptions } from "../../../tools/generator/generator-options";
|
||||
import {
|
||||
@@ -21,7 +18,6 @@ import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info";
|
||||
import { KdfType } from "../../enums";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
|
||||
import { EncryptedString, EncString } from "./enc-string";
|
||||
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
@@ -69,13 +65,6 @@ export class DataEncryptionPair<TEncrypted, TDecrypted> {
|
||||
decrypted?: TDecrypted[];
|
||||
}
|
||||
|
||||
// This is a temporary structure to handle migrated `DataEncryptionPair` to
|
||||
// avoid needing a data migration at this stage. It should be replaced with
|
||||
// proper data migrations when `DataEncryptionPair` is deprecated.
|
||||
export class TemporaryDataEncryption<TEncrypted> {
|
||||
encrypted?: { [id: string]: TEncrypted };
|
||||
}
|
||||
|
||||
export class AccountData {
|
||||
ciphers?: DataEncryptionPair<CipherData, CipherView> = new DataEncryptionPair<
|
||||
CipherData,
|
||||
@@ -106,7 +95,6 @@ export class AccountData {
|
||||
export class AccountKeys {
|
||||
masterKey?: MasterKey;
|
||||
masterKeyEncryptedUserKey?: string;
|
||||
deviceKey?: ReturnType<SymmetricCryptoKey["toJSON"]>;
|
||||
publicKey?: Uint8Array;
|
||||
|
||||
/** @deprecated July 2023, left for migration purposes*/
|
||||
@@ -136,7 +124,6 @@ export class AccountKeys {
|
||||
}
|
||||
return Object.assign(new AccountKeys(), obj, {
|
||||
masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey),
|
||||
deviceKey: obj?.deviceKey,
|
||||
cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey),
|
||||
cryptoSymmetricKey: EncryptionPair.fromJSON(
|
||||
obj?.cryptoSymmetricKey,
|
||||
@@ -162,7 +149,6 @@ export class AccountKeys {
|
||||
}
|
||||
|
||||
export class AccountProfile {
|
||||
convertAccountToKeyConnector?: boolean;
|
||||
name?: string;
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
@@ -170,7 +156,6 @@ export class AccountProfile {
|
||||
forceSetPasswordReason?: ForceSetPasswordReason;
|
||||
lastSync?: string;
|
||||
userId?: string;
|
||||
usesKeyConnector?: boolean;
|
||||
keyHash?: string;
|
||||
kdfIterations?: number;
|
||||
kdfMemory?: number;
|
||||
@@ -188,9 +173,6 @@ export class AccountProfile {
|
||||
|
||||
export class AccountSettings {
|
||||
defaultUriMatch?: UriMatchStrategySetting;
|
||||
disableGa?: boolean;
|
||||
enableAlwaysOnTop?: boolean;
|
||||
enableBiometric?: boolean;
|
||||
minimizeOnCopyToClipboard?: boolean;
|
||||
passwordGenerationOptions?: PasswordGeneratorOptions;
|
||||
usernameGenerationOptions?: UsernameGeneratorOptions;
|
||||
@@ -200,10 +182,7 @@ export class AccountSettings {
|
||||
protectedPin?: string;
|
||||
vaultTimeout?: number;
|
||||
vaultTimeoutAction?: string = "lock";
|
||||
serverConfig?: ServerConfigData;
|
||||
approveLoginRequests?: boolean;
|
||||
avatarColor?: string;
|
||||
trustDeviceChoiceForDecryption?: boolean;
|
||||
|
||||
/** @deprecated July 2023, left for migration purposes*/
|
||||
pinProtected?: EncryptionPair<string, EncString> = new EncryptionPair<string, EncString>();
|
||||
@@ -218,7 +197,6 @@ export class AccountSettings {
|
||||
obj?.pinProtected,
|
||||
EncString.fromJSON,
|
||||
),
|
||||
serverConfig: ServerConfigData.fromJSON(obj?.serverConfig),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -235,103 +213,12 @@ export class AccountTokens {
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountDecryptionOptions {
|
||||
hasMasterPassword: boolean;
|
||||
trustedDeviceOption?: TrustedDeviceUserDecryptionOption;
|
||||
keyConnectorOption?: KeyConnectorUserDecryptionOption;
|
||||
|
||||
constructor(init?: Partial<AccountDecryptionOptions>) {
|
||||
if (init) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: these nice getters don't work because the Account object is not properly being deserialized out of
|
||||
// JSON (the Account static fromJSON method is not running) so these getters don't exist on the
|
||||
// account decryptions options object when pulled out of state. This is a bug that needs to be fixed later on
|
||||
// get hasTrustedDeviceOption(): boolean {
|
||||
// return this.trustedDeviceOption !== null && this.trustedDeviceOption !== undefined;
|
||||
// }
|
||||
|
||||
// get hasKeyConnectorOption(): boolean {
|
||||
// return this.keyConnectorOption !== null && this.keyConnectorOption !== undefined;
|
||||
// }
|
||||
|
||||
static fromResponse(response: IdentityTokenResponse): AccountDecryptionOptions {
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountDecryptionOptions = new AccountDecryptionOptions();
|
||||
|
||||
if (response.userDecryptionOptions) {
|
||||
// If the response has userDecryptionOptions, this means it's on a post-TDE server version and can interrogate
|
||||
// the new decryption options.
|
||||
const responseOptions = response.userDecryptionOptions;
|
||||
accountDecryptionOptions.hasMasterPassword = responseOptions.hasMasterPassword;
|
||||
|
||||
if (responseOptions.trustedDeviceOption) {
|
||||
accountDecryptionOptions.trustedDeviceOption = new TrustedDeviceUserDecryptionOption(
|
||||
responseOptions.trustedDeviceOption.hasAdminApproval,
|
||||
responseOptions.trustedDeviceOption.hasLoginApprovingDevice,
|
||||
responseOptions.trustedDeviceOption.hasManageResetPasswordPermission,
|
||||
);
|
||||
}
|
||||
|
||||
if (responseOptions.keyConnectorOption) {
|
||||
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
|
||||
responseOptions.keyConnectorOption.keyConnectorUrl,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If the response does not have userDecryptionOptions, this means it's on a pre-TDE server version and so
|
||||
// we must base our decryption options on the presence of the keyConnectorUrl.
|
||||
// Note that the presence of keyConnectorUrl implies that the user does not have a master password, as in pre-TDE
|
||||
// server versions, a master password short-circuited the addition of the keyConnectorUrl to the response.
|
||||
// TODO: remove this check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
const usingKeyConnector = response.keyConnectorUrl != null;
|
||||
accountDecryptionOptions.hasMasterPassword = !usingKeyConnector;
|
||||
if (usingKeyConnector) {
|
||||
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
|
||||
response.keyConnectorUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
return accountDecryptionOptions;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<AccountDecryptionOptions>): AccountDecryptionOptions {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountDecryptionOptions = Object.assign(new AccountDecryptionOptions(), obj);
|
||||
|
||||
if (obj.trustedDeviceOption) {
|
||||
accountDecryptionOptions.trustedDeviceOption = new TrustedDeviceUserDecryptionOption(
|
||||
obj.trustedDeviceOption.hasAdminApproval,
|
||||
obj.trustedDeviceOption.hasLoginApprovingDevice,
|
||||
obj.trustedDeviceOption.hasManageResetPasswordPermission,
|
||||
);
|
||||
}
|
||||
|
||||
if (obj.keyConnectorOption) {
|
||||
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
|
||||
obj.keyConnectorOption.keyConnectorUrl,
|
||||
);
|
||||
}
|
||||
|
||||
return accountDecryptionOptions;
|
||||
}
|
||||
}
|
||||
|
||||
export class Account {
|
||||
data?: AccountData = new AccountData();
|
||||
keys?: AccountKeys = new AccountKeys();
|
||||
profile?: AccountProfile = new AccountProfile();
|
||||
settings?: AccountSettings = new AccountSettings();
|
||||
tokens?: AccountTokens = new AccountTokens();
|
||||
decryptionOptions?: AccountDecryptionOptions = new AccountDecryptionOptions();
|
||||
adminAuthRequest?: Jsonify<AdminAuthRequestStorable> = null;
|
||||
|
||||
constructor(init: Partial<Account>) {
|
||||
@@ -356,10 +243,6 @@ export class Account {
|
||||
...new AccountTokens(),
|
||||
...init?.tokens,
|
||||
},
|
||||
decryptionOptions: {
|
||||
...new AccountDecryptionOptions(),
|
||||
...init?.decryptionOptions,
|
||||
},
|
||||
adminAuthRequest: init?.adminAuthRequest,
|
||||
});
|
||||
}
|
||||
@@ -375,7 +258,6 @@ export class Account {
|
||||
profile: AccountProfile.fromJSON(json?.profile),
|
||||
settings: AccountSettings.fromJSON(json?.settings),
|
||||
tokens: AccountTokens.fromJSON(json?.tokens),
|
||||
decryptionOptions: AccountDecryptionOptions.fromJSON(json?.decryptionOptions),
|
||||
adminAuthRequest: AdminAuthRequestStorable.fromJSON(json?.adminAuthRequest),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
import { WindowState } from "../../../models/domain/window-state";
|
||||
import { ThemeType } from "../../enums";
|
||||
|
||||
export class GlobalState {
|
||||
enableAlwaysOnTop?: boolean;
|
||||
installedVersion?: string;
|
||||
locale?: string;
|
||||
organizationInvitation?: any;
|
||||
rememberedEmail?: string;
|
||||
theme?: ThemeType = ThemeType.System;
|
||||
window?: WindowState = new WindowState();
|
||||
twoFactorToken?: string;
|
||||
biometricFingerprintValidated?: boolean;
|
||||
vaultTimeout?: number;
|
||||
vaultTimeoutAction?: string;
|
||||
loginRedirect?: any;
|
||||
mainWindowSize?: number;
|
||||
enableTray?: boolean;
|
||||
enableMinimizeToTray?: boolean;
|
||||
enableCloseToTray?: boolean;
|
||||
enableStartToTray?: boolean;
|
||||
openAtLogin?: boolean;
|
||||
alwaysShowDock?: boolean;
|
||||
enableBrowserIntegration?: boolean;
|
||||
enableBrowserIntegrationFingerprint?: boolean;
|
||||
enableDuckDuckGoBrowserIntegration?: boolean;
|
||||
deepLinkRedirectUrl?: string;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ServerConfigResponse } from "../../models/response/server-config.response";
|
||||
|
||||
export class ConfigApiService implements ConfigApiServiceAbstraction {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private authService: AuthService,
|
||||
private tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
async get(): Promise<ServerConfigResponse> {
|
||||
async get(userId: UserId | undefined): Promise<ServerConfigResponse> {
|
||||
// Authentication adds extra context to config responses, if the user has an access token, we want to use it
|
||||
// We don't particularly care about ensuring the token is valid and not expired, just that it exists
|
||||
const authed: boolean =
|
||||
(await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut;
|
||||
userId == null ? false : (await this.tokenService.getAccessToken(userId)) != null;
|
||||
|
||||
const r = await this.apiService.send("GET", "/config", null, authed, true);
|
||||
return new ServerConfigResponse(r);
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { ReplaySubject, skip, take } from "rxjs";
|
||||
/**
|
||||
* need to update test environment so structuredClone works appropriately
|
||||
* @jest-environment ../../libs/shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { Subject, firstValueFrom, of } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
FakeStateProvider,
|
||||
awaitAsync,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { subscribeTo } from "../../../../spec/observable-tracker";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
import {
|
||||
EnvironmentServerConfigResponse,
|
||||
@@ -15,177 +27,238 @@ import {
|
||||
ThirdPartyServerConfigResponse,
|
||||
} from "../../models/response/server-config.response";
|
||||
|
||||
import { ConfigService } from "./config.service";
|
||||
import {
|
||||
ApiUrl,
|
||||
DefaultConfigService,
|
||||
RETRIEVAL_INTERVAL,
|
||||
GLOBAL_SERVER_CONFIGURATIONS,
|
||||
USER_SERVER_CONFIG,
|
||||
} from "./default-config.service";
|
||||
|
||||
describe("ConfigService", () => {
|
||||
let stateService: MockProxy<StateService>;
|
||||
let configApiService: MockProxy<ConfigApiServiceAbstraction>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
let serverResponseCount: number; // increments to track distinct responses received from server
|
||||
|
||||
// Observables will start emitting as soon as this is created, so only create it
|
||||
// after everything is mocked
|
||||
const configServiceFactory = () => {
|
||||
const configService = new ConfigService(
|
||||
stateService,
|
||||
configApiService,
|
||||
authService,
|
||||
environmentService,
|
||||
logService,
|
||||
);
|
||||
configService.init();
|
||||
return configService;
|
||||
};
|
||||
const configApiService = mock<ConfigApiServiceAbstraction>();
|
||||
const environmentService = mock<EnvironmentService>();
|
||||
const logService = mock<LogService>();
|
||||
let stateProvider: FakeStateProvider;
|
||||
let globalState: FakeGlobalState<Record<ApiUrl, ServerConfig>>;
|
||||
let userState: FakeSingleUserState<ServerConfig>;
|
||||
const activeApiUrl = apiUrl(0);
|
||||
const userId = "userId" as UserId;
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
const tooOld = new Date(Date.now() - 1.1 * RETRIEVAL_INTERVAL);
|
||||
|
||||
beforeEach(() => {
|
||||
stateService = mock();
|
||||
configApiService = mock();
|
||||
authService = mock();
|
||||
environmentService = mock();
|
||||
logService = mock();
|
||||
|
||||
environmentService.urls = new ReplaySubject<void>(1);
|
||||
|
||||
serverResponseCount = 1;
|
||||
configApiService.get.mockImplementation(() =>
|
||||
Promise.resolve(serverConfigResponseFactory("server" + serverResponseCount++)),
|
||||
);
|
||||
|
||||
jest.useFakeTimers();
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
globalState = stateProvider.global.getFake(GLOBAL_SERVER_CONFIGURATIONS);
|
||||
userState = stateProvider.singleUser.getFake(userId, USER_SERVER_CONFIG);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Uses storage as fallback", (done) => {
|
||||
const storedConfigData = serverConfigDataFactory("storedConfig");
|
||||
stateService.getServerConfig.mockResolvedValueOnce(storedConfigData);
|
||||
describe.each([null, userId])("active user: %s", (activeUserId) => {
|
||||
let sut: DefaultConfigService;
|
||||
|
||||
configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch"));
|
||||
|
||||
const configService = configServiceFactory();
|
||||
|
||||
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
|
||||
expect(config).toEqual(new ServerConfig(storedConfigData));
|
||||
expect(stateService.getServerConfig).toHaveBeenCalledTimes(1);
|
||||
expect(stateService.setServerConfig).not.toHaveBeenCalled();
|
||||
done();
|
||||
beforeAll(async () => {
|
||||
await accountService.switchAccount(activeUserId);
|
||||
});
|
||||
|
||||
configService.triggerServerConfigFetch();
|
||||
});
|
||||
|
||||
it("Stream does not error out if fetch fails", (done) => {
|
||||
const storedConfigData = serverConfigDataFactory("storedConfig");
|
||||
stateService.getServerConfig.mockResolvedValueOnce(storedConfigData);
|
||||
|
||||
const configService = configServiceFactory();
|
||||
|
||||
configService.serverConfig$.pipe(skip(1), take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server1");
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
});
|
||||
|
||||
configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch"));
|
||||
configService.triggerServerConfigFetch();
|
||||
|
||||
configApiService.get.mockResolvedValueOnce(serverConfigResponseFactory("server1"));
|
||||
configService.triggerServerConfigFetch();
|
||||
});
|
||||
|
||||
describe("Fetches config from server", () => {
|
||||
beforeEach(() => {
|
||||
stateService.getServerConfig.mockResolvedValueOnce(null);
|
||||
environmentService.environment$ = of(environmentFactory(activeApiUrl));
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
logService,
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
it.each<number | jest.DoneCallback>([1, 2, 3])(
|
||||
"after %p hour/s",
|
||||
(hours: number, done: jest.DoneCallback) => {
|
||||
const configService = configServiceFactory();
|
||||
describe("serverConfig$", () => {
|
||||
it.each([{}, null])("handles null stored state", async (globalTestState) => {
|
||||
globalState.stateSubject.next(globalTestState);
|
||||
userState.nextState(null);
|
||||
await expect(firstValueFrom(sut.serverConfig$)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
// skip previous hours (if any)
|
||||
configService.serverConfig$.pipe(skip(hours - 1), take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server" + hours);
|
||||
expect(configApiService.get).toHaveBeenCalledTimes(hours);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
describe.each(["stale", "missing"])("%s config", (configStateDescription) => {
|
||||
const userStored =
|
||||
configStateDescription === "missing"
|
||||
? null
|
||||
: serverConfigFactory(activeApiUrl + userId, tooOld);
|
||||
const globalStored =
|
||||
configStateDescription === "missing"
|
||||
? {}
|
||||
: {
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl, tooOld),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
globalState.stateSubject.next(globalStored);
|
||||
userState.nextState(userStored);
|
||||
});
|
||||
|
||||
const oneHourInMs = 1000 * 3600;
|
||||
jest.advanceTimersByTime(oneHourInMs * hours + 1);
|
||||
},
|
||||
);
|
||||
// sanity check
|
||||
test("authed and unauthorized state are different", () => {
|
||||
expect(globalStored[activeApiUrl]).not.toEqual(userStored);
|
||||
});
|
||||
|
||||
it("when environment URLs change", (done) => {
|
||||
const configService = configServiceFactory();
|
||||
describe("fail to fetch", () => {
|
||||
beforeEach(() => {
|
||||
configApiService.get.mockRejectedValue(new Error("Unable to fetch"));
|
||||
});
|
||||
|
||||
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server1");
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
it("uses storage as fallback", async () => {
|
||||
const actual = await firstValueFrom(sut.serverConfig$);
|
||||
expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
||||
expect(configApiService.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not error out when fetch fails", async () => {
|
||||
await expect(firstValueFrom(sut.serverConfig$)).resolves.not.toThrow();
|
||||
expect(configApiService.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs an error when unable to fetch", async () => {
|
||||
await firstValueFrom(sut.serverConfig$);
|
||||
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
`Unable to fetch ServerConfig from ${activeApiUrl}: Unable to fetch`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch success", () => {
|
||||
const response = serverConfigResponseFactory();
|
||||
const newConfig = new ServerConfig(new ServerConfigData(response));
|
||||
|
||||
it("should be a new config", async () => {
|
||||
expect(newConfig).not.toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
||||
});
|
||||
|
||||
it("fetches config from server when it's older than an hour", async () => {
|
||||
await firstValueFrom(sut.serverConfig$);
|
||||
|
||||
expect(configApiService.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the updated config", async () => {
|
||||
configApiService.get.mockResolvedValue(response);
|
||||
|
||||
const actual = await firstValueFrom(sut.serverConfig$);
|
||||
|
||||
// This is the time the response is converted to a config
|
||||
expect(actual.utcDate).toAlmostEqual(newConfig.utcDate, 1000);
|
||||
delete actual.utcDate;
|
||||
delete newConfig.utcDate;
|
||||
|
||||
expect(actual).toEqual(newConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
(environmentService.urls as ReplaySubject<void>).next();
|
||||
});
|
||||
describe("fresh configuration", () => {
|
||||
const userStored = serverConfigFactory(activeApiUrl + userId);
|
||||
const globalStored = {
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl),
|
||||
};
|
||||
beforeEach(() => {
|
||||
globalState.stateSubject.next(globalStored);
|
||||
userState.nextState(userStored);
|
||||
});
|
||||
it("does not fetch from server", async () => {
|
||||
await firstValueFrom(sut.serverConfig$);
|
||||
|
||||
it("when triggerServerConfigFetch() is called", (done) => {
|
||||
const configService = configServiceFactory();
|
||||
expect(configApiService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server1");
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
it("uses stored value", async () => {
|
||||
const actual = await firstValueFrom(sut.serverConfig$);
|
||||
expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
||||
});
|
||||
|
||||
it("does not complete after emit", async () => {
|
||||
const emissions = [];
|
||||
const subscription = sut.serverConfig$.subscribe((v) => emissions.push(v));
|
||||
await awaitAsync();
|
||||
expect(emissions.length).toBe(1);
|
||||
expect(subscription.closed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
configService.triggerServerConfigFetch();
|
||||
});
|
||||
});
|
||||
|
||||
it("Saves server config to storage when the user is logged in", (done) => {
|
||||
stateService.getServerConfig.mockResolvedValueOnce(null);
|
||||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
|
||||
const configService = configServiceFactory();
|
||||
describe("environment change", () => {
|
||||
let sut: DefaultConfigService;
|
||||
let environmentSubject: Subject<Environment>;
|
||||
|
||||
configService.serverConfig$.pipe(take(1)).subscribe(() => {
|
||||
try {
|
||||
expect(stateService.setServerConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitHash: "server1" }),
|
||||
);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
beforeAll(async () => {
|
||||
// updating environment with an active account is undefined behavior
|
||||
await accountService.switchAccount(null);
|
||||
});
|
||||
|
||||
configService.triggerServerConfigFetch();
|
||||
beforeEach(() => {
|
||||
environmentSubject = new Subject<Environment>();
|
||||
environmentService.environment$ = environmentSubject;
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
logService,
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
describe("serverConfig$", () => {
|
||||
it("emits a new config when the environment changes", async () => {
|
||||
const globalStored = {
|
||||
[apiUrl(0)]: serverConfigFactory(apiUrl(0)),
|
||||
[apiUrl(1)]: serverConfigFactory(apiUrl(1)),
|
||||
};
|
||||
globalState.stateSubject.next(globalStored);
|
||||
|
||||
const spy = subscribeTo(sut.serverConfig$);
|
||||
|
||||
environmentSubject.next(environmentFactory(apiUrl(0)));
|
||||
environmentSubject.next(environmentFactory(apiUrl(1)));
|
||||
|
||||
const expected = [globalStored[apiUrl(0)], globalStored[apiUrl(1)]];
|
||||
|
||||
const actual = await spy.pauseUntilReceived(2);
|
||||
expect(actual.length).toBe(2);
|
||||
|
||||
// validate dates this is done separately because the dates are created when ServerConfig is initialized
|
||||
expect(actual[0].utcDate).toAlmostEqual(expected[0].utcDate, 1000);
|
||||
expect(actual[1].utcDate).toAlmostEqual(expected[1].utcDate, 1000);
|
||||
delete actual[0].utcDate;
|
||||
delete actual[1].utcDate;
|
||||
delete expected[0].utcDate;
|
||||
delete expected[1].utcDate;
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
spy.unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function serverConfigDataFactory(gitHash: string) {
|
||||
return new ServerConfigData(serverConfigResponseFactory(gitHash));
|
||||
function apiUrl(count: number) {
|
||||
return `https://api${count}.test.com`;
|
||||
}
|
||||
|
||||
function serverConfigResponseFactory(gitHash: string) {
|
||||
function serverConfigFactory(hash: string, date: Date = new Date()) {
|
||||
const config = new ServerConfig(serverConfigDataFactory(hash));
|
||||
config.utcDate = date;
|
||||
return config;
|
||||
}
|
||||
|
||||
function serverConfigDataFactory(hash?: string) {
|
||||
return new ServerConfigData(serverConfigResponseFactory(hash));
|
||||
}
|
||||
|
||||
function serverConfigResponseFactory(hash?: string) {
|
||||
return new ServerConfigResponse({
|
||||
version: "myConfigVersion",
|
||||
gitHash: gitHash,
|
||||
gitHash: hash ?? Utils.newGuid(), // Use optional git hash to store uniqueness value
|
||||
server: new ThirdPartyServerConfigResponse({
|
||||
name: "myThirdPartyServer",
|
||||
url: "www.example.com",
|
||||
@@ -200,3 +273,9 @@ function serverConfigResponseFactory(gitHash: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function environmentFactory(apiUrl: string) {
|
||||
return {
|
||||
getApiUrl: () => apiUrl,
|
||||
} as Environment;
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
ReplaySubject,
|
||||
Subject,
|
||||
catchError,
|
||||
concatMap,
|
||||
defer,
|
||||
delayWhen,
|
||||
firstValueFrom,
|
||||
map,
|
||||
merge,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { FeatureFlag, FeatureFlagValue } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { EnvironmentService, Region } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
|
||||
const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600;
|
||||
|
||||
export class ConfigService implements ConfigServiceAbstraction {
|
||||
private inited = false;
|
||||
|
||||
protected _serverConfig = new ReplaySubject<ServerConfig | null>(1);
|
||||
serverConfig$ = this._serverConfig.asObservable();
|
||||
|
||||
private _forceFetchConfig = new Subject<void>();
|
||||
protected refreshTimer$ = timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS); // after 1 hour, then every hour
|
||||
|
||||
cloudRegion$ = this.serverConfig$.pipe(
|
||||
map((config) => config?.environment?.cloudRegion ?? Region.US),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private configApiService: ConfigApiServiceAbstraction,
|
||||
private authService: AuthService,
|
||||
private environmentService: EnvironmentService,
|
||||
private logService: LogService,
|
||||
|
||||
// Used to avoid duplicate subscriptions, e.g. in browser between the background and popup
|
||||
private subscribe = true,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
if (!this.subscribe || this.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestServerConfig$ = defer(() => this.configApiService.get()).pipe(
|
||||
map((response) => new ServerConfigData(response)),
|
||||
delayWhen((data) => this.saveConfig(data)),
|
||||
catchError((e: unknown) => {
|
||||
// fall back to stored ServerConfig (if any)
|
||||
this.logService.error("Unable to fetch ServerConfig: " + (e as Error)?.message);
|
||||
return this.stateService.getServerConfig();
|
||||
}),
|
||||
);
|
||||
|
||||
// If you need to fetch a new config when an event occurs, add an observable that emits on that event here
|
||||
merge(
|
||||
this.refreshTimer$, // an overridable interval
|
||||
this.environmentService.urls, // when environment URLs change (including when app is started)
|
||||
this._forceFetchConfig, // manual
|
||||
)
|
||||
.pipe(
|
||||
concatMap(() => latestServerConfig$),
|
||||
map((data) => (data == null ? null : new ServerConfig(data))),
|
||||
)
|
||||
.subscribe((config) => this._serverConfig.next(config));
|
||||
|
||||
this.inited = true;
|
||||
}
|
||||
|
||||
getFeatureFlag$<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[key] as T;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getFeatureFlag<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return await firstValueFrom(this.getFeatureFlag$(key, defaultValue));
|
||||
}
|
||||
|
||||
triggerServerConfigFetch() {
|
||||
this._forceFetchConfig.next();
|
||||
}
|
||||
|
||||
private async saveConfig(data: ServerConfigData) {
|
||||
if ((await this.authService.getAuthStatus()) === AuthenticationStatus.LoggedOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stateService.setServerConfig(data);
|
||||
this.environmentService.setCloudWebVaultUrl(data.environment?.cloudRegion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies whether the server version meets the minimum required version
|
||||
* @param minimumRequiredServerVersion The minimum version required
|
||||
* @returns True if the server version is greater than or equal to the minimum required version
|
||||
*/
|
||||
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => {
|
||||
if (serverConfig == null) {
|
||||
return false;
|
||||
}
|
||||
const serverVersion = new SemVer(serverConfig.version);
|
||||
return serverVersion.compare(minimumRequiredServerVersion) >= 0;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
NEVER,
|
||||
Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
map,
|
||||
mergeWith,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { FeatureFlag, FeatureFlagValue } from "../../../enums/feature-flag.enum";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { EnvironmentService, Region } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state";
|
||||
|
||||
export const RETRIEVAL_INTERVAL = 3_600_000; // 1 hour
|
||||
|
||||
export type ApiUrl = string;
|
||||
|
||||
export const USER_SERVER_CONFIG = new UserKeyDefinition<ServerConfig>(CONFIG_DISK, "serverConfig", {
|
||||
deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)),
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
|
||||
// TODO MDG: When to clean these up?
|
||||
export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, ApiUrl>(
|
||||
CONFIG_DISK,
|
||||
"byServer",
|
||||
{
|
||||
deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)),
|
||||
},
|
||||
);
|
||||
|
||||
// FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it.
|
||||
export class DefaultConfigService implements ConfigService {
|
||||
private failedFetchFallbackSubject = new Subject<ServerConfig>();
|
||||
|
||||
serverConfig$: Observable<ServerConfig>;
|
||||
|
||||
cloudRegion$: Observable<Region>;
|
||||
|
||||
constructor(
|
||||
private configApiService: ConfigApiServiceAbstraction,
|
||||
private environmentService: EnvironmentService,
|
||||
private logService: LogService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
const apiUrl$ = this.environmentService.environment$.pipe(
|
||||
map((environment) => environment.getApiUrl()),
|
||||
);
|
||||
|
||||
this.serverConfig$ = combineLatest([this.stateProvider.activeUserId$, apiUrl$]).pipe(
|
||||
switchMap(([userId, apiUrl]) => {
|
||||
const config$ =
|
||||
userId == null ? this.globalConfigFor$(apiUrl) : this.userConfigFor$(userId);
|
||||
return config$.pipe(map((config) => [config, userId, apiUrl] as const));
|
||||
}),
|
||||
tap(async (rec) => {
|
||||
const [existingConfig, userId, apiUrl] = rec;
|
||||
// Grab new config if older retrieval interval
|
||||
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
|
||||
await this.renewConfig(existingConfig, userId, apiUrl);
|
||||
}
|
||||
}),
|
||||
switchMap(([existingConfig]) => {
|
||||
// If we needed to fetch, stop this emit, we'll get a new one after update
|
||||
// This is split up with the above tap because we need to return an observable from a failed promise,
|
||||
// which isn't very doable since promises are converted to observables in switchMap
|
||||
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
|
||||
return NEVER;
|
||||
}
|
||||
return of(existingConfig);
|
||||
}),
|
||||
// If fetch fails, we'll emit on this subject to fallback to the existing config
|
||||
mergeWith(this.failedFetchFallbackSubject),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
this.cloudRegion$ = this.serverConfig$.pipe(
|
||||
map((config) => config?.environment?.cloudRegion ?? Region.US),
|
||||
);
|
||||
}
|
||||
getFeatureFlag$<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[key] as T;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getFeatureFlag<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return await firstValueFrom(this.getFeatureFlag$(key, defaultValue));
|
||||
}
|
||||
|
||||
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => {
|
||||
if (serverConfig == null) {
|
||||
return false;
|
||||
}
|
||||
const serverVersion = new SemVer(serverConfig.version);
|
||||
return serverVersion.compare(minimumRequiredServerVersion) >= 0;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async ensureConfigFetched() {
|
||||
// Triggering a retrieval for the given user ensures that the config is less than RETRIEVAL_INTERVAL old
|
||||
await firstValueFrom(this.serverConfig$);
|
||||
}
|
||||
|
||||
private olderThanRetrievalInterval(date: Date) {
|
||||
return new Date().getTime() - date.getTime() > RETRIEVAL_INTERVAL;
|
||||
}
|
||||
|
||||
// Updates the on-disk configuration with a newly retrieved configuration
|
||||
private async renewConfig(
|
||||
existingConfig: ServerConfig,
|
||||
userId: UserId,
|
||||
apiUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await this.configApiService.get(userId);
|
||||
const newConfig = new ServerConfig(new ServerConfigData(response));
|
||||
|
||||
// Update the environment region
|
||||
if (
|
||||
newConfig?.environment?.cloudRegion != null &&
|
||||
existingConfig?.environment?.cloudRegion != newConfig.environment.cloudRegion
|
||||
) {
|
||||
// Null userId sets global, otherwise sets to the given user
|
||||
await this.environmentService.setCloudRegion(userId, newConfig?.environment?.cloudRegion);
|
||||
}
|
||||
|
||||
if (userId == null) {
|
||||
// update global state with new pulled config
|
||||
await this.stateProvider.getGlobal(GLOBAL_SERVER_CONFIGURATIONS).update((configs) => {
|
||||
return { ...configs, [apiUrl]: newConfig };
|
||||
});
|
||||
} else {
|
||||
// update state with new pulled config
|
||||
await this.stateProvider.setUserState(USER_SERVER_CONFIG, newConfig, userId);
|
||||
}
|
||||
} catch (e) {
|
||||
// mutate error to be handled by catchError
|
||||
this.logService.error(
|
||||
`Unable to fetch ServerConfig from ${apiUrl}: ${(e as Error)?.message}`,
|
||||
);
|
||||
// Emit the existing config
|
||||
this.failedFetchFallbackSubject.next(existingConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private globalConfigFor$(apiUrl: string): Observable<ServerConfig> {
|
||||
return this.stateProvider
|
||||
.getGlobal(GLOBAL_SERVER_CONFIGURATIONS)
|
||||
.state$.pipe(map((configs) => configs?.[apiUrl]));
|
||||
}
|
||||
|
||||
private userConfigFor$(userId: UserId): Observable<ServerConfig> {
|
||||
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as bigInt from "big-integer";
|
||||
import { Observable, firstValueFrom, map } from "rxjs";
|
||||
import { Observable, filter, firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data";
|
||||
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
|
||||
@@ -100,7 +100,9 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
// User Asymmetric Key Pair
|
||||
this.activeUserEncryptedPrivateKeyState = stateProvider.getActive(USER_ENCRYPTED_PRIVATE_KEY);
|
||||
this.activeUserPrivateKeyState = stateProvider.getDerived(
|
||||
this.activeUserEncryptedPrivateKeyState.combinedState$,
|
||||
this.activeUserEncryptedPrivateKeyState.combinedState$.pipe(
|
||||
filter(([_userId, key]) => key != null),
|
||||
),
|
||||
USER_PRIVATE_KEY,
|
||||
{
|
||||
encryptService: this.encryptService,
|
||||
@@ -109,7 +111,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
);
|
||||
this.activeUserPrivateKey$ = this.activeUserPrivateKeyState.state$; // may be null
|
||||
this.activeUserPublicKeyState = stateProvider.getDerived(
|
||||
this.activeUserPrivateKey$,
|
||||
this.activeUserPrivateKey$.pipe(filter((key) => key != null)),
|
||||
USER_PUBLIC_KEY,
|
||||
{
|
||||
cryptoFunctionService: this.cryptoFunctionService,
|
||||
@@ -122,7 +124,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
USER_ENCRYPTED_ORGANIZATION_KEYS,
|
||||
);
|
||||
this.activeUserOrgKeysState = stateProvider.getDerived(
|
||||
this.activeUserEncryptedOrgKeysState.state$,
|
||||
this.activeUserEncryptedOrgKeysState.state$.pipe(filter((keys) => keys != null)),
|
||||
USER_ORGANIZATION_KEYS,
|
||||
{ cryptoService: this },
|
||||
);
|
||||
@@ -133,7 +135,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
USER_ENCRYPTED_PROVIDER_KEYS,
|
||||
);
|
||||
this.activeUserProviderKeysState = stateProvider.getDerived(
|
||||
this.activeUserEncryptedProviderKeysState.state$,
|
||||
this.activeUserEncryptedProviderKeysState.state$.pipe(filter((keys) => keys != null)),
|
||||
USER_PROVIDER_KEYS,
|
||||
{ encryptService: this.encryptService, cryptoService: this },
|
||||
);
|
||||
@@ -158,6 +160,10 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
await this.setUserKey(key);
|
||||
}
|
||||
|
||||
getInMemoryUserKeyFor$(userId: UserId): Observable<UserKey> {
|
||||
return this.stateProvider.getUserState$(USER_KEY, userId);
|
||||
}
|
||||
|
||||
async getUserKey(userId?: UserId): Promise<UserKey> {
|
||||
let userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId));
|
||||
if (userKey) {
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeStateProvider, awaitAsync } from "../../../spec";
|
||||
import { FakeAccountService } from "../../../spec/fake-account-service";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CloudRegion, Region } from "../abstractions/environment.service";
|
||||
|
||||
import {
|
||||
ENVIRONMENT_KEY,
|
||||
DefaultEnvironmentService,
|
||||
EnvironmentUrls,
|
||||
} from "./default-environment.service";
|
||||
|
||||
// There are a few main states EnvironmentService could be in when first used
|
||||
// 1. Not initialized, no active user. Hopefully not to likely but possible
|
||||
// 2. Not initialized, with active user. Not likely
|
||||
// 3. Initialized, no active user.
|
||||
// 4. Initialized, with active user.
|
||||
describe("EnvironmentService", () => {
|
||||
let accountService: FakeAccountService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
let sut: DefaultEnvironmentService;
|
||||
|
||||
const testUser = "00000000-0000-1000-a000-000000000001" as UserId;
|
||||
const alternateTestUser = "00000000-0000-1000-a000-000000000002" as UserId;
|
||||
|
||||
beforeEach(async () => {
|
||||
accountService = new FakeAccountService({
|
||||
[testUser]: {
|
||||
name: "name",
|
||||
email: "email",
|
||||
status: AuthenticationStatus.Locked,
|
||||
},
|
||||
[alternateTestUser]: {
|
||||
name: "name",
|
||||
email: "email",
|
||||
status: AuthenticationStatus.Locked,
|
||||
},
|
||||
});
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
sut = new DefaultEnvironmentService(stateProvider, accountService);
|
||||
});
|
||||
|
||||
const switchUser = async (userId: UserId) => {
|
||||
accountService.activeAccountSubject.next({
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
name: `Test Name ${userId}`,
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
await awaitAsync();
|
||||
};
|
||||
|
||||
const setGlobalData = (region: Region, environmentUrls: EnvironmentUrls) => {
|
||||
stateProvider.global.getFake(ENVIRONMENT_KEY).stateSubject.next({
|
||||
region: region,
|
||||
urls: environmentUrls,
|
||||
});
|
||||
};
|
||||
|
||||
const setUserData = (
|
||||
region: Region,
|
||||
environmentUrls: EnvironmentUrls,
|
||||
userId: UserId = testUser,
|
||||
) => {
|
||||
stateProvider.singleUser.getFake(userId, ENVIRONMENT_KEY).nextState({
|
||||
region: region,
|
||||
urls: environmentUrls,
|
||||
});
|
||||
};
|
||||
|
||||
const REGION_SETUP = [
|
||||
{
|
||||
region: Region.US,
|
||||
expectedUrls: {
|
||||
webVault: "https://vault.bitwarden.com",
|
||||
identity: "https://identity.bitwarden.com",
|
||||
api: "https://api.bitwarden.com",
|
||||
icons: "https://icons.bitwarden.net",
|
||||
notifications: "https://notifications.bitwarden.com",
|
||||
events: "https://events.bitwarden.com",
|
||||
scim: "https://scim.bitwarden.com/v2",
|
||||
send: "https://send.bitwarden.com/#",
|
||||
},
|
||||
},
|
||||
{
|
||||
region: Region.EU,
|
||||
expectedUrls: {
|
||||
webVault: "https://vault.bitwarden.eu",
|
||||
identity: "https://identity.bitwarden.eu",
|
||||
api: "https://api.bitwarden.eu",
|
||||
icons: "https://icons.bitwarden.eu",
|
||||
notifications: "https://notifications.bitwarden.eu",
|
||||
events: "https://events.bitwarden.eu",
|
||||
scim: "https://scim.bitwarden.eu/v2",
|
||||
send: "https://vault.bitwarden.eu/#/send/",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("with user", () => {
|
||||
it.each(REGION_SETUP)(
|
||||
"sets correct urls for each region %s",
|
||||
async ({ region, expectedUrls }) => {
|
||||
setUserData(region, new EnvironmentUrls());
|
||||
await switchUser(testUser);
|
||||
|
||||
const env = await firstValueFrom(sut.environment$);
|
||||
|
||||
expect(env.hasBaseUrl()).toBe(false);
|
||||
expect(env.getWebVaultUrl()).toBe(expectedUrls.webVault);
|
||||
expect(env.getIdentityUrl()).toBe(expectedUrls.identity);
|
||||
expect(env.getApiUrl()).toBe(expectedUrls.api);
|
||||
expect(env.getIconsUrl()).toBe(expectedUrls.icons);
|
||||
expect(env.getNotificationsUrl()).toBe(expectedUrls.notifications);
|
||||
expect(env.getEventsUrl()).toBe(expectedUrls.events);
|
||||
expect(env.getScimUrl()).toBe(expectedUrls.scim);
|
||||
expect(env.getSendUrl()).toBe(expectedUrls.send);
|
||||
expect(env.getKeyConnectorUrl()).toBe(undefined);
|
||||
expect(env.isCloud()).toBe(true);
|
||||
expect(env.getUrls()).toEqual({
|
||||
base: null,
|
||||
cloudWebVault: undefined,
|
||||
webVault: expectedUrls.webVault,
|
||||
identity: expectedUrls.identity,
|
||||
api: expectedUrls.api,
|
||||
icons: expectedUrls.icons,
|
||||
notifications: expectedUrls.notifications,
|
||||
events: expectedUrls.events,
|
||||
scim: expectedUrls.scim.replace("/v2", ""),
|
||||
keyConnector: undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("returns user data", async () => {
|
||||
const globalEnvironmentUrls = new EnvironmentUrls();
|
||||
globalEnvironmentUrls.base = "https://global-url.example.com";
|
||||
setGlobalData(Region.SelfHosted, globalEnvironmentUrls);
|
||||
|
||||
const userEnvironmentUrls = new EnvironmentUrls();
|
||||
userEnvironmentUrls.base = "https://user-url.example.com";
|
||||
setUserData(Region.SelfHosted, userEnvironmentUrls);
|
||||
|
||||
await switchUser(testUser);
|
||||
|
||||
const env = await firstValueFrom(sut.environment$);
|
||||
|
||||
expect(env.getWebVaultUrl()).toBe("https://user-url.example.com");
|
||||
expect(env.getIdentityUrl()).toBe("https://user-url.example.com/identity");
|
||||
expect(env.getApiUrl()).toBe("https://user-url.example.com/api");
|
||||
expect(env.getIconsUrl()).toBe("https://user-url.example.com/icons");
|
||||
expect(env.getNotificationsUrl()).toBe("https://user-url.example.com/notifications");
|
||||
expect(env.getEventsUrl()).toBe("https://user-url.example.com/events");
|
||||
expect(env.getScimUrl()).toBe("https://user-url.example.com/scim/v2");
|
||||
expect(env.getSendUrl()).toBe("https://user-url.example.com/#/send/");
|
||||
expect(env.isCloud()).toBe(false);
|
||||
expect(env.getUrls()).toEqual({
|
||||
base: "https://user-url.example.com",
|
||||
api: null,
|
||||
cloudWebVault: undefined,
|
||||
events: null,
|
||||
icons: null,
|
||||
identity: null,
|
||||
keyConnector: null,
|
||||
notifications: null,
|
||||
scim: null,
|
||||
webVault: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("without user", () => {
|
||||
it.each(REGION_SETUP)("gets default urls %s", async ({ region, expectedUrls }) => {
|
||||
setGlobalData(region, new EnvironmentUrls());
|
||||
const env = await firstValueFrom(sut.environment$);
|
||||
|
||||
expect(env.hasBaseUrl()).toBe(false);
|
||||
expect(env.getWebVaultUrl()).toBe(expectedUrls.webVault);
|
||||
expect(env.getIdentityUrl()).toBe(expectedUrls.identity);
|
||||
expect(env.getApiUrl()).toBe(expectedUrls.api);
|
||||
expect(env.getIconsUrl()).toBe(expectedUrls.icons);
|
||||
expect(env.getNotificationsUrl()).toBe(expectedUrls.notifications);
|
||||
expect(env.getEventsUrl()).toBe(expectedUrls.events);
|
||||
expect(env.getScimUrl()).toBe(expectedUrls.scim);
|
||||
expect(env.getSendUrl()).toBe(expectedUrls.send);
|
||||
expect(env.getKeyConnectorUrl()).toBe(undefined);
|
||||
expect(env.isCloud()).toBe(true);
|
||||
expect(env.getUrls()).toEqual({
|
||||
base: null,
|
||||
cloudWebVault: undefined,
|
||||
webVault: expectedUrls.webVault,
|
||||
identity: expectedUrls.identity,
|
||||
api: expectedUrls.api,
|
||||
icons: expectedUrls.icons,
|
||||
notifications: expectedUrls.notifications,
|
||||
events: expectedUrls.events,
|
||||
scim: expectedUrls.scim.replace("/v2", ""),
|
||||
keyConnector: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("gets global data", async () => {
|
||||
const globalEnvironmentUrls = new EnvironmentUrls();
|
||||
globalEnvironmentUrls.base = "https://global-url.example.com";
|
||||
globalEnvironmentUrls.keyConnector = "https://global-key-connector.example.com";
|
||||
setGlobalData(Region.SelfHosted, globalEnvironmentUrls);
|
||||
|
||||
const userEnvironmentUrls = new EnvironmentUrls();
|
||||
userEnvironmentUrls.base = "https://user-url.example.com";
|
||||
userEnvironmentUrls.keyConnector = "https://user-key-connector.example.com";
|
||||
setUserData(Region.SelfHosted, userEnvironmentUrls);
|
||||
|
||||
const env = await firstValueFrom(sut.environment$);
|
||||
|
||||
expect(env.getWebVaultUrl()).toBe("https://global-url.example.com");
|
||||
expect(env.getIdentityUrl()).toBe("https://global-url.example.com/identity");
|
||||
expect(env.getApiUrl()).toBe("https://global-url.example.com/api");
|
||||
expect(env.getIconsUrl()).toBe("https://global-url.example.com/icons");
|
||||
expect(env.getNotificationsUrl()).toBe("https://global-url.example.com/notifications");
|
||||
expect(env.getEventsUrl()).toBe("https://global-url.example.com/events");
|
||||
expect(env.getScimUrl()).toBe("https://global-url.example.com/scim/v2");
|
||||
expect(env.getSendUrl()).toBe("https://global-url.example.com/#/send/");
|
||||
expect(env.getKeyConnectorUrl()).toBe("https://global-key-connector.example.com");
|
||||
expect(env.isCloud()).toBe(false);
|
||||
expect(env.getUrls()).toEqual({
|
||||
api: null,
|
||||
base: "https://global-url.example.com",
|
||||
cloudWebVault: undefined,
|
||||
webVault: null,
|
||||
events: null,
|
||||
icons: null,
|
||||
identity: null,
|
||||
keyConnector: "https://global-key-connector.example.com",
|
||||
notifications: null,
|
||||
scim: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setEnvironment", () => {
|
||||
it("self-hosted with base-url", async () => {
|
||||
await sut.setEnvironment(Region.SelfHosted, {
|
||||
base: "base.example.com",
|
||||
});
|
||||
await awaitAsync();
|
||||
|
||||
const env = await firstValueFrom(sut.environment$);
|
||||
|
||||
expect(env.getRegion()).toBe(Region.SelfHosted);
|
||||
expect(env.getUrls()).toEqual({
|
||||
base: "https://base.example.com",
|
||||
api: null,
|
||||
identity: null,
|
||||
webVault: null,
|
||||
icons: null,
|
||||
notifications: null,
|
||||
scim: null,
|
||||
events: null,
|
||||
keyConnector: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("self-hosted and sets all urls", async () => {
|
||||
let env = await firstValueFrom(sut.environment$);
|
||||
expect(env.getScimUrl()).toBe("https://scim.bitwarden.com/v2");
|
||||
|
||||
await sut.setEnvironment(Region.SelfHosted, {
|
||||
base: "base.example.com",
|
||||
api: "api.example.com",
|
||||
identity: "identity.example.com",
|
||||
webVault: "vault.example.com",
|
||||
icons: "icons.example.com",
|
||||
notifications: "notifications.example.com",
|
||||
scim: "scim.example.com",
|
||||
});
|
||||
|
||||
env = await firstValueFrom(sut.environment$);
|
||||
|
||||
expect(env.getRegion()).toBe(Region.SelfHosted);
|
||||
expect(env.getUrls()).toEqual({
|
||||
base: "https://base.example.com",
|
||||
api: "https://api.example.com",
|
||||
identity: "https://identity.example.com",
|
||||
webVault: "https://vault.example.com",
|
||||
icons: "https://icons.example.com",
|
||||
notifications: "https://notifications.example.com",
|
||||
scim: null,
|
||||
events: null,
|
||||
keyConnector: null,
|
||||
});
|
||||
expect(env.getScimUrl()).toBe("https://vault.example.com/scim/v2");
|
||||
});
|
||||
|
||||
it("sets the region", async () => {
|
||||
await sut.setEnvironment(Region.US);
|
||||
|
||||
const data = await firstValueFrom(sut.environment$);
|
||||
|
||||
expect(data.getRegion()).toBe(Region.US);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironment", () => {
|
||||
it.each([
|
||||
{ region: Region.US, expectedHost: "bitwarden.com" },
|
||||
{ region: Region.EU, expectedHost: "bitwarden.eu" },
|
||||
])("gets it from user data if there is an active user", async ({ region, expectedHost }) => {
|
||||
setGlobalData(Region.US, new EnvironmentUrls());
|
||||
setUserData(region, new EnvironmentUrls());
|
||||
|
||||
await switchUser(testUser);
|
||||
|
||||
const env = await sut.getEnvironment();
|
||||
expect(env.getHostname()).toBe(expectedHost);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ region: Region.US, expectedHost: "bitwarden.com" },
|
||||
{ region: Region.EU, expectedHost: "bitwarden.eu" },
|
||||
])("gets it from global data if there is no active user", async ({ region, expectedHost }) => {
|
||||
setGlobalData(region, new EnvironmentUrls());
|
||||
setUserData(Region.US, new EnvironmentUrls());
|
||||
|
||||
const env = await sut.getEnvironment();
|
||||
expect(env.getHostname()).toBe(expectedHost);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ region: Region.US, expectedHost: "bitwarden.com" },
|
||||
{ region: Region.EU, expectedHost: "bitwarden.eu" },
|
||||
])(
|
||||
"gets it from global state if there is no active user even if a user id is passed in.",
|
||||
async ({ region, expectedHost }) => {
|
||||
setGlobalData(region, new EnvironmentUrls());
|
||||
setUserData(Region.US, new EnvironmentUrls());
|
||||
|
||||
const env = await sut.getEnvironment(testUser);
|
||||
expect(env.getHostname()).toBe(expectedHost);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ region: Region.US, expectedHost: "bitwarden.com" },
|
||||
{ region: Region.EU, expectedHost: "bitwarden.eu" },
|
||||
])(
|
||||
"gets it from the passed in userId if there is any active user: %s",
|
||||
async ({ region, expectedHost }) => {
|
||||
setGlobalData(Region.US, new EnvironmentUrls());
|
||||
setUserData(Region.US, new EnvironmentUrls());
|
||||
setUserData(region, new EnvironmentUrls(), alternateTestUser);
|
||||
|
||||
await switchUser(testUser);
|
||||
|
||||
const env = await sut.getEnvironment(alternateTestUser);
|
||||
expect(env.getHostname()).toBe(expectedHost);
|
||||
},
|
||||
);
|
||||
|
||||
it("gets it from base url saved in self host config", async () => {
|
||||
const globalSelfHostUrls = new EnvironmentUrls();
|
||||
globalSelfHostUrls.base = "https://base.example.com";
|
||||
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
|
||||
setUserData(Region.EU, new EnvironmentUrls());
|
||||
|
||||
const env = await sut.getEnvironment();
|
||||
expect(env.getHostname()).toBe("base.example.com");
|
||||
});
|
||||
|
||||
it("gets it from webVault url saved in self host config", async () => {
|
||||
const globalSelfHostUrls = new EnvironmentUrls();
|
||||
globalSelfHostUrls.webVault = "https://vault.example.com";
|
||||
globalSelfHostUrls.base = "https://base.example.com";
|
||||
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
|
||||
setUserData(Region.EU, new EnvironmentUrls());
|
||||
|
||||
const env = await sut.getEnvironment();
|
||||
expect(env.getHostname()).toBe("vault.example.com");
|
||||
});
|
||||
|
||||
it("gets it from saved self host config from passed in user when there is an active user", async () => {
|
||||
setGlobalData(Region.US, new EnvironmentUrls());
|
||||
setUserData(Region.EU, new EnvironmentUrls());
|
||||
|
||||
const selfHostUserUrls = new EnvironmentUrls();
|
||||
selfHostUserUrls.base = "https://base.example.com";
|
||||
setUserData(Region.SelfHosted, selfHostUserUrls, alternateTestUser);
|
||||
|
||||
await switchUser(testUser);
|
||||
|
||||
const env = await sut.getEnvironment(alternateTestUser);
|
||||
expect(env.getHostname()).toBe("base.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudWebVaultUrl$", () => {
|
||||
it("no extra initialization, returns US vault", async () => {
|
||||
expect(await firstValueFrom(sut.cloudWebVaultUrl$)).toBe("https://vault.bitwarden.com");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ region: Region.US, expectedVault: "https://vault.bitwarden.com" },
|
||||
{ region: Region.EU, expectedVault: "https://vault.bitwarden.eu" },
|
||||
{ region: Region.SelfHosted, expectedVault: "https://vault.bitwarden.com" },
|
||||
])(
|
||||
"no extra initialization, returns expected host for each region %s",
|
||||
async ({ region, expectedVault }) => {
|
||||
await switchUser(testUser);
|
||||
|
||||
expect(await sut.setCloudRegion(testUser, region as CloudRegion));
|
||||
expect(await firstValueFrom(sut.cloudWebVaultUrl$)).toBe(expectedVault);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
433
libs/common/src/platform/services/default-environment.service.ts
Normal file
433
libs/common/src/platform/services/default-environment.service.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { distinctUntilChanged, firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Environment,
|
||||
Region,
|
||||
RegionConfig,
|
||||
Urls,
|
||||
CloudRegion,
|
||||
} from "../abstractions/environment.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
import {
|
||||
ENVIRONMENT_DISK,
|
||||
ENVIRONMENT_MEMORY,
|
||||
GlobalState,
|
||||
KeyDefinition,
|
||||
StateProvider,
|
||||
} from "../state";
|
||||
|
||||
export class EnvironmentUrls {
|
||||
base: string = null;
|
||||
api: string = null;
|
||||
identity: string = null;
|
||||
icons: string = null;
|
||||
notifications: string = null;
|
||||
events: string = null;
|
||||
webVault: string = null;
|
||||
keyConnector: string = null;
|
||||
}
|
||||
|
||||
class EnvironmentState {
|
||||
region: Region;
|
||||
urls: EnvironmentUrls;
|
||||
|
||||
static fromJSON(obj: Jsonify<EnvironmentState>): EnvironmentState {
|
||||
return Object.assign(new EnvironmentState(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
export const ENVIRONMENT_KEY = new KeyDefinition<EnvironmentState>(
|
||||
ENVIRONMENT_DISK,
|
||||
"environment",
|
||||
{
|
||||
deserializer: EnvironmentState.fromJSON,
|
||||
},
|
||||
);
|
||||
|
||||
export const CLOUD_REGION_KEY = new KeyDefinition<CloudRegion>(ENVIRONMENT_MEMORY, "cloudRegion", {
|
||||
deserializer: (b) => b,
|
||||
});
|
||||
|
||||
/**
|
||||
* The production regions available for selection.
|
||||
*
|
||||
* In the future we desire to load these urls from the config endpoint.
|
||||
*/
|
||||
export const PRODUCTION_REGIONS: RegionConfig[] = [
|
||||
{
|
||||
key: Region.US,
|
||||
domain: "bitwarden.com",
|
||||
urls: {
|
||||
base: null,
|
||||
api: "https://api.bitwarden.com",
|
||||
identity: "https://identity.bitwarden.com",
|
||||
icons: "https://icons.bitwarden.net",
|
||||
webVault: "https://vault.bitwarden.com",
|
||||
notifications: "https://notifications.bitwarden.com",
|
||||
events: "https://events.bitwarden.com",
|
||||
scim: "https://scim.bitwarden.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: Region.EU,
|
||||
domain: "bitwarden.eu",
|
||||
urls: {
|
||||
base: null,
|
||||
api: "https://api.bitwarden.eu",
|
||||
identity: "https://identity.bitwarden.eu",
|
||||
icons: "https://icons.bitwarden.eu",
|
||||
webVault: "https://vault.bitwarden.eu",
|
||||
notifications: "https://notifications.bitwarden.eu",
|
||||
events: "https://events.bitwarden.eu",
|
||||
scim: "https://scim.bitwarden.eu",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The default region when starting the app.
|
||||
*/
|
||||
const DEFAULT_REGION = Region.US;
|
||||
|
||||
/**
|
||||
* The default region configuration.
|
||||
*/
|
||||
const DEFAULT_REGION_CONFIG = PRODUCTION_REGIONS.find((r) => r.key === DEFAULT_REGION);
|
||||
|
||||
export class DefaultEnvironmentService implements EnvironmentService {
|
||||
private globalState: GlobalState<EnvironmentState | null>;
|
||||
private globalCloudRegionState: GlobalState<CloudRegion | null>;
|
||||
|
||||
// We intentionally don't want the helper on account service, we want the null back if there is no active user
|
||||
private activeAccountId$: Observable<UserId | null> = this.accountService.activeAccount$.pipe(
|
||||
map((a) => a?.id),
|
||||
);
|
||||
|
||||
environment$: Observable<Environment>;
|
||||
cloudWebVaultUrl$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private accountService: AccountService,
|
||||
) {
|
||||
this.globalState = this.stateProvider.getGlobal(ENVIRONMENT_KEY);
|
||||
this.globalCloudRegionState = this.stateProvider.getGlobal(CLOUD_REGION_KEY);
|
||||
|
||||
const account$ = this.activeAccountId$.pipe(
|
||||
// Use == here to not trigger on undefined -> null transition
|
||||
distinctUntilChanged((oldUserId: UserId, newUserId: UserId) => oldUserId == newUserId),
|
||||
);
|
||||
|
||||
this.environment$ = account$.pipe(
|
||||
switchMap((userId) => {
|
||||
const t = userId
|
||||
? this.stateProvider.getUser(userId, ENVIRONMENT_KEY).state$
|
||||
: this.stateProvider.getGlobal(ENVIRONMENT_KEY).state$;
|
||||
return t;
|
||||
}),
|
||||
map((state) => {
|
||||
return this.buildEnvironment(state?.region, state?.urls);
|
||||
}),
|
||||
);
|
||||
this.cloudWebVaultUrl$ = account$.pipe(
|
||||
switchMap((userId) => {
|
||||
const t = userId
|
||||
? this.stateProvider.getUser(userId, CLOUD_REGION_KEY).state$
|
||||
: this.stateProvider.getGlobal(CLOUD_REGION_KEY).state$;
|
||||
return t;
|
||||
}),
|
||||
map((region) => {
|
||||
if (region != null) {
|
||||
const config = this.getRegionConfig(region);
|
||||
|
||||
if (config != null) {
|
||||
return config.urls.webVault;
|
||||
}
|
||||
}
|
||||
return DEFAULT_REGION_CONFIG.urls.webVault;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
availableRegions(): RegionConfig[] {
|
||||
const additionalRegions = (process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[]) ?? [];
|
||||
return PRODUCTION_REGIONS.concat(additionalRegions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the region configuration for the given region.
|
||||
*/
|
||||
private getRegionConfig(region: Region): RegionConfig | undefined {
|
||||
return this.availableRegions().find((r) => r.key === region);
|
||||
}
|
||||
|
||||
async setEnvironment(region: Region, urls?: Urls): Promise<Urls> {
|
||||
// Unknown regions are treated as self-hosted
|
||||
if (this.getRegionConfig(region) == null) {
|
||||
region = Region.SelfHosted;
|
||||
}
|
||||
|
||||
// If self-hosted ensure urls are valid else fallback to default region
|
||||
if (region == Region.SelfHosted && isEmpty(urls)) {
|
||||
region = DEFAULT_REGION;
|
||||
}
|
||||
|
||||
if (region != Region.SelfHosted) {
|
||||
await this.globalState.update(() => ({
|
||||
region: region,
|
||||
urls: null,
|
||||
}));
|
||||
|
||||
return null;
|
||||
} else {
|
||||
// Clean the urls
|
||||
urls.base = formatUrl(urls.base);
|
||||
urls.webVault = formatUrl(urls.webVault);
|
||||
urls.api = formatUrl(urls.api);
|
||||
urls.identity = formatUrl(urls.identity);
|
||||
urls.icons = formatUrl(urls.icons);
|
||||
urls.notifications = formatUrl(urls.notifications);
|
||||
urls.events = formatUrl(urls.events);
|
||||
urls.keyConnector = formatUrl(urls.keyConnector);
|
||||
urls.scim = null;
|
||||
|
||||
await this.globalState.update(() => ({
|
||||
region: region,
|
||||
urls: {
|
||||
base: urls.base,
|
||||
api: urls.api,
|
||||
identity: urls.identity,
|
||||
webVault: urls.webVault,
|
||||
icons: urls.icons,
|
||||
notifications: urls.notifications,
|
||||
events: urls.events,
|
||||
keyConnector: urls.keyConnector,
|
||||
},
|
||||
}));
|
||||
|
||||
return urls;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for building the environment from state. Performs some general sanitization to avoid invalid regions and urls.
|
||||
*/
|
||||
protected buildEnvironment(region: Region, urls: Urls) {
|
||||
// Unknown regions are treated as self-hosted
|
||||
if (this.getRegionConfig(region) == null) {
|
||||
region = Region.SelfHosted;
|
||||
}
|
||||
|
||||
// If self-hosted ensure urls are valid else fallback to default region
|
||||
if (region == Region.SelfHosted && isEmpty(urls)) {
|
||||
region = DEFAULT_REGION;
|
||||
}
|
||||
|
||||
// Load urls from region config
|
||||
if (region != Region.SelfHosted) {
|
||||
const regionConfig = this.getRegionConfig(region);
|
||||
if (regionConfig != null) {
|
||||
return new CloudEnvironment(regionConfig);
|
||||
}
|
||||
}
|
||||
|
||||
return new SelfHostedEnvironment(urls);
|
||||
}
|
||||
|
||||
async setCloudRegion(userId: UserId, region: CloudRegion) {
|
||||
if (userId == null) {
|
||||
await this.globalCloudRegionState.update(() => region);
|
||||
} else {
|
||||
await this.stateProvider.getUser(userId, CLOUD_REGION_KEY).update(() => region);
|
||||
}
|
||||
}
|
||||
|
||||
async getEnvironment(userId?: UserId) {
|
||||
if (userId == null) {
|
||||
return await firstValueFrom(this.environment$);
|
||||
}
|
||||
|
||||
const state = await this.getEnvironmentState(userId);
|
||||
return this.buildEnvironment(state.region, state.urls);
|
||||
}
|
||||
|
||||
private async getEnvironmentState(userId: UserId | null) {
|
||||
// Previous rules dictated that we only get from user scoped state if there is an active user.
|
||||
const activeUserId = await firstValueFrom(this.activeAccountId$);
|
||||
return activeUserId == null
|
||||
? await firstValueFrom(this.globalState.state$)
|
||||
: await firstValueFrom(
|
||||
this.stateProvider.getUser(userId ?? activeUserId, ENVIRONMENT_KEY).state$,
|
||||
);
|
||||
}
|
||||
|
||||
async seedUserEnvironment(userId: UserId) {
|
||||
const global = await firstValueFrom(this.globalState.state$);
|
||||
await this.stateProvider.getUser(userId, ENVIRONMENT_KEY).update(() => global);
|
||||
}
|
||||
}
|
||||
|
||||
function formatUrl(url: string): string {
|
||||
if (url == null || url === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
url = url.replace(/\/+$/g, "");
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
|
||||
return url.trim();
|
||||
}
|
||||
|
||||
function isEmpty(u?: Urls): boolean {
|
||||
if (u == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
u.base == null &&
|
||||
u.webVault == null &&
|
||||
u.api == null &&
|
||||
u.identity == null &&
|
||||
u.icons == null &&
|
||||
u.notifications == null &&
|
||||
u.events == null
|
||||
);
|
||||
}
|
||||
|
||||
abstract class UrlEnvironment implements Environment {
|
||||
constructor(
|
||||
protected region: Region,
|
||||
protected urls: Urls,
|
||||
) {
|
||||
// Scim is always null for self-hosted
|
||||
if (region == Region.SelfHosted) {
|
||||
this.urls.scim = null;
|
||||
}
|
||||
}
|
||||
|
||||
abstract getHostname(): string;
|
||||
|
||||
getRegion() {
|
||||
return this.region;
|
||||
}
|
||||
|
||||
getUrls() {
|
||||
return {
|
||||
base: this.urls.base,
|
||||
webVault: this.urls.webVault,
|
||||
api: this.urls.api,
|
||||
identity: this.urls.identity,
|
||||
icons: this.urls.icons,
|
||||
notifications: this.urls.notifications,
|
||||
events: this.urls.events,
|
||||
keyConnector: this.urls.keyConnector,
|
||||
scim: this.urls.scim,
|
||||
};
|
||||
}
|
||||
|
||||
hasBaseUrl() {
|
||||
return this.urls.base != null;
|
||||
}
|
||||
|
||||
getWebVaultUrl() {
|
||||
return this.getUrl("webVault", "");
|
||||
}
|
||||
|
||||
getApiUrl() {
|
||||
return this.getUrl("api", "/api");
|
||||
}
|
||||
|
||||
getEventsUrl() {
|
||||
return this.getUrl("events", "/events");
|
||||
}
|
||||
|
||||
getIconsUrl() {
|
||||
return this.getUrl("icons", "/icons");
|
||||
}
|
||||
|
||||
getIdentityUrl() {
|
||||
return this.getUrl("identity", "/identity");
|
||||
}
|
||||
|
||||
getKeyConnectorUrl() {
|
||||
return this.urls.keyConnector;
|
||||
}
|
||||
|
||||
getNotificationsUrl() {
|
||||
return this.getUrl("notifications", "/notifications");
|
||||
}
|
||||
|
||||
getScimUrl() {
|
||||
if (this.urls.scim != null) {
|
||||
return this.urls.scim + "/v2";
|
||||
}
|
||||
|
||||
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
|
||||
? "https://scim.bitwarden.com/v2"
|
||||
: this.getWebVaultUrl() + "/scim/v2";
|
||||
}
|
||||
|
||||
getSendUrl() {
|
||||
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
|
||||
? "https://send.bitwarden.com/#"
|
||||
: this.getWebVaultUrl() + "/#/send/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Presume that if the region is not self-hosted, it is cloud.
|
||||
*/
|
||||
isCloud(): boolean {
|
||||
return this.region !== Region.SelfHosted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for getting an URL.
|
||||
*
|
||||
* @param key Key of the URL to get from URLs
|
||||
* @param baseSuffix Suffix to append to the base URL if the url is not set
|
||||
* @returns
|
||||
*/
|
||||
private getUrl(key: keyof Urls, baseSuffix: string) {
|
||||
if (this.urls[key] != null) {
|
||||
return this.urls[key];
|
||||
}
|
||||
|
||||
if (this.urls.base) {
|
||||
return this.urls.base + baseSuffix;
|
||||
}
|
||||
|
||||
return DEFAULT_REGION_CONFIG.urls[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Denote a cloud environment.
|
||||
*/
|
||||
export class CloudEnvironment extends UrlEnvironment {
|
||||
constructor(private config: RegionConfig) {
|
||||
super(config.key, config.urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud always returns nice urls, i.e. bitwarden.com instead of vault.bitwarden.com.
|
||||
*/
|
||||
getHostname() {
|
||||
return this.config.domain;
|
||||
}
|
||||
}
|
||||
|
||||
export class SelfHostedEnvironment extends UrlEnvironment {
|
||||
constructor(urls: Urls) {
|
||||
super(Region.SelfHosted, urls);
|
||||
}
|
||||
|
||||
getHostname() {
|
||||
return Utils.getHost(this.getWebVaultUrl());
|
||||
}
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, timeout } from "rxjs";
|
||||
|
||||
import { awaitAsync } from "../../../spec";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeStorageService } from "../../../spec/fake-storage.service";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { Region } from "../abstractions/environment.service";
|
||||
import { StateProvider } from "../state";
|
||||
/* eslint-disable import/no-restricted-paths -- Rare testing need */
|
||||
import { DefaultActiveUserStateProvider } from "../state/implementations/default-active-user-state.provider";
|
||||
import { DefaultDerivedStateProvider } from "../state/implementations/default-derived-state.provider";
|
||||
import { DefaultGlobalStateProvider } from "../state/implementations/default-global-state.provider";
|
||||
import { DefaultSingleUserStateProvider } from "../state/implementations/default-single-user-state.provider";
|
||||
import { DefaultStateProvider } from "../state/implementations/default-state.provider";
|
||||
import { StateEventRegistrarService } from "../state/state-event-registrar.service";
|
||||
/* eslint-enable import/no-restricted-paths */
|
||||
|
||||
import { EnvironmentService } from "./environment.service";
|
||||
import { StorageServiceProvider } from "./storage-service.provider";
|
||||
|
||||
// There are a few main states EnvironmentService could be in when first used
|
||||
// 1. Not initialized, no active user. Hopefully not to likely but possible
|
||||
// 2. Not initialized, with active user. Not likely
|
||||
// 3. Initialized, no active user.
|
||||
// 4. Initialized, with active user.
|
||||
describe("EnvironmentService", () => {
|
||||
let diskStorageService: FakeStorageService;
|
||||
let memoryStorageService: FakeStorageService;
|
||||
let storageServiceProvider: StorageServiceProvider;
|
||||
const stateEventRegistrarService = mock<StateEventRegistrarService>();
|
||||
let accountService: FakeAccountService;
|
||||
let stateProvider: StateProvider;
|
||||
|
||||
let sut: EnvironmentService;
|
||||
|
||||
const testUser = "00000000-0000-1000-a000-000000000001" as UserId;
|
||||
const alternateTestUser = "00000000-0000-1000-a000-000000000002" as UserId;
|
||||
|
||||
beforeEach(async () => {
|
||||
diskStorageService = new FakeStorageService();
|
||||
memoryStorageService = new FakeStorageService();
|
||||
storageServiceProvider = new StorageServiceProvider(diskStorageService, memoryStorageService);
|
||||
|
||||
accountService = mockAccountServiceWith(undefined);
|
||||
const singleUserStateProvider = new DefaultSingleUserStateProvider(
|
||||
storageServiceProvider,
|
||||
stateEventRegistrarService,
|
||||
);
|
||||
stateProvider = new DefaultStateProvider(
|
||||
new DefaultActiveUserStateProvider(accountService, singleUserStateProvider),
|
||||
singleUserStateProvider,
|
||||
new DefaultGlobalStateProvider(storageServiceProvider),
|
||||
new DefaultDerivedStateProvider(memoryStorageService),
|
||||
);
|
||||
|
||||
sut = new EnvironmentService(stateProvider, accountService);
|
||||
});
|
||||
|
||||
const switchUser = async (userId: UserId) => {
|
||||
accountService.activeAccountSubject.next({
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
name: `Test Name ${userId}`,
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
await awaitAsync();
|
||||
};
|
||||
|
||||
const setGlobalData = (region: Region, environmentUrls: EnvironmentUrls) => {
|
||||
const data = diskStorageService.internalStore;
|
||||
data["global_environment_region"] = region;
|
||||
data["global_environment_urls"] = environmentUrls;
|
||||
diskStorageService.internalUpdateStore(data);
|
||||
};
|
||||
|
||||
const getGlobalData = () => {
|
||||
const storage = diskStorageService.internalStore;
|
||||
return {
|
||||
region: storage?.["global_environment_region"],
|
||||
urls: storage?.["global_environment_urls"],
|
||||
};
|
||||
};
|
||||
|
||||
const setUserData = (
|
||||
region: Region,
|
||||
environmentUrls: EnvironmentUrls,
|
||||
userId: UserId = testUser,
|
||||
) => {
|
||||
const data = diskStorageService.internalStore;
|
||||
data[`user_${userId}_environment_region`] = region;
|
||||
data[`user_${userId}_environment_urls`] = environmentUrls;
|
||||
|
||||
diskStorageService.internalUpdateStore(data);
|
||||
};
|
||||
// END: CAN CHANGE
|
||||
|
||||
const initialize = async (options: { switchUser: boolean }) => {
|
||||
await sut.setUrlsFromStorage();
|
||||
sut.initialized = true;
|
||||
|
||||
if (options.switchUser) {
|
||||
await switchUser(testUser);
|
||||
}
|
||||
};
|
||||
|
||||
const REGION_SETUP = [
|
||||
{
|
||||
region: Region.US,
|
||||
expectedUrls: {
|
||||
webVault: "https://vault.bitwarden.com",
|
||||
identity: "https://identity.bitwarden.com",
|
||||
api: "https://api.bitwarden.com",
|
||||
icons: "https://icons.bitwarden.net",
|
||||
notifications: "https://notifications.bitwarden.com",
|
||||
events: "https://events.bitwarden.com",
|
||||
scim: "https://scim.bitwarden.com/v2",
|
||||
send: "https://send.bitwarden.com/#",
|
||||
},
|
||||
},
|
||||
{
|
||||
region: Region.EU,
|
||||
expectedUrls: {
|
||||
webVault: "https://vault.bitwarden.eu",
|
||||
identity: "https://identity.bitwarden.eu",
|
||||
api: "https://api.bitwarden.eu",
|
||||
icons: "https://icons.bitwarden.eu",
|
||||
notifications: "https://notifications.bitwarden.eu",
|
||||
events: "https://events.bitwarden.eu",
|
||||
scim: "https://scim.bitwarden.eu/v2",
|
||||
send: "https://vault.bitwarden.eu/#/send/",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("with user", () => {
|
||||
it.each(REGION_SETUP)(
|
||||
"sets correct urls for each region %s",
|
||||
async ({ region, expectedUrls }) => {
|
||||
setUserData(region, new EnvironmentUrls());
|
||||
|
||||
await initialize({ switchUser: true });
|
||||
|
||||
expect(sut.hasBaseUrl()).toBe(false);
|
||||
expect(sut.getWebVaultUrl()).toBe(expectedUrls.webVault);
|
||||
expect(sut.getIdentityUrl()).toBe(expectedUrls.identity);
|
||||
expect(sut.getApiUrl()).toBe(expectedUrls.api);
|
||||
expect(sut.getIconsUrl()).toBe(expectedUrls.icons);
|
||||
expect(sut.getNotificationsUrl()).toBe(expectedUrls.notifications);
|
||||
expect(sut.getEventsUrl()).toBe(expectedUrls.events);
|
||||
expect(sut.getScimUrl()).toBe(expectedUrls.scim);
|
||||
expect(sut.getSendUrl()).toBe(expectedUrls.send);
|
||||
expect(sut.getKeyConnectorUrl()).toBe(null);
|
||||
expect(sut.isCloud()).toBe(true);
|
||||
expect(sut.getUrls()).toEqual({
|
||||
base: null,
|
||||
cloudWebVault: undefined,
|
||||
webVault: expectedUrls.webVault,
|
||||
identity: expectedUrls.identity,
|
||||
api: expectedUrls.api,
|
||||
icons: expectedUrls.icons,
|
||||
notifications: expectedUrls.notifications,
|
||||
events: expectedUrls.events,
|
||||
scim: expectedUrls.scim.replace("/v2", ""),
|
||||
keyConnector: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("returns user data", async () => {
|
||||
const globalEnvironmentUrls = new EnvironmentUrls();
|
||||
globalEnvironmentUrls.base = "https://global-url.example.com";
|
||||
setGlobalData(Region.SelfHosted, globalEnvironmentUrls);
|
||||
|
||||
const userEnvironmentUrls = new EnvironmentUrls();
|
||||
userEnvironmentUrls.base = "https://user-url.example.com";
|
||||
setUserData(Region.SelfHosted, userEnvironmentUrls);
|
||||
|
||||
await initialize({ switchUser: true });
|
||||
|
||||
expect(sut.getWebVaultUrl()).toBe("https://user-url.example.com");
|
||||
expect(sut.getIdentityUrl()).toBe("https://user-url.example.com/identity");
|
||||
expect(sut.getApiUrl()).toBe("https://user-url.example.com/api");
|
||||
expect(sut.getIconsUrl()).toBe("https://user-url.example.com/icons");
|
||||
expect(sut.getNotificationsUrl()).toBe("https://user-url.example.com/notifications");
|
||||
expect(sut.getEventsUrl()).toBe("https://user-url.example.com/events");
|
||||
expect(sut.getScimUrl()).toBe("https://user-url.example.com/scim/v2");
|
||||
expect(sut.getSendUrl()).toBe("https://user-url.example.com/#/send/");
|
||||
expect(sut.isCloud()).toBe(false);
|
||||
expect(sut.getUrls()).toEqual({
|
||||
base: "https://user-url.example.com",
|
||||
api: null,
|
||||
cloudWebVault: undefined,
|
||||
events: null,
|
||||
icons: null,
|
||||
identity: null,
|
||||
keyConnector: null,
|
||||
notifications: null,
|
||||
scim: null,
|
||||
webVault: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("without user", () => {
|
||||
it.each(REGION_SETUP)("gets default urls %s", async ({ region, expectedUrls }) => {
|
||||
setGlobalData(region, new EnvironmentUrls());
|
||||
|
||||
await initialize({ switchUser: false });
|
||||
|
||||
expect(sut.hasBaseUrl()).toBe(false);
|
||||
expect(sut.getWebVaultUrl()).toBe(expectedUrls.webVault);
|
||||
expect(sut.getIdentityUrl()).toBe(expectedUrls.identity);
|
||||
expect(sut.getApiUrl()).toBe(expectedUrls.api);
|
||||
expect(sut.getIconsUrl()).toBe(expectedUrls.icons);
|
||||
expect(sut.getNotificationsUrl()).toBe(expectedUrls.notifications);
|
||||
expect(sut.getEventsUrl()).toBe(expectedUrls.events);
|
||||
expect(sut.getScimUrl()).toBe(expectedUrls.scim);
|
||||
expect(sut.getSendUrl()).toBe(expectedUrls.send);
|
||||
expect(sut.getKeyConnectorUrl()).toBe(null);
|
||||
expect(sut.isCloud()).toBe(true);
|
||||
expect(sut.getUrls()).toEqual({
|
||||
base: null,
|
||||
cloudWebVault: undefined,
|
||||
webVault: expectedUrls.webVault,
|
||||
identity: expectedUrls.identity,
|
||||
api: expectedUrls.api,
|
||||
icons: expectedUrls.icons,
|
||||
notifications: expectedUrls.notifications,
|
||||
events: expectedUrls.events,
|
||||
scim: expectedUrls.scim.replace("/v2", ""),
|
||||
keyConnector: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("gets global data", async () => {
|
||||
const globalEnvironmentUrls = new EnvironmentUrls();
|
||||
globalEnvironmentUrls.base = "https://global-url.example.com";
|
||||
globalEnvironmentUrls.keyConnector = "https://global-key-connector.example.com";
|
||||
setGlobalData(Region.SelfHosted, globalEnvironmentUrls);
|
||||
|
||||
const userEnvironmentUrls = new EnvironmentUrls();
|
||||
userEnvironmentUrls.base = "https://user-url.example.com";
|
||||
userEnvironmentUrls.keyConnector = "https://user-key-connector.example.com";
|
||||
setUserData(Region.SelfHosted, userEnvironmentUrls);
|
||||
|
||||
await initialize({ switchUser: false });
|
||||
|
||||
expect(sut.getWebVaultUrl()).toBe("https://global-url.example.com");
|
||||
expect(sut.getIdentityUrl()).toBe("https://global-url.example.com/identity");
|
||||
expect(sut.getApiUrl()).toBe("https://global-url.example.com/api");
|
||||
expect(sut.getIconsUrl()).toBe("https://global-url.example.com/icons");
|
||||
expect(sut.getNotificationsUrl()).toBe("https://global-url.example.com/notifications");
|
||||
expect(sut.getEventsUrl()).toBe("https://global-url.example.com/events");
|
||||
expect(sut.getScimUrl()).toBe("https://global-url.example.com/scim/v2");
|
||||
expect(sut.getSendUrl()).toBe("https://global-url.example.com/#/send/");
|
||||
expect(sut.getKeyConnectorUrl()).toBe("https://global-key-connector.example.com");
|
||||
expect(sut.isCloud()).toBe(false);
|
||||
expect(sut.getUrls()).toEqual({
|
||||
api: null,
|
||||
base: "https://global-url.example.com",
|
||||
cloudWebVault: undefined,
|
||||
webVault: null,
|
||||
events: null,
|
||||
icons: null,
|
||||
identity: null,
|
||||
keyConnector: "https://global-key-connector.example.com",
|
||||
notifications: null,
|
||||
scim: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("returns US defaults when not initialized", async () => {
|
||||
setGlobalData(Region.EU, new EnvironmentUrls());
|
||||
setUserData(Region.EU, new EnvironmentUrls());
|
||||
|
||||
expect(sut.initialized).toBe(false);
|
||||
|
||||
expect(sut.hasBaseUrl()).toBe(false);
|
||||
expect(sut.getWebVaultUrl()).toBe("https://vault.bitwarden.com");
|
||||
expect(sut.getIdentityUrl()).toBe("https://identity.bitwarden.com");
|
||||
expect(sut.getApiUrl()).toBe("https://api.bitwarden.com");
|
||||
expect(sut.getIconsUrl()).toBe("https://icons.bitwarden.net");
|
||||
expect(sut.getNotificationsUrl()).toBe("https://notifications.bitwarden.com");
|
||||
expect(sut.getEventsUrl()).toBe("https://events.bitwarden.com");
|
||||
expect(sut.getScimUrl()).toBe("https://scim.bitwarden.com/v2");
|
||||
expect(sut.getKeyConnectorUrl()).toBe(undefined);
|
||||
expect(sut.isCloud()).toBe(true);
|
||||
});
|
||||
|
||||
describe("setUrls", () => {
|
||||
it("set just a base url", async () => {
|
||||
await initialize({ switchUser: true });
|
||||
|
||||
await sut.setUrls({
|
||||
base: "base.example.com",
|
||||
});
|
||||
|
||||
const globalData = getGlobalData();
|
||||
expect(globalData.region).toBe(Region.SelfHosted);
|
||||
expect(globalData.urls).toEqual({
|
||||
base: "https://base.example.com",
|
||||
api: null,
|
||||
identity: null,
|
||||
webVault: null,
|
||||
icons: null,
|
||||
notifications: null,
|
||||
events: null,
|
||||
keyConnector: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets all urls", async () => {
|
||||
await initialize({ switchUser: true });
|
||||
|
||||
expect(sut.getScimUrl()).toBe("https://scim.bitwarden.com/v2");
|
||||
|
||||
await sut.setUrls({
|
||||
base: "base.example.com",
|
||||
api: "api.example.com",
|
||||
identity: "identity.example.com",
|
||||
webVault: "vault.example.com",
|
||||
icons: "icons.example.com",
|
||||
notifications: "notifications.example.com",
|
||||
scim: "scim.example.com",
|
||||
});
|
||||
|
||||
const globalData = getGlobalData();
|
||||
expect(globalData.region).toBe(Region.SelfHosted);
|
||||
expect(globalData.urls).toEqual({
|
||||
base: "https://base.example.com",
|
||||
api: "https://api.example.com",
|
||||
identity: "https://identity.example.com",
|
||||
webVault: "https://vault.example.com",
|
||||
icons: "https://icons.example.com",
|
||||
notifications: "https://notifications.example.com",
|
||||
events: null,
|
||||
keyConnector: null,
|
||||
});
|
||||
expect(sut.getScimUrl()).toBe("https://scim.example.com/v2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRegion", () => {
|
||||
it("sets the region on the global object even if there is a user.", async () => {
|
||||
setGlobalData(Region.EU, new EnvironmentUrls());
|
||||
setUserData(Region.EU, new EnvironmentUrls());
|
||||
|
||||
await initialize({ switchUser: true });
|
||||
|
||||
await sut.setRegion(Region.US);
|
||||
|
||||
const globalData = getGlobalData();
|
||||
expect(globalData.region).toBe(Region.US);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHost", () => {
|
||||
it.each([
|
||||
{ region: Region.US, expectedHost: "bitwarden.com" },
|
||||
{ region: Region.EU, expectedHost: "bitwarden.eu" },
|
||||
])("gets it from user data if there is an active user", async ({ region, expectedHost }) => {
|
||||
setGlobalData(Region.US, new EnvironmentUrls());
|
||||
setUserData(region, new EnvironmentUrls());
|
||||
|
||||
await initialize({ switchUser: true });
|
||||
|
||||
const host = await sut.getHost();
|
||||
expect(host).toBe(expectedHost);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ region: Region.US, expectedHost: "bitwarden.com" },
|
||||
{ region: Region.EU, expectedHost: "bitwarden.eu" },
|
||||
])("gets it from global data if there is no active user", async ({ region, expectedHost }) => {
|
||||
setGlobalData(region, new EnvironmentUrls());
|
||||
setUserData(Region.US, new EnvironmentUrls());
|
||||
|
||||
await initialize({ switchUser: false });
|
||||
|
||||
const host = await sut.getHost();
|
||||
expect(host).toBe(expectedHost);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ region: Region.US, expectedHost: "bitwarden.com" },
|
||||
{ region: Region.EU, expectedHost: "bitwarden.eu" },
|
||||
])(
|
||||
"gets it from global state if there is no active user even if a user id is passed in.",
|
||||
async ({ region, expectedHost }) => {
|
||||
setGlobalData(region, new EnvironmentUrls());
|
||||
setUserData(Region.US, new EnvironmentUrls());
|
||||
|
||||
await initialize({ switchUser: false });
|
||||
|
||||
const host = await sut.getHost(testUser);
|
||||
expect(host).toBe(expectedHost);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ region: Region.US, expectedHost: "bitwarden.com" },
|
||||
{ region: Region.EU, expectedHost: "bitwarden.eu" },
|
||||
])(
|
||||
"gets it from the passed in userId if there is any active user: %s",
|
||||
async ({ region, expectedHost }) => {
|
||||
setGlobalData(Region.US, new EnvironmentUrls());
|
||||
setUserData(Region.US, new EnvironmentUrls());
|
||||
setUserData(region, new EnvironmentUrls(), alternateTestUser);
|
||||
|
||||
await initialize({ switchUser: true });
|
||||
|
||||
const host = await sut.getHost(alternateTestUser);
|
||||
expect(host).toBe(expectedHost);
|
||||
},
|
||||
);
|
||||
|
||||
it("gets it from base url saved in self host config", async () => {
|
||||
const globalSelfHostUrls = new EnvironmentUrls();
|
||||
globalSelfHostUrls.base = "https://base.example.com";
|
||||
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
|
||||
setUserData(Region.EU, new EnvironmentUrls());
|
||||
|
||||
await initialize({ switchUser: false });
|
||||
|
||||
const host = await sut.getHost();
|
||||
expect(host).toBe("base.example.com");
|
||||
});
|
||||
|
||||
it("gets it from webVault url saved in self host config", async () => {
|
||||
const globalSelfHostUrls = new EnvironmentUrls();
|
||||
globalSelfHostUrls.webVault = "https://vault.example.com";
|
||||
globalSelfHostUrls.base = "https://base.example.com";
|
||||
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
|
||||
setUserData(Region.EU, new EnvironmentUrls());
|
||||
|
||||
await initialize({ switchUser: false });
|
||||
|
||||
const host = await sut.getHost();
|
||||
expect(host).toBe("vault.example.com");
|
||||
});
|
||||
|
||||
it("gets it from saved self host config from passed in user when there is an active user", async () => {
|
||||
setGlobalData(Region.US, new EnvironmentUrls());
|
||||
setUserData(Region.EU, new EnvironmentUrls());
|
||||
|
||||
const selfHostUserUrls = new EnvironmentUrls();
|
||||
selfHostUserUrls.base = "https://base.example.com";
|
||||
setUserData(Region.SelfHosted, selfHostUserUrls, alternateTestUser);
|
||||
|
||||
await initialize({ switchUser: true });
|
||||
|
||||
const host = await sut.getHost(alternateTestUser);
|
||||
expect(host).toBe("base.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setUrlsFromStorage", () => {
|
||||
it("will set the global data to Region US if no existing data", async () => {
|
||||
await sut.setUrlsFromStorage();
|
||||
|
||||
expect(sut.getWebVaultUrl()).toBe("https://vault.bitwarden.com");
|
||||
|
||||
const globalData = getGlobalData();
|
||||
expect(globalData.region).toBe(Region.US);
|
||||
});
|
||||
|
||||
it("will set the urls to whatever is in global", async () => {
|
||||
setGlobalData(Region.EU, new EnvironmentUrls());
|
||||
|
||||
await sut.setUrlsFromStorage();
|
||||
|
||||
expect(sut.getWebVaultUrl()).toBe("https://vault.bitwarden.eu");
|
||||
});
|
||||
|
||||
it("recovers from previous bug", async () => {
|
||||
const buggedEnvironmentUrls = new EnvironmentUrls();
|
||||
buggedEnvironmentUrls.base = "https://vault.bitwarden.com";
|
||||
buggedEnvironmentUrls.notifications = null;
|
||||
setGlobalData(null, buggedEnvironmentUrls);
|
||||
|
||||
const urlEmission = firstValueFrom(sut.urls.pipe(timeout(100)));
|
||||
|
||||
await sut.setUrlsFromStorage();
|
||||
|
||||
await urlEmission;
|
||||
|
||||
const globalData = getGlobalData();
|
||||
expect(globalData.region).toBe(Region.US);
|
||||
expect(globalData.urls).toEqual({
|
||||
base: null,
|
||||
api: null,
|
||||
identity: null,
|
||||
events: null,
|
||||
icons: null,
|
||||
notifications: null,
|
||||
keyConnector: null,
|
||||
webVault: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("will get urls from signed in user", async () => {
|
||||
await switchUser(testUser);
|
||||
|
||||
const userUrls = new EnvironmentUrls();
|
||||
userUrls.base = "base.example.com";
|
||||
setUserData(Region.SelfHosted, userUrls);
|
||||
|
||||
await sut.setUrlsFromStorage();
|
||||
|
||||
expect(sut.getWebVaultUrl()).toBe("base.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCloudWebVaultUrl", () => {
|
||||
it("no extra initialization, returns US vault", () => {
|
||||
expect(sut.getCloudWebVaultUrl()).toBe("https://vault.bitwarden.com");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ region: Region.US, expectedVault: "https://vault.bitwarden.com" },
|
||||
{ region: Region.EU, expectedVault: "https://vault.bitwarden.eu" },
|
||||
{ region: Region.SelfHosted, expectedVault: "https://vault.bitwarden.com" },
|
||||
])(
|
||||
"no extra initialization, returns expected host for each region %s",
|
||||
({ region, expectedVault }) => {
|
||||
expect(sut.setCloudWebVaultUrl(region));
|
||||
expect(sut.getCloudWebVaultUrl()).toBe(expectedVault);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
import {
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
ReplaySubject,
|
||||
} from "rxjs";
|
||||
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
|
||||
import { UserId } from "../../types/guid";
|
||||
import {
|
||||
EnvironmentService as EnvironmentServiceAbstraction,
|
||||
Region,
|
||||
RegionDomain,
|
||||
Urls,
|
||||
} from "../abstractions/environment.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { ENVIRONMENT_DISK, GlobalState, KeyDefinition, StateProvider } from "../state";
|
||||
|
||||
const REGION_KEY = new KeyDefinition<Region>(ENVIRONMENT_DISK, "region", {
|
||||
deserializer: (s) => s,
|
||||
});
|
||||
|
||||
const URLS_KEY = new KeyDefinition<EnvironmentUrls>(ENVIRONMENT_DISK, "urls", {
|
||||
deserializer: EnvironmentUrls.fromJSON,
|
||||
});
|
||||
|
||||
export class EnvironmentService implements EnvironmentServiceAbstraction {
|
||||
private readonly urlsSubject = new ReplaySubject<void>(1);
|
||||
urls: Observable<void> = this.urlsSubject.asObservable();
|
||||
selectedRegion?: Region;
|
||||
initialized = false;
|
||||
|
||||
protected baseUrl: string;
|
||||
protected webVaultUrl: string;
|
||||
protected apiUrl: string;
|
||||
protected identityUrl: string;
|
||||
protected iconsUrl: string;
|
||||
protected notificationsUrl: string;
|
||||
protected eventsUrl: string;
|
||||
private keyConnectorUrl: string;
|
||||
private scimUrl: string = null;
|
||||
private cloudWebVaultUrl: string;
|
||||
|
||||
private regionGlobalState: GlobalState<Region | null>;
|
||||
private urlsGlobalState: GlobalState<EnvironmentUrls | null>;
|
||||
|
||||
private activeAccountId$: Observable<UserId | null>;
|
||||
|
||||
readonly usUrls: Urls = {
|
||||
base: null,
|
||||
api: "https://api.bitwarden.com",
|
||||
identity: "https://identity.bitwarden.com",
|
||||
icons: "https://icons.bitwarden.net",
|
||||
webVault: "https://vault.bitwarden.com",
|
||||
notifications: "https://notifications.bitwarden.com",
|
||||
events: "https://events.bitwarden.com",
|
||||
scim: "https://scim.bitwarden.com",
|
||||
};
|
||||
|
||||
readonly euUrls: Urls = {
|
||||
base: null,
|
||||
api: "https://api.bitwarden.eu",
|
||||
identity: "https://identity.bitwarden.eu",
|
||||
icons: "https://icons.bitwarden.eu",
|
||||
webVault: "https://vault.bitwarden.eu",
|
||||
notifications: "https://notifications.bitwarden.eu",
|
||||
events: "https://events.bitwarden.eu",
|
||||
scim: "https://scim.bitwarden.eu",
|
||||
};
|
||||
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private accountService: AccountService,
|
||||
) {
|
||||
// We intentionally don't want the helper on account service, we want the null back if there is no active user
|
||||
this.activeAccountId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
|
||||
|
||||
// TODO: Get rid of early subscription during EnvironmentService refactor
|
||||
this.activeAccountId$
|
||||
.pipe(
|
||||
// Use == here to not trigger on undefined -> null transition
|
||||
distinctUntilChanged((oldUserId: string, newUserId: string) => oldUserId == newUserId),
|
||||
concatMap(async () => {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
await this.setUrlsFromStorage();
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.regionGlobalState = this.stateProvider.getGlobal(REGION_KEY);
|
||||
this.urlsGlobalState = this.stateProvider.getGlobal(URLS_KEY);
|
||||
}
|
||||
|
||||
hasBaseUrl() {
|
||||
return this.baseUrl != null;
|
||||
}
|
||||
|
||||
getNotificationsUrl() {
|
||||
if (this.notificationsUrl != null) {
|
||||
return this.notificationsUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl != null) {
|
||||
return this.baseUrl + "/notifications";
|
||||
}
|
||||
|
||||
return "https://notifications.bitwarden.com";
|
||||
}
|
||||
|
||||
getWebVaultUrl() {
|
||||
if (this.webVaultUrl != null) {
|
||||
return this.webVaultUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl;
|
||||
}
|
||||
return "https://vault.bitwarden.com";
|
||||
}
|
||||
|
||||
getCloudWebVaultUrl() {
|
||||
if (this.cloudWebVaultUrl != null) {
|
||||
return this.cloudWebVaultUrl;
|
||||
}
|
||||
|
||||
return this.usUrls.webVault;
|
||||
}
|
||||
|
||||
setCloudWebVaultUrl(region: Region) {
|
||||
switch (region) {
|
||||
case Region.EU:
|
||||
this.cloudWebVaultUrl = this.euUrls.webVault;
|
||||
break;
|
||||
case Region.US:
|
||||
default:
|
||||
this.cloudWebVaultUrl = this.usUrls.webVault;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getSendUrl() {
|
||||
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
|
||||
? "https://send.bitwarden.com/#"
|
||||
: this.getWebVaultUrl() + "/#/send/";
|
||||
}
|
||||
|
||||
getIconsUrl() {
|
||||
if (this.iconsUrl != null) {
|
||||
return this.iconsUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl + "/icons";
|
||||
}
|
||||
|
||||
return "https://icons.bitwarden.net";
|
||||
}
|
||||
|
||||
getApiUrl() {
|
||||
if (this.apiUrl != null) {
|
||||
return this.apiUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl + "/api";
|
||||
}
|
||||
|
||||
return "https://api.bitwarden.com";
|
||||
}
|
||||
|
||||
getIdentityUrl() {
|
||||
if (this.identityUrl != null) {
|
||||
return this.identityUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl + "/identity";
|
||||
}
|
||||
|
||||
return "https://identity.bitwarden.com";
|
||||
}
|
||||
|
||||
getEventsUrl() {
|
||||
if (this.eventsUrl != null) {
|
||||
return this.eventsUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl + "/events";
|
||||
}
|
||||
|
||||
return "https://events.bitwarden.com";
|
||||
}
|
||||
|
||||
getKeyConnectorUrl() {
|
||||
return this.keyConnectorUrl;
|
||||
}
|
||||
|
||||
getScimUrl() {
|
||||
if (this.scimUrl != null) {
|
||||
return this.scimUrl + "/v2";
|
||||
}
|
||||
|
||||
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
|
||||
? "https://scim.bitwarden.com/v2"
|
||||
: this.getWebVaultUrl() + "/scim/v2";
|
||||
}
|
||||
|
||||
async setUrlsFromStorage(): Promise<void> {
|
||||
const activeUserId = await firstValueFrom(this.activeAccountId$);
|
||||
|
||||
const region = await this.getRegion(activeUserId);
|
||||
const savedUrls = await this.getEnvironmentUrls(activeUserId);
|
||||
const envUrls = new EnvironmentUrls();
|
||||
|
||||
// In release `2023.5.0`, we set the `base` property of the environment URLs to the US web vault URL when a user clicked the "US" region.
|
||||
// This check will detect these cases and convert them to the proper region instead.
|
||||
// We are detecting this by checking for the presence of the web vault URL in the `base` and the absence of the `notifications` property.
|
||||
// This is because the `notifications` will not be `null` in the web vault, and we don't want to migrate the URLs in that case.
|
||||
if (savedUrls.base === "https://vault.bitwarden.com" && savedUrls.notifications == null) {
|
||||
await this.setRegion(Region.US);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (region) {
|
||||
case Region.EU:
|
||||
await this.setRegion(Region.EU);
|
||||
return;
|
||||
case Region.US:
|
||||
await this.setRegion(Region.US);
|
||||
return;
|
||||
case Region.SelfHosted:
|
||||
case null:
|
||||
default:
|
||||
this.baseUrl = envUrls.base = savedUrls.base;
|
||||
this.webVaultUrl = savedUrls.webVault;
|
||||
this.apiUrl = envUrls.api = savedUrls.api;
|
||||
this.identityUrl = envUrls.identity = savedUrls.identity;
|
||||
this.iconsUrl = savedUrls.icons;
|
||||
this.notificationsUrl = savedUrls.notifications;
|
||||
this.eventsUrl = envUrls.events = savedUrls.events;
|
||||
this.keyConnectorUrl = savedUrls.keyConnector;
|
||||
await this.setRegion(Region.SelfHosted);
|
||||
// scimUrl is not saved to storage
|
||||
this.urlsSubject.next();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async setUrls(urls: Urls): Promise<Urls> {
|
||||
urls.base = this.formatUrl(urls.base);
|
||||
urls.webVault = this.formatUrl(urls.webVault);
|
||||
urls.api = this.formatUrl(urls.api);
|
||||
urls.identity = this.formatUrl(urls.identity);
|
||||
urls.icons = this.formatUrl(urls.icons);
|
||||
urls.notifications = this.formatUrl(urls.notifications);
|
||||
urls.events = this.formatUrl(urls.events);
|
||||
urls.keyConnector = this.formatUrl(urls.keyConnector);
|
||||
|
||||
// scimUrl cannot be cleared
|
||||
urls.scim = this.formatUrl(urls.scim) ?? this.scimUrl;
|
||||
|
||||
// Don't save scim url
|
||||
await this.urlsGlobalState.update(() => ({
|
||||
base: urls.base,
|
||||
api: urls.api,
|
||||
identity: urls.identity,
|
||||
webVault: urls.webVault,
|
||||
icons: urls.icons,
|
||||
notifications: urls.notifications,
|
||||
events: urls.events,
|
||||
keyConnector: urls.keyConnector,
|
||||
}));
|
||||
|
||||
this.baseUrl = urls.base;
|
||||
this.webVaultUrl = urls.webVault;
|
||||
this.apiUrl = urls.api;
|
||||
this.identityUrl = urls.identity;
|
||||
this.iconsUrl = urls.icons;
|
||||
this.notificationsUrl = urls.notifications;
|
||||
this.eventsUrl = urls.events;
|
||||
this.keyConnectorUrl = urls.keyConnector;
|
||||
this.scimUrl = urls.scim;
|
||||
|
||||
await this.setRegion(Region.SelfHosted);
|
||||
|
||||
this.urlsSubject.next();
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
getUrls() {
|
||||
return {
|
||||
base: this.baseUrl,
|
||||
webVault: this.webVaultUrl,
|
||||
cloudWebVault: this.cloudWebVaultUrl,
|
||||
api: this.apiUrl,
|
||||
identity: this.identityUrl,
|
||||
icons: this.iconsUrl,
|
||||
notifications: this.notificationsUrl,
|
||||
events: this.eventsUrl,
|
||||
keyConnector: this.keyConnectorUrl,
|
||||
scim: this.scimUrl,
|
||||
};
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return (
|
||||
this.baseUrl == null &&
|
||||
this.webVaultUrl == null &&
|
||||
this.apiUrl == null &&
|
||||
this.identityUrl == null &&
|
||||
this.iconsUrl == null &&
|
||||
this.notificationsUrl == null &&
|
||||
this.eventsUrl == null
|
||||
);
|
||||
}
|
||||
|
||||
async getHost(userId?: UserId) {
|
||||
const region = await this.getRegion(userId);
|
||||
|
||||
switch (region) {
|
||||
case Region.US:
|
||||
return RegionDomain.US;
|
||||
case Region.EU:
|
||||
return RegionDomain.EU;
|
||||
default: {
|
||||
// Environment is self-hosted
|
||||
const envUrls = await this.getEnvironmentUrls(userId);
|
||||
return Utils.getHost(envUrls.webVault || envUrls.base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getRegion(userId: UserId | null) {
|
||||
// Previous rules dictated that we only get from user scoped state if there is an active user.
|
||||
const activeUserId = await firstValueFrom(this.activeAccountId$);
|
||||
return activeUserId == null
|
||||
? await firstValueFrom(this.regionGlobalState.state$)
|
||||
: await firstValueFrom(this.stateProvider.getUser(userId ?? activeUserId, REGION_KEY).state$);
|
||||
}
|
||||
|
||||
private async getEnvironmentUrls(userId: UserId | null) {
|
||||
return userId == null
|
||||
? (await firstValueFrom(this.urlsGlobalState.state$)) ?? new EnvironmentUrls()
|
||||
: (await firstValueFrom(this.stateProvider.getUser(userId, URLS_KEY).state$)) ??
|
||||
new EnvironmentUrls();
|
||||
}
|
||||
|
||||
async setRegion(region: Region) {
|
||||
this.selectedRegion = region;
|
||||
await this.regionGlobalState.update(() => region);
|
||||
|
||||
if (region === Region.SelfHosted) {
|
||||
// If user saves a self-hosted region with empty fields, default to US
|
||||
if (this.isEmpty()) {
|
||||
await this.setRegion(Region.US);
|
||||
}
|
||||
} else {
|
||||
// If we are setting the region to EU or US, clear the self-hosted URLs
|
||||
await this.urlsGlobalState.update(() => new EnvironmentUrls());
|
||||
if (region === Region.EU) {
|
||||
this.setUrlsInternal(this.euUrls);
|
||||
} else if (region === Region.US) {
|
||||
this.setUrlsInternal(this.usUrls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async seedUserEnvironment(userId: UserId) {
|
||||
const globalRegion = await firstValueFrom(this.regionGlobalState.state$);
|
||||
const globalUrls = await firstValueFrom(this.urlsGlobalState.state$);
|
||||
await this.stateProvider.getUser(userId, REGION_KEY).update(() => globalRegion);
|
||||
await this.stateProvider.getUser(userId, URLS_KEY).update(() => globalUrls);
|
||||
}
|
||||
|
||||
private setUrlsInternal(urls: Urls) {
|
||||
this.baseUrl = this.formatUrl(urls.base);
|
||||
this.webVaultUrl = this.formatUrl(urls.webVault);
|
||||
this.apiUrl = this.formatUrl(urls.api);
|
||||
this.identityUrl = this.formatUrl(urls.identity);
|
||||
this.iconsUrl = this.formatUrl(urls.icons);
|
||||
this.notificationsUrl = this.formatUrl(urls.notifications);
|
||||
this.eventsUrl = this.formatUrl(urls.events);
|
||||
this.keyConnectorUrl = this.formatUrl(urls.keyConnector);
|
||||
|
||||
// scimUrl cannot be cleared
|
||||
this.scimUrl = this.formatUrl(urls.scim) ?? this.scimUrl;
|
||||
this.urlsSubject.next();
|
||||
}
|
||||
|
||||
private formatUrl(url: string): string {
|
||||
if (url == null || url === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
url = url.replace(/\/+$/g, "");
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
|
||||
return url.trim();
|
||||
}
|
||||
|
||||
isCloud(): boolean {
|
||||
return [
|
||||
"https://api.bitwarden.com",
|
||||
"https://vault.bitwarden.com/api",
|
||||
"https://api.bitwarden.eu",
|
||||
"https://vault.bitwarden.eu/api",
|
||||
].includes(this.getApiUrl());
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ describe("MigrationBuilderService", () => {
|
||||
startingStateVersion,
|
||||
new FakeStorageService(startingState),
|
||||
mock(),
|
||||
"general",
|
||||
);
|
||||
|
||||
await sut.build().migrate(helper);
|
||||
|
||||
@@ -18,6 +18,7 @@ export class MigrationRunner {
|
||||
await currentVersion(this.diskStorage, this.logService),
|
||||
this.diskStorage,
|
||||
this.logService,
|
||||
"general",
|
||||
);
|
||||
|
||||
if (migrationHelper.currentVersion < 0) {
|
||||
|
||||
@@ -8,14 +8,13 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re
|
||||
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
|
||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||
import { BiometricKey } from "../../auth/types/biometric-key";
|
||||
import { WindowState } from "../../models/domain/window-state";
|
||||
import { GeneratorOptions } from "../../tools/generator/generator-options";
|
||||
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
|
||||
import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, MasterKey } from "../../types/key";
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { EnvironmentService } from "../abstractions/environment.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import {
|
||||
@@ -29,13 +28,7 @@ import {
|
||||
import { HtmlStorageLocation, KdfType, StorageLocation } from "../enums";
|
||||
import { StateFactory } from "../factories/state-factory";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { ServerConfigData } from "../models/data/server-config.data";
|
||||
import {
|
||||
Account,
|
||||
AccountData,
|
||||
AccountDecryptionOptions,
|
||||
AccountSettings,
|
||||
} from "../models/domain/account";
|
||||
import { Account, AccountData, AccountSettings } from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { GlobalState } from "../models/domain/global-state";
|
||||
import { State } from "../models/domain/state";
|
||||
@@ -250,59 +243,6 @@ export class StateService<
|
||||
return currentUser as UserId;
|
||||
}
|
||||
|
||||
async getAlwaysShowDock(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.alwaysShowDock ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setAlwaysShowDock(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.alwaysShowDock = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getBiometricFingerprintValidated(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.biometricFingerprintValidated ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setBiometricFingerprintValidated(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.biometricFingerprintValidated = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getConvertAccountToKeyConnector(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
)?.profile?.convertAccountToKeyConnector;
|
||||
}
|
||||
|
||||
async setConvertAccountToKeyConnector(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.profile.convertAccountToKeyConnector = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do not save the Master Key. Use the User Symmetric Key instead
|
||||
*/
|
||||
@@ -642,24 +582,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getDisableGa(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.settings?.disableGa ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setDisableGa(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.settings.disableGa = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
@@ -678,39 +600,6 @@ export class StateService<
|
||||
: await this.secureStorageService.save(DDG_SHARED_KEY, value, options);
|
||||
}
|
||||
|
||||
async getDeviceKey(options?: StorageOptions): Promise<DeviceKey | null> {
|
||||
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
|
||||
|
||||
if (options?.userId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const account = await this.getAccount(options);
|
||||
|
||||
const existingDeviceKey = account?.keys?.deviceKey;
|
||||
|
||||
// Must manually instantiate the SymmetricCryptoKey class from the JSON object
|
||||
if (existingDeviceKey != null) {
|
||||
return SymmetricCryptoKey.fromJSON(existingDeviceKey) as DeviceKey;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setDeviceKey(value: DeviceKey | null, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
|
||||
|
||||
if (options?.userId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const account = await this.getAccount(options);
|
||||
|
||||
account.keys.deviceKey = value?.toJSON() ?? null;
|
||||
|
||||
await this.saveAccount(account, options);
|
||||
}
|
||||
|
||||
async getAdminAuthRequest(options?: StorageOptions): Promise<AdminAuthRequestStorable | null> {
|
||||
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
|
||||
|
||||
@@ -742,62 +631,6 @@ export class StateService<
|
||||
await this.saveAccount(account, options);
|
||||
}
|
||||
|
||||
async getShouldTrustDevice(options?: StorageOptions): Promise<boolean | null> {
|
||||
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
|
||||
|
||||
if (options?.userId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const account = await this.getAccount(options);
|
||||
|
||||
return account?.settings?.trustDeviceChoiceForDecryption ?? null;
|
||||
}
|
||||
|
||||
async setShouldTrustDevice(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
|
||||
if (options?.userId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const account = await this.getAccount(options);
|
||||
|
||||
account.settings.trustDeviceChoiceForDecryption = value;
|
||||
|
||||
await this.saveAccount(account, options);
|
||||
}
|
||||
|
||||
async getAccountDecryptionOptions(
|
||||
options?: StorageOptions,
|
||||
): Promise<AccountDecryptionOptions | null> {
|
||||
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
|
||||
|
||||
if (options?.userId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const account = await this.getAccount(options);
|
||||
|
||||
return account?.decryptionOptions as AccountDecryptionOptions;
|
||||
}
|
||||
|
||||
async setAccountDecryptionOptions(
|
||||
value: AccountDecryptionOptions,
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
|
||||
|
||||
if (options?.userId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const account = await this.getAccount(options);
|
||||
|
||||
account.decryptionOptions = value;
|
||||
|
||||
await this.saveAccount(account, options);
|
||||
}
|
||||
|
||||
async getEmail(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
@@ -833,36 +666,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableAlwaysOnTop(options?: StorageOptions): Promise<boolean> {
|
||||
const accountPreference = (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
)?.settings?.enableAlwaysOnTop;
|
||||
const globalPreference = (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
)?.enableAlwaysOnTop;
|
||||
return accountPreference ?? globalPreference ?? false;
|
||||
}
|
||||
|
||||
async setEnableAlwaysOnTop(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.settings.enableAlwaysOnTop = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.enableAlwaysOnTop = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
@@ -902,99 +705,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableCloseToTray(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.enableCloseToTray ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setEnableCloseToTray(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.enableCloseToTray = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableDuckDuckGoBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.enableDuckDuckGoBrowserIntegration ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setEnableDuckDuckGoBrowserIntegration(
|
||||
value: boolean,
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.enableDuckDuckGoBrowserIntegration = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableMinimizeToTray(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.enableMinimizeToTray ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setEnableMinimizeToTray(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.enableMinimizeToTray = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableStartToTray(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.enableStartToTray ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setEnableStartToTray(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.enableStartToTray = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableTray(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.enableTray ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setEnableTray(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.enableTray = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use UserKey instead
|
||||
*/
|
||||
@@ -1107,23 +817,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getInstalledVersion(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
)?.installedVersion;
|
||||
}
|
||||
|
||||
async setInstalledVersion(value: string, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.installedVersion = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getIsAuthenticated(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.tokenService.getAccessToken(options?.userId as UserId)) != null &&
|
||||
@@ -1237,40 +930,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getLocale(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
|
||||
)?.locale;
|
||||
}
|
||||
|
||||
async setLocale(value: string, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
||||
);
|
||||
globals.locale = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getMainWindowSize(options?: StorageOptions): Promise<number> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.mainWindowSize;
|
||||
}
|
||||
|
||||
async setMainWindowSize(value: number, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
globals.mainWindowSize = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getMinimizeOnCopyToClipboard(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
@@ -1289,24 +948,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getOpenAtLogin(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.openAtLogin ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setOpenAtLogin(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.openAtLogin = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getOrganizationInvitation(options?: StorageOptions): Promise<any> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
@@ -1398,23 +1039,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getRememberedEmail(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
|
||||
)?.rememberedEmail;
|
||||
}
|
||||
|
||||
async setRememberedEmail(value: string, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
||||
);
|
||||
globals.rememberedEmail = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getSecurityStamp(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
@@ -1438,23 +1062,6 @@ export class StateService<
|
||||
)?.profile?.userId;
|
||||
}
|
||||
|
||||
async getUsesKeyConnector(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
)?.profile?.usesKeyConnector;
|
||||
}
|
||||
|
||||
async setUsesKeyConnector(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.profile.usesKeyConnector = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getVaultTimeout(options?: StorageOptions): Promise<number> {
|
||||
const accountVaultTimeout = (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
|
||||
@@ -1516,41 +1123,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getWindow(): Promise<WindowState> {
|
||||
const globals = await this.getGlobals(await this.defaultOnDiskOptions());
|
||||
return globals?.window != null && Object.keys(globals.window).length > 0
|
||||
? globals.window
|
||||
: new WindowState();
|
||||
}
|
||||
|
||||
async setWindow(value: WindowState, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.window = value;
|
||||
return await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async setServerConfig(value: ServerConfigData, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
||||
);
|
||||
account.settings.serverConfig = value;
|
||||
return await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getServerConfig(options: StorageOptions): Promise<ServerConfigData> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
|
||||
)?.settings?.serverConfig;
|
||||
}
|
||||
|
||||
async getDeepLinkRedirectUrl(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
@@ -1912,7 +1484,6 @@ export class StateService<
|
||||
protected resetAccount(account: TAccount) {
|
||||
const persistentAccountInformation = {
|
||||
settings: account.settings,
|
||||
keys: { deviceKey: account.keys.deviceKey },
|
||||
adminAuthRequest: account.adminAuthRequest,
|
||||
};
|
||||
return Object.assign(this.createAccount(), persistentAccountInformation);
|
||||
@@ -1938,7 +1509,9 @@ export class StateService<
|
||||
}
|
||||
|
||||
protected async deAuthenticateAccount(userId: string): Promise<void> {
|
||||
await this.tokenService.clearAccessToken(userId as UserId);
|
||||
// We must have a manual call to clear tokens as we can't leverage state provider to clean
|
||||
// up our data as we have secure storage in the mix.
|
||||
await this.tokenService.clearTokens(userId as UserId);
|
||||
await this.setLastActive(null, { userId: userId });
|
||||
await this.updateState(async (state) => {
|
||||
state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId);
|
||||
|
||||
@@ -16,6 +16,7 @@ export abstract class TranslationService implements TranslationServiceAbstractio
|
||||
["bs", "bosanski jezik"],
|
||||
["ca", "català"],
|
||||
["cs", "čeština"],
|
||||
["cy", "Cymraeg, y Gymraeg"],
|
||||
["da", "dansk"],
|
||||
["de", "Deutsch"],
|
||||
["el", "Ελληνικά"],
|
||||
@@ -30,6 +31,7 @@ export abstract class TranslationService implements TranslationServiceAbstractio
|
||||
["fi", "suomi"],
|
||||
["fil", "Wikang Filipino"],
|
||||
["fr", "français"],
|
||||
["gl", "galego"],
|
||||
["he", "עברית"],
|
||||
["hi", "हिन्दी"],
|
||||
["hr", "hrvatski"],
|
||||
@@ -45,9 +47,13 @@ export abstract class TranslationService implements TranslationServiceAbstractio
|
||||
["lv", "Latvietis"],
|
||||
["me", "црногорски"],
|
||||
["ml", "മലയാളം"],
|
||||
["mr", "मराठी"],
|
||||
["my", "ဗမာစကား"],
|
||||
["nb", "norsk (bokmål)"],
|
||||
["ne", "नेपाली"],
|
||||
["nl", "Nederlands"],
|
||||
["nn", "Norsk Nynorsk"],
|
||||
["or", "ଓଡ଼ିଆ"],
|
||||
["pl", "polski"],
|
||||
["pt-BR", "português do Brasil"],
|
||||
["pt-PT", "português"],
|
||||
@@ -58,6 +64,7 @@ export abstract class TranslationService implements TranslationServiceAbstractio
|
||||
["sl", "Slovenski jezik, Slovenščina"],
|
||||
["sr", "Српски"],
|
||||
["sv", "svenska"],
|
||||
["te", "తెలుగు"],
|
||||
["th", "ไทย"],
|
||||
["tr", "Türkçe"],
|
||||
["uk", "українська"],
|
||||
|
||||
@@ -17,9 +17,9 @@ export abstract class DerivedStateProvider {
|
||||
* well as some memory persistent information.
|
||||
* @param dependencies The dependencies of the derive function
|
||||
*/
|
||||
get: <TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
abstract get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
) => DerivedState<TTo>;
|
||||
): DerivedState<TTo>;
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ export abstract class GlobalStateProvider {
|
||||
* Gets a {@link GlobalState} scoped to the given {@link KeyDefinition}
|
||||
* @param keyDefinition - The {@link KeyDefinition} for which you want the state for.
|
||||
*/
|
||||
get: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
|
||||
abstract get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export { StateProvider } from "./state.provider";
|
||||
export { GlobalStateProvider } from "./global-state.provider";
|
||||
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
|
||||
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
|
||||
export { KeyDefinition } from "./key-definition";
|
||||
export { KeyDefinition, KeyDefinitionOptions } from "./key-definition";
|
||||
export { StateUpdateOptions } from "./state-update-options";
|
||||
export { UserKeyDefinition } from "./user-key-definition";
|
||||
export { StateEventRunnerService } from "./state-event-runner.service";
|
||||
|
||||
@@ -35,15 +35,23 @@ export const BILLING_DISK = new StateDefinition("billing", "disk");
|
||||
|
||||
// Auth
|
||||
|
||||
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
|
||||
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||
export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" });
|
||||
export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
|
||||
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
||||
export const TOKEN_DISK = new StateDefinition("token", "disk");
|
||||
export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const TOKEN_MEMORY = new StateDefinition("token", "memory");
|
||||
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
|
||||
export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk");
|
||||
|
||||
// Autofill
|
||||
|
||||
@@ -72,10 +80,15 @@ export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk",
|
||||
});
|
||||
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
||||
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
|
||||
export const CONFIG_DISK = new StateDefinition("config", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
|
||||
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
|
||||
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
|
||||
export const THEMING_DISK = new StateDefinition("theming", "disk");
|
||||
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
|
||||
export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" });
|
||||
export const TRANSLATION_DISK = new StateDefinition("translation", "disk");
|
||||
|
||||
// Secrets Manager
|
||||
@@ -105,5 +118,5 @@ export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", {
|
||||
export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory");
|
||||
export const CIPHERS_DISK = new StateDefinition("localData", "disk", { web: "disk-local" });
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.p
|
||||
*/
|
||||
export abstract class StateProvider {
|
||||
/** @see{@link ActiveUserStateProvider.activeUserId$} */
|
||||
activeUserId$: Observable<UserId | undefined>;
|
||||
abstract activeUserId$: Observable<UserId | undefined>;
|
||||
|
||||
/**
|
||||
* Gets a state observable for a given key and userId.
|
||||
@@ -149,10 +149,10 @@ export abstract class StateProvider {
|
||||
): SingleUserState<T>;
|
||||
|
||||
/** @see{@link GlobalStateProvider.get} */
|
||||
getGlobal: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
|
||||
getDerived: <TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
abstract getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>;
|
||||
abstract getDerived<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
) => DerivedState<TTo>;
|
||||
): DerivedState<TTo>;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export abstract class ActiveUserStateProvider {
|
||||
/**
|
||||
* Convenience re-emission of active user ID from {@link AccountService.activeAccount$}
|
||||
*/
|
||||
activeUserId$: Observable<UserId | undefined>;
|
||||
abstract activeUserId$: Observable<UserId | undefined>;
|
||||
|
||||
/**
|
||||
* Gets a {@link ActiveUserState} scoped to the given {@link KeyDefinition}, but updates when active user changes such
|
||||
|
||||
@@ -6,24 +6,25 @@ import { StateUpdateOptions } from "./state-update-options";
|
||||
|
||||
export type CombinedState<T> = readonly [userId: UserId, state: T];
|
||||
|
||||
/**
|
||||
* A helper object for interacting with state that is scoped to a specific user.
|
||||
*/
|
||||
/** A helper object for interacting with state that is scoped to a specific user. */
|
||||
export interface UserState<T> {
|
||||
/**
|
||||
* Emits a stream of data.
|
||||
*/
|
||||
readonly state$: Observable<T>;
|
||||
/** Emits a stream of data. Emits null if the user does not have specified state. */
|
||||
readonly state$: Observable<T | null>;
|
||||
|
||||
/**
|
||||
* Emits a stream of data alongside the user id the data corresponds to.
|
||||
*/
|
||||
/** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */
|
||||
readonly combinedState$: Observable<CombinedState<T>>;
|
||||
}
|
||||
|
||||
export const activeMarker: unique symbol = Symbol("active");
|
||||
export interface ActiveUserState<T> extends UserState<T> {
|
||||
readonly [activeMarker]: true;
|
||||
|
||||
/**
|
||||
* Emits a stream of data. Emits null if the user does not have specified state.
|
||||
* Note: Will not emit if there is no active user.
|
||||
*/
|
||||
readonly state$: Observable<T | null>;
|
||||
|
||||
/**
|
||||
* Updates backing stores for the active user.
|
||||
* @param configureState function that takes the current state and returns the new state
|
||||
|
||||
@@ -7,13 +7,13 @@ export abstract class ThemeStateService {
|
||||
/**
|
||||
* The users selected theme.
|
||||
*/
|
||||
selectedTheme$: Observable<ThemeType>;
|
||||
abstract selectedTheme$: Observable<ThemeType>;
|
||||
|
||||
/**
|
||||
* A method for updating the current users configured theme.
|
||||
* @param theme The chosen user theme.
|
||||
*/
|
||||
setSelectedTheme: (theme: ThemeType) => Promise<void>;
|
||||
abstract setSelectedTheme(theme: ThemeType): Promise<void>;
|
||||
}
|
||||
|
||||
const THEME_SELECTION = new KeyDefinition<ThemeType>(THEMING_DISK, "selection", {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service";
|
||||
import { OrganizationConnectionType } from "../admin-console/enums";
|
||||
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request";
|
||||
@@ -204,10 +206,12 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
? request.toIdentityToken()
|
||||
: request.toIdentityToken(this.platformUtilsService.getClientType());
|
||||
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
|
||||
const response = await this.fetch(
|
||||
new Request(this.environmentService.getIdentityUrl() + "/connect/token", {
|
||||
new Request(env.getIdentityUrl() + "/connect/token", {
|
||||
body: this.qsStringify(identityToken),
|
||||
credentials: this.getCredentials(),
|
||||
credentials: await this.getCredentials(),
|
||||
cache: "no-store",
|
||||
headers: headers,
|
||||
method: "POST",
|
||||
@@ -323,13 +327,14 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async postPrelogin(request: PreloginRequest): Promise<PreloginResponse> {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const r = await this.send(
|
||||
"POST",
|
||||
"/accounts/prelogin",
|
||||
request,
|
||||
false,
|
||||
true,
|
||||
this.environmentService.getIdentityUrl(),
|
||||
env.getIdentityUrl(),
|
||||
);
|
||||
return new PreloginResponse(r);
|
||||
}
|
||||
@@ -368,13 +373,14 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async postRegister(request: RegisterRequest): Promise<RegisterResponse> {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const r = await this.send(
|
||||
"POST",
|
||||
"/accounts/register",
|
||||
request,
|
||||
false,
|
||||
true,
|
||||
this.environmentService.getIdentityUrl(),
|
||||
env.getIdentityUrl(),
|
||||
);
|
||||
return new RegisterResponse(r);
|
||||
}
|
||||
@@ -388,10 +394,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return this.send("POST", "/accounts/reinstate-premium", null, true, false);
|
||||
}
|
||||
|
||||
postCancelPremium(): Promise<any> {
|
||||
return this.send("POST", "/accounts/cancel-premium", null, true, false);
|
||||
}
|
||||
|
||||
async postAccountStorage(request: StorageRequest): Promise<PaymentResponse> {
|
||||
const r = await this.send("POST", "/accounts/storage", request, true, true);
|
||||
return new PaymentResponse(r);
|
||||
@@ -1457,10 +1459,11 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
if (this.customUserAgent != null) {
|
||||
headers.set("User-Agent", this.customUserAgent);
|
||||
}
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const response = await this.fetch(
|
||||
new Request(this.environmentService.getEventsUrl() + "/collect", {
|
||||
new Request(env.getEventsUrl() + "/collect", {
|
||||
cache: "no-store",
|
||||
credentials: this.getCredentials(),
|
||||
credentials: await this.getCredentials(),
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
headers: headers,
|
||||
@@ -1617,11 +1620,12 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
headers.set("User-Agent", this.customUserAgent);
|
||||
}
|
||||
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const path = `/sso/prevalidate?domainHint=${encodeURIComponent(identifier)}`;
|
||||
const response = await this.fetch(
|
||||
new Request(this.environmentService.getIdentityUrl() + path, {
|
||||
new Request(env.getIdentityUrl() + path, {
|
||||
cache: "no-store",
|
||||
credentials: this.getCredentials(),
|
||||
credentials: await this.getCredentials(),
|
||||
headers: headers,
|
||||
method: "GET",
|
||||
}),
|
||||
@@ -1751,16 +1755,17 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
headers.set("User-Agent", this.customUserAgent);
|
||||
}
|
||||
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const decodedToken = await this.tokenService.decodeAccessToken();
|
||||
const response = await this.fetch(
|
||||
new Request(this.environmentService.getIdentityUrl() + "/connect/token", {
|
||||
new Request(env.getIdentityUrl() + "/connect/token", {
|
||||
body: this.qsStringify({
|
||||
grant_type: "refresh_token",
|
||||
client_id: decodedToken.client_id,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
cache: "no-store",
|
||||
credentials: this.getCredentials(),
|
||||
credentials: await this.getCredentials(),
|
||||
headers: headers,
|
||||
method: "POST",
|
||||
}),
|
||||
@@ -1775,9 +1780,9 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
|
||||
await this.tokenService.setTokens(
|
||||
tokenResponse.accessToken,
|
||||
tokenResponse.refreshToken,
|
||||
vaultTimeoutAction as VaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
tokenResponse.refreshToken,
|
||||
);
|
||||
} else {
|
||||
const error = await this.handleError(response, true, true);
|
||||
@@ -1822,7 +1827,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
apiUrl?: string,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
): Promise<any> {
|
||||
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? this.environmentService.getApiUrl() : apiUrl;
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
|
||||
|
||||
// Prevent directory traversal from malicious paths
|
||||
const pathParts = path.split("?");
|
||||
@@ -1838,7 +1844,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
cache: "no-store",
|
||||
credentials: this.getCredentials(),
|
||||
credentials: await this.getCredentials(),
|
||||
method: method,
|
||||
};
|
||||
|
||||
@@ -1917,8 +1923,9 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
.join("&");
|
||||
}
|
||||
|
||||
private getCredentials(): RequestCredentials {
|
||||
if (!this.isWebClient || this.environmentService.hasBaseUrl()) {
|
||||
private async getCredentials(): Promise<RequestCredentials> {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
if (!this.isWebClient || env.hasBaseUrl()) {
|
||||
return "include";
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
|
||||
@@ -38,7 +39,7 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
||||
private authService: AuthService,
|
||||
private messagingService: MessagingService,
|
||||
) {
|
||||
this.environmentService.urls.subscribe(() => {
|
||||
this.environmentService.environment$.subscribe(() => {
|
||||
if (!this.inited) {
|
||||
return;
|
||||
}
|
||||
@@ -51,7 +52,7 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.inited = false;
|
||||
this.url = this.environmentService.getNotificationsUrl();
|
||||
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
|
||||
|
||||
// Set notifications server URL to `https://-` to effectively disable communication
|
||||
// with the notifications server from the client app
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
import { BehaviorSubject, firstValueFrom, map, of } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeUserDecryptionOptions as UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
|
||||
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Policy } from "../../admin-console/models/domain/policy";
|
||||
@@ -8,12 +13,12 @@ import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { BiometricStateService } from "../../platform/biometrics/biometric-state.service";
|
||||
import { AccountDecryptionOptions } from "../../platform/models/domain/account";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
|
||||
import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service";
|
||||
|
||||
describe("VaultTimeoutSettingsService", () => {
|
||||
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
@@ -21,12 +26,26 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
const biometricStateService = mock<BiometricStateService>();
|
||||
let service: VaultTimeoutSettingsService;
|
||||
|
||||
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
|
||||
|
||||
beforeEach(() => {
|
||||
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
cryptoService = mock<CryptoService>();
|
||||
tokenService = mock<TokenService>();
|
||||
policyService = mock<PolicyService>();
|
||||
stateService = mock<StateService>();
|
||||
|
||||
userDecryptionOptionsSubject = new BehaviorSubject(null);
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
|
||||
userDecryptionOptionsService.hasMasterPassword$ = userDecryptionOptionsSubject.pipe(
|
||||
map((options) => options?.hasMasterPassword ?? false),
|
||||
);
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
userDecryptionOptionsSubject,
|
||||
);
|
||||
|
||||
service = new VaultTimeoutSettingsService(
|
||||
userDecryptionOptionsService,
|
||||
cryptoService,
|
||||
tokenService,
|
||||
policyService,
|
||||
@@ -49,9 +68,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
});
|
||||
|
||||
it("contains Lock when the user has a master password", async () => {
|
||||
stateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
new AccountDecryptionOptions({ hasMasterPassword: true }),
|
||||
);
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
|
||||
const result = await firstValueFrom(service.availableVaultTimeoutActions$());
|
||||
|
||||
@@ -83,9 +100,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
});
|
||||
|
||||
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
|
||||
stateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
new AccountDecryptionOptions({ hasMasterPassword: false }),
|
||||
);
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false }));
|
||||
stateService.getPinKeyEncryptedUserKey.mockResolvedValue(null);
|
||||
stateService.getProtectedPin.mockResolvedValue(null);
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
@@ -107,9 +122,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
`(
|
||||
"returns $expected when policy is $policy, and user preference is $userPreference",
|
||||
async ({ policy, userPreference, expected }) => {
|
||||
stateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
new AccountDecryptionOptions({ hasMasterPassword: true }),
|
||||
);
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.getAll$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
@@ -136,8 +149,8 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
"returns $expected when policy is $policy, has unlock method is $unlockMethod, and user preference is $userPreference",
|
||||
async ({ unlockMethod, policy, userPreference, expected }) => {
|
||||
biometricStateService.biometricUnlockEnabled$ = of(unlockMethod);
|
||||
stateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
new AccountDecryptionOptions({ hasMasterPassword: false }),
|
||||
userDecryptionOptionsSubject.next(
|
||||
new UserDecryptionOptions({ hasMasterPassword: false }),
|
||||
);
|
||||
policyService.getAll$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defer, firstValueFrom } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
|
||||
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "../../admin-console/enums";
|
||||
@@ -19,6 +21,7 @@ export type PinLockType = "DISABLED" | "PERSISTANT" | "TRANSIENT";
|
||||
|
||||
export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceAbstraction {
|
||||
constructor(
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private cryptoService: CryptoService,
|
||||
private tokenService: TokenService,
|
||||
private policyService: PolicyService,
|
||||
@@ -49,7 +52,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
|
||||
await this.stateService.setVaultTimeoutAction(action);
|
||||
|
||||
await this.tokenService.setTokens(accessToken, refreshToken, action, timeout, [
|
||||
await this.tokenService.setTokens(accessToken, action, timeout, refreshToken, [
|
||||
clientId,
|
||||
clientSecret,
|
||||
]);
|
||||
@@ -174,12 +177,15 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
}
|
||||
|
||||
private async userHasMasterPassword(userId: string): Promise<boolean> {
|
||||
const acctDecryptionOpts = await this.stateService.getAccountDecryptionOptions({
|
||||
userId: userId,
|
||||
});
|
||||
if (userId) {
|
||||
const decryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
);
|
||||
|
||||
if (acctDecryptionOpts?.hasMasterPassword != undefined) {
|
||||
return acctDecryptionOpts.hasMasterPassword;
|
||||
if (decryptionOptions?.hasMasterPassword != undefined) {
|
||||
return decryptionOptions.hasMasterPassword;
|
||||
}
|
||||
}
|
||||
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,12 @@ import { OrganizationMigrator } from "./migrations/40-move-organization-state-to
|
||||
import { EventCollectionMigrator } from "./migrations/41-move-event-collection-to-state-provider";
|
||||
import { EnableFaviconMigrator } from "./migrations/42-move-enable-favicon-to-domain-settings-state-provider";
|
||||
import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confirm-finger-prints-to-state-provider";
|
||||
import { LocalDataMigrator } from "./migrations/44-move-local-data-to-state-provider";
|
||||
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
||||
import { KeyConnectorMigrator } from "./migrations/50-move-key-connector-to-state-provider";
|
||||
import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-to-state-providers";
|
||||
import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version";
|
||||
import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers";
|
||||
import { LocalDataMigrator } from "./migrations/54-move-local-data-to-state-provider";
|
||||
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
||||
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
|
||||
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
|
||||
@@ -48,7 +52,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
|
||||
import { MinVersionMigrator } from "./migrations/min-version";
|
||||
|
||||
export const MIN_VERSION = 3;
|
||||
export const CURRENT_VERSION = 44;
|
||||
export const CURRENT_VERSION = 54;
|
||||
export type MinVersion = typeof MIN_VERSION;
|
||||
|
||||
export function createMigrationBuilder() {
|
||||
@@ -94,7 +98,17 @@ export function createMigrationBuilder() {
|
||||
.with(EventCollectionMigrator, 40, 41)
|
||||
.with(EnableFaviconMigrator, 41, 42)
|
||||
.with(AutoConfirmFingerPrintsMigrator, 42, 43)
|
||||
.with(LocalDataMigrator, 43, CURRENT_VERSION);
|
||||
.with(UserDecryptionOptionsMigrator, 43, 44)
|
||||
.with(MergeEnvironmentState, 44, 45)
|
||||
.with(DeleteBiometricPromptCancelledData, 45, 46)
|
||||
.with(MoveDesktopSettingsMigrator, 46, 47)
|
||||
.with(MoveDdgToStateProviderMigrator, 47, 48)
|
||||
.with(AccountServerConfigMigrator, 48, 49)
|
||||
.with(KeyConnectorMigrator, 49, 50)
|
||||
.with(RememberedEmailMigrator, 50, 51)
|
||||
.with(DeleteInstalledVersion, 51, 52)
|
||||
.with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53)
|
||||
.with(LocalDataMigrator, 53, CURRENT_VERSION);
|
||||
}
|
||||
|
||||
export async function currentVersion(
|
||||
|
||||
@@ -83,35 +83,35 @@ describe("MigrationBuilder", () => {
|
||||
});
|
||||
|
||||
it("should migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock());
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general");
|
||||
const spy = jest.spyOn(migrator, "migrate");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
});
|
||||
|
||||
it("should rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock());
|
||||
const helper = new MigrationHelper(1, mock(), mock(), "general");
|
||||
const spy = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
});
|
||||
|
||||
it("should update version on migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock());
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general");
|
||||
const spy = jest.spyOn(migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "up");
|
||||
});
|
||||
|
||||
it("should update version on rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock());
|
||||
const helper = new MigrationHelper(1, mock(), mock(), "general");
|
||||
const spy = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "down");
|
||||
});
|
||||
|
||||
it("should not run the migrator if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock());
|
||||
const helper = new MigrationHelper(3, mock(), mock(), "general");
|
||||
const migrate = jest.spyOn(migrator, "migrate");
|
||||
const rollback = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
@@ -120,7 +120,7 @@ describe("MigrationBuilder", () => {
|
||||
});
|
||||
|
||||
it("should not update version if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock());
|
||||
const helper = new MigrationHelper(3, mock(), mock(), "general");
|
||||
const migrate = jest.spyOn(migrator, "updateVersion");
|
||||
const rollback = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
@@ -130,7 +130,7 @@ describe("MigrationBuilder", () => {
|
||||
});
|
||||
|
||||
it("should be able to call instance methods", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock());
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general");
|
||||
await sut.with(TestMigratorWithInstanceMethod, 0, 1).migrate(helper);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AbstractStorageService } from "../platform/abstractions/storage.service
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to generate unique strings for injection
|
||||
import { Utils } from "../platform/misc/utils";
|
||||
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
import { MigrationHelper, MigrationHelperType } from "./migration-helper";
|
||||
import { Migrator } from "./migrator";
|
||||
|
||||
const exampleJSON = {
|
||||
@@ -37,7 +37,7 @@ describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
storage = mock();
|
||||
storage.get.mockImplementation((key) => (exampleJSON as any)[key]);
|
||||
|
||||
sut = new MigrationHelper(0, storage, logService);
|
||||
sut = new MigrationHelper(0, storage, logService, "general");
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
@@ -150,6 +150,7 @@ describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
export function mockMigrationHelper(
|
||||
storageJson: any,
|
||||
stateVersion = 0,
|
||||
type: MigrationHelperType = "general",
|
||||
): MockProxy<MigrationHelper> {
|
||||
const logService: MockProxy<LogService> = mock();
|
||||
const storage: MockProxy<AbstractStorageService> = mock();
|
||||
@@ -157,7 +158,7 @@ export function mockMigrationHelper(
|
||||
storage.save.mockImplementation(async (key, value) => {
|
||||
(storageJson as any)[key] = value;
|
||||
});
|
||||
const helper = new MigrationHelper(stateVersion, storage, logService);
|
||||
const helper = new MigrationHelper(stateVersion, storage, logService, type);
|
||||
|
||||
const mockHelper = mock<MigrationHelper>();
|
||||
mockHelper.get.mockImplementation((key) => helper.get(key));
|
||||
@@ -175,15 +176,15 @@ export function mockMigrationHelper(
|
||||
helper.setToUser(userId, keyDefinition, value),
|
||||
);
|
||||
mockHelper.getAccounts.mockImplementation(() => helper.getAccounts());
|
||||
|
||||
mockHelper.type = helper.type;
|
||||
|
||||
return mockHelper;
|
||||
}
|
||||
|
||||
// TODO: Use const generic for TUsers in TypeScript 5.0 so consumers don't have to `as const` themselves
|
||||
export type InitialDataHint<TUsers extends readonly string[]> = {
|
||||
/**
|
||||
* A string array of the users id who are authenticated
|
||||
*
|
||||
* NOTE: It's recommended to as const this string array so you get type help defining the users data
|
||||
*/
|
||||
authenticatedAccounts?: TUsers;
|
||||
/**
|
||||
@@ -282,10 +283,9 @@ function expectInjectedData(
|
||||
* @param initalData The data to start with
|
||||
* @returns State after your migration has ran.
|
||||
*/
|
||||
// TODO: Use const generic for TUsers in TypeScript 5.0 so consumers don't have to `as const` themselves
|
||||
export async function runMigrator<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TUsers extends readonly string[] = string[],
|
||||
const TUsers extends readonly string[],
|
||||
>(
|
||||
migrator: TMigrator,
|
||||
initalData?: InitialDataHint<TUsers>,
|
||||
@@ -295,7 +295,7 @@ export async function runMigrator<
|
||||
const allInjectedData = injectData(initalData, []);
|
||||
|
||||
const fakeStorageService = new FakeStorageService(initalData);
|
||||
const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock());
|
||||
const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock(), "general");
|
||||
|
||||
// Run their migrations
|
||||
if (direction === "rollback") {
|
||||
|
||||
@@ -9,12 +9,29 @@ export type KeyDefinitionLike = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type MigrationHelperType = "general" | "web-disk-local";
|
||||
|
||||
export class MigrationHelper {
|
||||
constructor(
|
||||
public currentVersion: number,
|
||||
private storageService: AbstractStorageService,
|
||||
public logService: LogService,
|
||||
) {}
|
||||
type: MigrationHelperType,
|
||||
) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* On some clients, migrations are ran multiple times without direct action from the migration writer.
|
||||
*
|
||||
* All clients will run through migrations at least once, this run is referred to as `"general"`. If a migration is
|
||||
* ran more than that single time, they will get a unique name if that the write can make conditional logic based on which
|
||||
* migration run this is.
|
||||
*
|
||||
* @remarks The preferrable way of writing migrations is ALWAYS to be defensive and reflect on the data you are given back. This
|
||||
* should really only be used when reflecting on the data given isn't enough.
|
||||
*/
|
||||
type: MigrationHelperType;
|
||||
|
||||
/**
|
||||
* Gets a value from the storage service at the given key.
|
||||
|
||||
@@ -87,12 +87,12 @@ describe("VaultSettingsKeyMigrator", () => {
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" },
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" },
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ export class VaultSettingsKeyMigrator extends Migrator<35, 36> {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" },
|
||||
accountSettings.dontShowCardsCurrentTab,
|
||||
!accountSettings.dontShowCardsCurrentTab,
|
||||
);
|
||||
delete account.settings.dontShowCardsCurrentTab;
|
||||
updateAccount = true;
|
||||
@@ -40,7 +40,7 @@ export class VaultSettingsKeyMigrator extends Migrator<35, 36> {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" },
|
||||
accountSettings.dontShowIdentitiesCurrentTab,
|
||||
!accountSettings.dontShowIdentitiesCurrentTab,
|
||||
);
|
||||
delete account.settings.dontShowIdentitiesCurrentTab;
|
||||
updateAccount = true;
|
||||
|
||||
@@ -124,65 +124,107 @@ describe("TokenServiceStateProviderMigrator", () => {
|
||||
sut = new TokenServiceStateProviderMigrator(37, 38);
|
||||
});
|
||||
|
||||
it("should remove state service data from all accounts that have it", async () => {
|
||||
await sut.migrate(helper);
|
||||
describe("Session storage", () => {
|
||||
it("should remove state service data from all accounts that have it", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("user1", {
|
||||
tokens: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
profile: {
|
||||
email: "user1Email",
|
||||
otherStuff: "overStuff3",
|
||||
},
|
||||
keys: {
|
||||
otherStuff: "overStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
expect(helper.set).toHaveBeenCalledWith("user1", {
|
||||
tokens: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
profile: {
|
||||
email: "user1Email",
|
||||
otherStuff: "overStuff3",
|
||||
},
|
||||
keys: {
|
||||
otherStuff: "overStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
|
||||
});
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
|
||||
it("should migrate data to state providers for defined accounts that have the data", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
// Two factor Token Migration
|
||||
expect(helper.setToGlobal).toHaveBeenLastCalledWith(
|
||||
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
|
||||
{
|
||||
user1Email: "twoFactorToken",
|
||||
user2Email: "twoFactorToken",
|
||||
},
|
||||
);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, "accessToken");
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, "refreshToken");
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user1",
|
||||
API_KEY_CLIENT_ID_DISK,
|
||||
"apiKeyClientId",
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user1",
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
"apiKeyClientSecret",
|
||||
);
|
||||
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith(
|
||||
"user2",
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
any(),
|
||||
);
|
||||
|
||||
// Expect that we didn't migrate anything to user 3
|
||||
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith(
|
||||
"user3",
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
any(),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("Local storage", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(preMigrationJson(), 37, "web-disk-local");
|
||||
});
|
||||
it("should remove state service data from all accounts that have it", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
it("should migrate data to state providers for defined accounts that have the data", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user1", {
|
||||
tokens: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
profile: {
|
||||
email: "user1Email",
|
||||
otherStuff: "overStuff3",
|
||||
},
|
||||
keys: {
|
||||
otherStuff: "overStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
|
||||
// Two factor Token Migration
|
||||
expect(helper.setToGlobal).toHaveBeenLastCalledWith(
|
||||
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
|
||||
{
|
||||
user1Email: "twoFactorToken",
|
||||
user2Email: "twoFactorToken",
|
||||
},
|
||||
);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
|
||||
});
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, "accessToken");
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, "refreshToken");
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user1",
|
||||
API_KEY_CLIENT_ID_DISK,
|
||||
"apiKeyClientId",
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user1",
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
"apiKeyClientSecret",
|
||||
);
|
||||
it("should not migrate any data to local storage", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_SECRET_DISK, any());
|
||||
|
||||
// Expect that we didn't migrate anything to user 3
|
||||
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_SECRET_DISK, any());
|
||||
expect(helper.setToUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -84,7 +84,10 @@ export class TokenServiceStateProviderMigrator extends Migrator<37, 38> {
|
||||
|
||||
if (existingAccessToken != null) {
|
||||
// Only migrate data that exists
|
||||
await helper.setToUser(userId, ACCESS_TOKEN_DISK, existingAccessToken);
|
||||
if (helper.type !== "web-disk-local") {
|
||||
// only migrate access token to session storage - never local.
|
||||
await helper.setToUser(userId, ACCESS_TOKEN_DISK, existingAccessToken);
|
||||
}
|
||||
delete account.tokens.accessToken;
|
||||
updatedAccount = true;
|
||||
}
|
||||
@@ -93,7 +96,10 @@ export class TokenServiceStateProviderMigrator extends Migrator<37, 38> {
|
||||
const existingRefreshToken = account?.tokens?.refreshToken;
|
||||
|
||||
if (existingRefreshToken != null) {
|
||||
await helper.setToUser(userId, REFRESH_TOKEN_DISK, existingRefreshToken);
|
||||
if (helper.type !== "web-disk-local") {
|
||||
// only migrate refresh token to session storage - never local.
|
||||
await helper.setToUser(userId, REFRESH_TOKEN_DISK, existingRefreshToken);
|
||||
}
|
||||
delete account.tokens.refreshToken;
|
||||
updatedAccount = true;
|
||||
}
|
||||
@@ -102,7 +108,10 @@ export class TokenServiceStateProviderMigrator extends Migrator<37, 38> {
|
||||
const existingApiKeyClientId = account?.profile?.apiKeyClientId;
|
||||
|
||||
if (existingApiKeyClientId != null) {
|
||||
await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, existingApiKeyClientId);
|
||||
if (helper.type !== "web-disk-local") {
|
||||
// only migrate client id to session storage - never local.
|
||||
await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, existingApiKeyClientId);
|
||||
}
|
||||
delete account.profile.apiKeyClientId;
|
||||
updatedAccount = true;
|
||||
}
|
||||
@@ -110,7 +119,10 @@ export class TokenServiceStateProviderMigrator extends Migrator<37, 38> {
|
||||
// Migrate API key client secret
|
||||
const existingApiKeyClientSecret = account?.keys?.apiKeyClientSecret;
|
||||
if (existingApiKeyClientSecret != null) {
|
||||
await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, existingApiKeyClientSecret);
|
||||
if (helper.type !== "web-disk-local") {
|
||||
// only migrate client secret to session storage - never local.
|
||||
await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, existingApiKeyClientSecret);
|
||||
}
|
||||
delete account.keys.apiKeyClientSecret;
|
||||
updatedAccount = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { any, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { UserDecryptionOptionsMigrator } from "./44-move-user-decryption-options-to-state-provider";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"],
|
||||
FirstAccount: {
|
||||
decryptionOptions: {
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: false,
|
||||
hasLoginApprovingDevice: false,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://keyconnector.bitwarden.com",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
SecondAccount: {
|
||||
decryptionOptions: {
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: true,
|
||||
hasLoginApprovingDevice: true,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://selfhosted.bitwarden.com",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
user_FirstAccount_decryptionOptions_userDecryptionOptions: {
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: false,
|
||||
hasLoginApprovingDevice: false,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://keyconnector.bitwarden.com",
|
||||
},
|
||||
},
|
||||
user_SecondAccount_decryptionOptions_userDecryptionOptions: {
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: true,
|
||||
hasLoginApprovingDevice: true,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://selfhosted.bitwarden.com",
|
||||
},
|
||||
},
|
||||
user_ThirdAccount_decryptionOptions_userDecryptionOptions: {},
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"],
|
||||
FirstAccount: {
|
||||
decryptionOptions: {
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: false,
|
||||
hasLoginApprovingDevice: false,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://keyconnector.bitwarden.com",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
SecondAccount: {
|
||||
decryptionOptions: {
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: true,
|
||||
hasLoginApprovingDevice: true,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://selfhosted.bitwarden.com",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("UserDecryptionOptionsMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: UserDecryptionOptionsMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "decryptionOptions",
|
||||
stateDefinition: {
|
||||
name: "userDecryptionOptions",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 43);
|
||||
sut = new UserDecryptionOptionsMigrator(43, 44);
|
||||
});
|
||||
|
||||
it("should remove decryptionOptions from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
|
||||
profile: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set decryptionOptions provider value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", keyDefinitionLike, {
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: false,
|
||||
hasLoginApprovingDevice: false,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://keyconnector.bitwarden.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("SecondAccount", keyDefinitionLike, {
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: true,
|
||||
hasLoginApprovingDevice: true,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://selfhosted.bitwarden.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 44);
|
||||
sut = new UserDecryptionOptionsMigrator(43, 44);
|
||||
});
|
||||
|
||||
it.each(["FirstAccount", "SecondAccount", "ThirdAccount"])(
|
||||
"should null out new values",
|
||||
async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
},
|
||||
);
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
|
||||
decryptionOptions: {
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: false,
|
||||
hasLoginApprovingDevice: false,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://keyconnector.bitwarden.com",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
|
||||
decryptionOptions: {
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: true,
|
||||
hasLoginApprovingDevice: true,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://selfhosted.bitwarden.com",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("ThirdAccount", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user