Ps/pm 2910/add browser storage services (#6849)

* Allow for update logic in state update callbacks

* Prefer reading updates to sending in stream

* Inform state providers when they must deserialize

* Update DefaultGlobalState to act more like DefaultUserState

* Fully Implement AbstractStorageService

* Add KeyDefinitionOptions

* Address PR feedback

* Prefer testing interactions for ports

* Synced memory storage for browser

* Fix port handling

* Do not stringify port message data

* Use messaging storage

* Initialize new foreground memory storage services

This will need to be rethought for short-lived background pages, but for
now the background is the source of truth for memory storage

* Use global state for account service

* Use BrowserApi listener to avoid safari memory leaks

* Fix build errors: debugging and missed impls

* Prefer bound arrow functions

* JSON Stringify Messages

* Prefer `useClass`

* Use noop services

* extract storage observable to new interface

This also reverts changes for the existing services to use
foreground/background services. Those are now used only in state
providers

* Fix web DI

* Prefer initializing observable in constructor

* Do not use jsonify as equality operator

* Remove port listener to avoid memory leaks

* Fix logic and type issues

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
Matt Gibson 2023-11-21 16:35:37 -05:00 committed by GitHub
parent 1ecf019397
commit 24c240d0d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 744 additions and 160 deletions

View File

@ -6,6 +6,10 @@ import {
CachedServices, CachedServices,
factory, factory,
} from "../../../platform/background/service-factories/factory-options"; } from "../../../platform/background/service-factories/factory-options";
import {
GlobalStateProviderInitOptions,
globalStateProviderFactory,
} from "../../../platform/background/service-factories/global-state-provider.factory";
import { import {
LogServiceInitOptions, LogServiceInitOptions,
logServiceFactory, logServiceFactory,
@ -19,7 +23,8 @@ type AccountServiceFactoryOptions = FactoryOptions;
export type AccountServiceInitOptions = AccountServiceFactoryOptions & export type AccountServiceInitOptions = AccountServiceFactoryOptions &
MessagingServiceInitOptions & MessagingServiceInitOptions &
LogServiceInitOptions; LogServiceInitOptions &
GlobalStateProviderInitOptions;
export function accountServiceFactory( export function accountServiceFactory(
cache: { accountService?: AccountService } & CachedServices, cache: { accountService?: AccountService } & CachedServices,
@ -32,7 +37,8 @@ export function accountServiceFactory(
async () => async () =>
new AccountServiceImplementation( new AccountServiceImplementation(
await messagingServiceFactory(cache, opts), await messagingServiceFactory(cache, opts),
await logServiceFactory(cache, opts) await logServiceFactory(cache, opts),
await globalStateProviderFactory(cache, opts)
) )
); );
} }

View File

@ -60,9 +60,11 @@ import { ContainerService } from "@bitwarden/common/platform/services/container.
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation"; import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { ApiService } from "@bitwarden/common/services/api.service"; import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service"; import { AuditService } from "@bitwarden/common/services/audit.service";
@ -145,6 +147,7 @@ import BrowserPlatformUtilsService from "../platform/services/browser-platform-u
import { BrowserStateService } from "../platform/services/browser-state.service"; import { BrowserStateService } from "../platform/services/browser-state.service";
import { KeyGenerationService } from "../platform/services/key-generation.service"; import { KeyGenerationService } from "../platform/services/key-generation.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
import { BrowserSendService } from "../services/browser-send.service"; import { BrowserSendService } from "../services/browser-send.service";
import { BrowserSettingsService } from "../services/browser-settings.service"; import { BrowserSettingsService } from "../services/browser-settings.service";
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
@ -225,6 +228,7 @@ export default class MainBackground {
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction;
authRequestCryptoService: AuthRequestCryptoServiceAbstraction; authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
accountService: AccountServiceAbstraction; accountService: AccountServiceAbstraction;
globalStateProvider: GlobalStateProvider;
// Passed to the popup for Safari to workaround issues with theming, downloading, etc. // Passed to the popup for Safari to workaround issues with theming, downloading, etc.
backgroundWindow = window; backgroundWindow = window;
@ -279,8 +283,16 @@ export default class MainBackground {
new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false),
new KeyGenerationService(this.cryptoFunctionService) new KeyGenerationService(this.cryptoFunctionService)
) )
: new MemoryStorageService(); : new BackgroundMemoryStorageService();
this.accountService = new AccountServiceImplementation(this.messagingService, this.logService); this.globalStateProvider = new DefaultGlobalStateProvider(
this.memoryStorageService as BackgroundMemoryStorageService,
this.storageService as BrowserLocalStorageService
);
this.accountService = new AccountServiceImplementation(
this.messagingService,
this.logService,
this.globalStateProvider
);
this.stateService = new BrowserStateService( this.stateService = new BrowserStateService(
this.storageService, this.storageService,
this.secureStorageService, this.secureStorageService,

View File

@ -0,0 +1,33 @@
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
DiskStorageServiceInitOptions,
MemoryStorageServiceInitOptions,
observableDiskStorageServiceFactory,
observableMemoryStorageServiceFactory,
} from "./storage-service.factory";
type GlobalStateProviderFactoryOptions = FactoryOptions;
export type GlobalStateProviderInitOptions = GlobalStateProviderFactoryOptions &
MemoryStorageServiceInitOptions &
DiskStorageServiceInitOptions;
export async function globalStateProviderFactory(
cache: { globalStateProvider?: GlobalStateProvider } & CachedServices,
opts: GlobalStateProviderInitOptions
): Promise<GlobalStateProvider> {
return factory(
cache,
"globalStateProvider",
opts,
async () =>
new DefaultGlobalStateProvider(
await observableMemoryStorageServiceFactory(cache, opts),
await observableDiskStorageServiceFactory(cache, opts)
)
);
}

View File

@ -1,12 +1,14 @@
import { import {
AbstractMemoryStorageService, AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { BrowserApi } from "../../browser/browser-api"; import { BrowserApi } from "../../browser/browser-api";
import BrowserLocalStorageService from "../../services/browser-local-storage.service"; import BrowserLocalStorageService from "../../services/browser-local-storage.service";
import { LocalBackedSessionStorageService } from "../../services/local-backed-session-storage.service"; import { LocalBackedSessionStorageService } from "../../services/local-backed-session-storage.service";
import { BackgroundMemoryStorageService } from "../../storage/background-memory-storage.service";
import { EncryptServiceInitOptions, encryptServiceFactory } from "./encrypt-service.factory"; import { EncryptServiceInitOptions, encryptServiceFactory } from "./encrypt-service.factory";
import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { CachedServices, factory, FactoryOptions } from "./factory-options";
@ -29,6 +31,14 @@ export function diskStorageServiceFactory(
): Promise<AbstractStorageService> { ): Promise<AbstractStorageService> {
return factory(cache, "diskStorageService", opts, () => new BrowserLocalStorageService()); return factory(cache, "diskStorageService", opts, () => new BrowserLocalStorageService());
} }
export function observableDiskStorageServiceFactory(
cache: {
diskStorageService?: AbstractStorageService & ObservableStorageService;
} & CachedServices,
opts: DiskStorageServiceInitOptions
): Promise<AbstractStorageService & ObservableStorageService> {
return factory(cache, "diskStorageService", opts, () => new BrowserLocalStorageService());
}
export function secureStorageServiceFactory( export function secureStorageServiceFactory(
cache: { secureStorageService?: AbstractStorageService } & CachedServices, cache: { secureStorageService?: AbstractStorageService } & CachedServices,
@ -51,3 +61,14 @@ export function memoryStorageServiceFactory(
return new MemoryStorageService(); return new MemoryStorageService();
}); });
} }
export function observableMemoryStorageServiceFactory(
cache: {
memoryStorageService?: AbstractMemoryStorageService & ObservableStorageService;
} & CachedServices,
opts: MemoryStorageServiceInitOptions
): Promise<AbstractMemoryStorageService & ObservableStorageService> {
return factory(cache, "memoryStorageService", opts, async () => {
return new BackgroundMemoryStorageService();
});
}

View File

@ -1,21 +1,20 @@
import { Observable, mergeMap } from "rxjs"; import { mergeMap } from "rxjs";
import { import {
AbstractStorageService, AbstractStorageService,
StorageUpdate, ObservableStorageService,
StorageUpdateType, StorageUpdateType,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
import { fromChromeEvent } from "../../browser/from-chrome-event"; import { fromChromeEvent } from "../../browser/from-chrome-event";
export default abstract class AbstractChromeStorageService implements AbstractStorageService { export default abstract class AbstractChromeStorageService
constructor(protected chromeStorageApi: chrome.storage.StorageArea) {} implements AbstractStorageService, ObservableStorageService
{
updates$;
get valuesRequireDeserialization(): boolean { constructor(protected chromeStorageApi: chrome.storage.StorageArea) {
return true; this.updates$ = fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
}
get updates$(): Observable<StorageUpdate> {
return fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
mergeMap(([changes]) => { mergeMap(([changes]) => {
return Object.entries(changes).map(([key, change]) => { return Object.entries(changes).map(([key, change]) => {
// The `newValue` property isn't on the StorageChange object // The `newValue` property isn't on the StorageChange object
@ -37,6 +36,10 @@ export default abstract class AbstractChromeStorageService implements AbstractSt
); );
} }
get valuesRequireDeserialization(): boolean {
return true;
}
async get<T>(key: string): Promise<T> { async get<T>(key: string): Promise<T> {
return new Promise((resolve) => { return new Promise((resolve) => {
this.chromeStorageApi.get(key, (obj: any) => { this.chromeStorageApi.get(key, (obj: any) => {

View File

@ -27,22 +27,20 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi
private localStorage = new BrowserLocalStorageService(); private localStorage = new BrowserLocalStorageService();
private sessionStorage = new BrowserMemoryStorageService(); private sessionStorage = new BrowserMemoryStorageService();
private updatesSubject = new Subject<StorageUpdate>(); private updatesSubject = new Subject<StorageUpdate>();
updates$;
constructor( constructor(
private encryptService: EncryptService, private encryptService: EncryptService,
private keyGenerationService: AbstractKeyGenerationService private keyGenerationService: AbstractKeyGenerationService
) { ) {
super(); super();
this.updates$ = this.updatesSubject.asObservable();
} }
get valuesRequireDeserialization(): boolean { get valuesRequireDeserialization(): boolean {
return true; return true;
} }
get updates$() {
return this.updatesSubject.asObservable();
}
async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> { async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
if (this.cache.has(key)) { if (this.cache.has(key)) {
return this.cache.get(key) as T; return this.cache.get(key) as T;

View File

@ -0,0 +1,78 @@
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { BrowserApi } from "../browser/browser-api";
import { MemoryStoragePortMessage } from "./port-messages";
import { portName } from "./port-name";
export class BackgroundMemoryStorageService extends MemoryStorageService {
private _ports: chrome.runtime.Port[] = [];
constructor() {
super();
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
if (port.name !== portName(chrome.storage.session)) {
return;
}
this._ports.push(port);
const listenerCallback = this.onMessageFromForeground.bind(this);
port.onDisconnect.addListener(() => {
this._ports.splice(this._ports.indexOf(port), 1);
port.onMessage.removeListener(listenerCallback);
});
port.onMessage.addListener(listenerCallback);
// Initialize the new memory storage service with existing data
this.sendMessage({
action: "initialization",
data: Array.from(this.store.keys()),
});
});
this.updates$.subscribe((update) => {
this.sendMessage({
action: "subject_update",
data: update,
});
});
}
private async onMessageFromForeground(message: MemoryStoragePortMessage) {
if (message.originator === "background") {
return;
}
let result: unknown = null;
switch (message.action) {
case "get":
case "getBypassCache":
case "has": {
result = await this[message.action](message.key);
break;
}
case "save":
await this.save(message.key, JSON.parse(message.data as string) as unknown);
break;
case "remove":
await this.remove(message.key);
break;
}
this.sendMessage({
id: message.id,
key: message.key,
data: JSON.stringify(result),
});
}
private async sendMessage(data: Omit<MemoryStoragePortMessage, "originator">) {
this._ports.forEach((port) => {
port.postMessage({
...data,
originator: "background",
});
});
}
}

View File

@ -0,0 +1,113 @@
import { Observable, Subject, filter, firstValueFrom, map } from "rxjs";
import {
AbstractMemoryStorageService,
StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { fromChromeEvent } from "../browser/from-chrome-event";
import { MemoryStoragePortMessage } from "./port-messages";
import { portName } from "./port-name";
export class ForegroundMemoryStorageService extends AbstractMemoryStorageService {
private _port: chrome.runtime.Port;
private _backgroundResponses$: Observable<MemoryStoragePortMessage>;
private updatesSubject = new Subject<StorageUpdate>();
get valuesRequireDeserialization(): boolean {
return true;
}
updates$;
constructor() {
super();
this.updates$ = this.updatesSubject.asObservable();
this._port = chrome.runtime.connect({ name: portName(chrome.storage.session) });
this._backgroundResponses$ = fromChromeEvent(this._port.onMessage).pipe(
map(([message]) => message),
filter((message) => message.originator === "background")
);
this._backgroundResponses$
.pipe(
filter(
(message) => message.action === "subject_update" || message.action === "initialization"
)
)
.subscribe((message) => {
switch (message.action) {
case "initialization":
this.handleInitialize(message.data as string[]); // Map entries as array
break;
case "subject_update":
this.handleSubjectUpdate(message.data as StorageUpdate);
break;
default:
throw new Error(`Unknown action: ${message.action}`);
}
});
}
async get<T>(key: string): Promise<T> {
return await this.delegateToBackground<T>("get", key);
}
async getBypassCache<T>(key: string): Promise<T> {
return await this.delegateToBackground<T>("getBypassCache", key);
}
async has(key: string): Promise<boolean> {
return await this.delegateToBackground<boolean>("has", key);
}
async save<T>(key: string, obj: T): Promise<void> {
await this.delegateToBackground<T>("save", key, obj);
}
async remove(key: string): Promise<void> {
await this.delegateToBackground<void>("remove", key);
}
private async delegateToBackground<T>(
action: MemoryStoragePortMessage["action"],
key: string,
data?: T
): Promise<T> {
const id = Utils.newGuid();
// listen for response before request
const response = firstValueFrom(
this._backgroundResponses$.pipe(
filter((message) => message.id === id),
map((message) => JSON.parse(message.data as string) as T)
)
);
this.sendMessage({
id: id,
key: key,
action: action,
data: JSON.stringify(data),
});
const result = await response;
return result;
}
private sendMessage(data: Omit<MemoryStoragePortMessage, "originator">) {
this._port.postMessage({
...data,
originator: "foreground",
});
}
private handleInitialize(data: string[]) {
// TODO: this isn't a save, but we don't have a better indicator for this
data.forEach((key) => {
this.updatesSubject.next({ key, updateType: "save" });
});
}
private handleSubjectUpdate(data: StorageUpdate) {
this.updatesSubject.next(data);
}
}

View File

@ -0,0 +1,61 @@
import { trackEmissions } from "@bitwarden/common/../spec/utils";
import { BackgroundMemoryStorageService } from "./background-memory-storage.service";
import { ForegroundMemoryStorageService } from "./foreground-memory-storage.service";
import { mockPort } from "./mock-port.spec-util";
import { portName } from "./port-name";
describe("foreground background memory storage interaction", () => {
let foreground: ForegroundMemoryStorageService;
let background: BackgroundMemoryStorageService;
beforeEach(() => {
mockPort(portName(chrome.storage.session));
background = new BackgroundMemoryStorageService();
foreground = new ForegroundMemoryStorageService();
});
afterEach(() => {
jest.resetAllMocks();
});
test.each(["has", "get", "getBypassCache"])(
"background should respond with the correct value for %s",
async (action: "get" | "has" | "getBypassCache") => {
const key = "key";
const value = "value";
background[action] = jest.fn().mockResolvedValue(value);
const result = await foreground[action](key);
expect(result).toEqual(value);
}
);
test("background should call save from foreground", async () => {
const key = "key";
const value = "value";
const actionSpy = jest.spyOn(background, "save");
await foreground.save(key, value);
expect(actionSpy).toHaveBeenCalledWith(key, value);
});
test("background should call remove from foreground", async () => {
const key = "key";
const actionSpy = jest.spyOn(background, "remove");
await foreground.remove(key);
expect(actionSpy).toHaveBeenCalledWith(key);
});
test("background updates push to foreground", async () => {
const key = "key";
const value = "value";
const updateType = "save";
const emissions = trackEmissions(foreground.updates$);
await background.save(key, value);
expect(emissions).toEqual([{ key, updateType }]);
});
});

View File

@ -0,0 +1,25 @@
import { mockDeep } from "jest-mock-extended";
/**
* Mocks a chrome.runtime.Port set up to send messages through `postMessage` to `onMessage.addListener` callbacks.
* @returns a mock chrome.runtime.Port
*/
export function mockPort(name: string) {
const port = mockDeep<chrome.runtime.Port>();
// notify listeners of a new port
(chrome.runtime.connect as jest.Mock).mockImplementation((portInfo) => {
port.name = portInfo.name;
(chrome.runtime.onConnect.addListener as jest.Mock).mock.calls.forEach(([callbackFn]) => {
callbackFn(port);
});
return port;
});
// set message broadcast
(port.postMessage as jest.Mock).mockImplementation((message) => {
(port.onMessage.addListener as jest.Mock).mock.calls.forEach(([callbackFn]) => {
callbackFn(message);
});
});
return port;
}

View File

@ -0,0 +1,20 @@
import {
AbstractMemoryStorageService,
StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service";
type MemoryStoragePortMessage = {
id?: string;
key?: string;
/**
* We allow sending a string[] array since it is JSON safe and StorageUpdate since it's
* a simple object with just two properties that are strings. Everything else is expected to
* be JSON-ified.
*/
data: string | string[] | StorageUpdate;
originator: "foreground" | "background";
action?:
| keyof Pick<AbstractMemoryStorageService, "get" | "getBypassCache" | "has" | "save" | "remove">
| "subject_update"
| "initialization";
};

View File

@ -0,0 +1,12 @@
export function portName(storageLocation: chrome.storage.StorageArea) {
switch (storageLocation) {
case chrome.storage.local:
return "local";
case chrome.storage.sync:
return "sync";
case chrome.storage.session:
return "session";
default:
throw new Error("Unknown storage location");
}
}

View File

@ -1,7 +1,12 @@
import { APP_INITIALIZER, LOCALE_ID, NgModule } from "@angular/core"; import { APP_INITIALIZER, LOCALE_ID, NgModule } from "@angular/core";
import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards";
import { MEMORY_STORAGE, SECURE_STORAGE } from "@bitwarden/angular/services/injection-tokens"; import {
MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
SECURE_STORAGE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { ThemingService } from "@bitwarden/angular/services/theming/theming.service"; import { ThemingService } from "@bitwarden/angular/services/theming/theming.service";
import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction";
@ -100,9 +105,11 @@ import { BrowserConfigService } from "../../platform/services/browser-config.ser
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service"; import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service";
import { BrowserI18nService } from "../../platform/services/browser-i18n.service"; import { BrowserI18nService } from "../../platform/services/browser-i18n.service";
import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service";
import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service"; import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service";
import BrowserMessagingService from "../../platform/services/browser-messaging.service"; import BrowserMessagingService from "../../platform/services/browser-messaging.service";
import { BrowserStateService } from "../../platform/services/browser-state.service"; import { BrowserStateService } from "../../platform/services/browser-state.service";
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
import { BrowserSendService } from "../../services/browser-send.service"; import { BrowserSendService } from "../../services/browser-send.service";
import { BrowserSettingsService } from "../../services/browser-settings.service"; import { BrowserSettingsService } from "../../services/browser-settings.service";
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
@ -361,7 +368,7 @@ function getBgService<T>(service: keyof MainBackground) {
}, },
{ {
provide: AbstractStorageService, provide: AbstractStorageService,
useFactory: getBgService<AbstractStorageService>("storageService"), useClass: BrowserLocalStorageService,
deps: [], deps: [],
}, },
{ provide: AppIdService, useFactory: getBgService<AppIdService>("appIdService"), deps: [] }, { provide: AppIdService, useFactory: getBgService<AppIdService>("appIdService"), deps: [] },
@ -444,12 +451,20 @@ function getBgService<T>(service: keyof MainBackground) {
{ {
provide: SECURE_STORAGE, provide: SECURE_STORAGE,
useFactory: getBgService<AbstractStorageService>("secureStorageService"), useFactory: getBgService<AbstractStorageService>("secureStorageService"),
deps: [],
}, },
{ {
provide: MEMORY_STORAGE, provide: MEMORY_STORAGE,
useFactory: getBgService<AbstractStorageService>("memoryStorageService"), useFactory: getBgService<AbstractStorageService>("memoryStorageService"),
}, },
{
provide: OBSERVABLE_MEMORY_STORAGE,
useClass: ForegroundMemoryStorageService,
deps: [],
},
{
provide: OBSERVABLE_DISK_STORAGE,
useExisting: AbstractStorageService,
},
{ {
provide: StateServiceAbstraction, provide: StateServiceAbstraction,
useFactory: ( useFactory: (

View File

@ -25,6 +25,7 @@ const runtime = {
sendMessage: jest.fn(), sendMessage: jest.fn(),
getManifest: jest.fn(), getManifest: jest.fn(),
getURL: jest.fn((path) => `chrome-extension://id/${path}`), getURL: jest.fn((path) => `chrome-extension://id/${path}`),
connect: jest.fn(),
onConnect: { onConnect: {
addListener: jest.fn(), addListener: jest.fn(),
}, },

View File

@ -45,6 +45,9 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service";
import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StateService } from "@bitwarden/common/platform/services/state.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { AuditService } from "@bitwarden/common/services/audit.service"; import { AuditService } from "@bitwarden/common/services/audit.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
@ -161,6 +164,7 @@ export class Main {
configApiService: ConfigApiServiceAbstraction; configApiService: ConfigApiServiceAbstraction;
configService: CliConfigService; configService: CliConfigService;
accountService: AccountService; accountService: AccountService;
globalStateProvider: GlobalStateProvider;
constructor() { constructor() {
let p = null; let p = null;
@ -200,7 +204,18 @@ export class Main {
this.memoryStorageService = new MemoryStorageService(); this.memoryStorageService = new MemoryStorageService();
this.accountService = new AccountServiceImplementation(null, this.logService); this.globalStateProvider = new DefaultGlobalStateProvider(
this.memoryStorageService,
this.storageService
);
this.messagingService = new NoopMessagingService();
this.accountService = new AccountServiceImplementation(
this.messagingService,
this.logService,
this.globalStateProvider
);
this.stateService = new StateService( this.stateService = new StateService(
this.storageService, this.storageService,
@ -221,7 +236,6 @@ export class Main {
this.appIdService = new AppIdService(this.storageService); this.appIdService = new AppIdService(this.storageService);
this.tokenService = new TokenService(this.stateService); this.tokenService = new TokenService(this.stateService);
this.messagingService = new NoopMessagingService();
this.environmentService = new EnvironmentService(this.stateService); this.environmentService = new EnvironmentService(this.stateService);
const customUserAgent = const customUserAgent =

View File

@ -29,6 +29,7 @@ export class LowdbStorageService implements AbstractStorageService {
private defaults: any; private defaults: any;
private ready = false; private ready = false;
private updatesSubject = new Subject<StorageUpdate>(); private updatesSubject = new Subject<StorageUpdate>();
updates$;
constructor( constructor(
protected logService: LogService, protected logService: LogService,
@ -38,6 +39,7 @@ export class LowdbStorageService implements AbstractStorageService {
private requireLock = false private requireLock = false
) { ) {
this.defaults = defaults; this.defaults = defaults;
this.updates$ = this.updatesSubject.asObservable();
} }
@sequentialize(() => "lowdbStorageInit") @sequentialize(() => "lowdbStorageInit")
@ -110,9 +112,6 @@ export class LowdbStorageService implements AbstractStorageService {
get valuesRequireDeserialization(): boolean { get valuesRequireDeserialization(): boolean {
return true; return true;
} }
get updates$() {
return this.updatesSubject.asObservable();
}
async get<T>(key: string): Promise<T> { async get<T>(key: string): Promise<T> {
await this.waitForReady(); await this.waitForReady();

View File

@ -7,6 +7,8 @@ import {
LOCALES_DIRECTORY, LOCALES_DIRECTORY,
SYSTEM_LANGUAGE, SYSTEM_LANGUAGE,
MEMORY_STORAGE, MEMORY_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
} from "@bitwarden/angular/services/injection-tokens"; } from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction";
@ -102,6 +104,8 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
{ provide: AbstractStorageService, useClass: ElectronRendererStorageService }, { provide: AbstractStorageService, useClass: ElectronRendererStorageService },
{ provide: SECURE_STORAGE, useClass: ElectronRendererSecureStorageService }, { provide: SECURE_STORAGE, useClass: ElectronRendererSecureStorageService },
{ provide: MEMORY_STORAGE, useClass: MemoryStorageService }, { provide: MEMORY_STORAGE, useClass: MemoryStorageService },
{ provide: OBSERVABLE_MEMORY_STORAGE, useExisting: MEMORY_STORAGE },
{ provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService },
{ {
provide: SystemServiceAbstraction, provide: SystemServiceAbstraction,
useClass: SystemService, useClass: SystemService,

View File

@ -6,6 +6,9 @@ import { AccountServiceImplementation } from "@bitwarden/common/auth/services/ac
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service";
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { MenuMain } from "./main/menu/menu.main"; import { MenuMain } from "./main/menu/menu.main";
import { MessagingMain } from "./main/messaging.main"; import { MessagingMain } from "./main/messaging.main";
@ -85,6 +88,10 @@ export class Main {
storageDefaults["global.vaultTimeoutAction"] = "lock"; storageDefaults["global.vaultTimeoutAction"] = "lock";
this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults); this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults);
this.memoryStorageService = new MemoryStorageService(); this.memoryStorageService = new MemoryStorageService();
const globalStateProvider = new DefaultGlobalStateProvider(
this.memoryStorageService,
this.storageService
);
// TODO: this state service will have access to on disk storage, but not in memory storage. // TODO: this state service will have access to on disk storage, but not in memory storage.
// If we could get this to work using the stateService singleton that the rest of the app uses we could save // If we could get this to work using the stateService singleton that the rest of the app uses we could save
@ -95,7 +102,11 @@ export class Main {
this.memoryStorageService, this.memoryStorageService,
this.logService, this.logService,
new StateFactory(GlobalState, Account), new StateFactory(GlobalState, Account),
new AccountServiceImplementation(null, this.logService), // will not broadcast logouts. This is a hack until we can remove messaging dependency new AccountServiceImplementation(
new NoopMessagingService(),
this.logService,
globalStateProvider
), // will not broadcast logouts. This is a hack until we can remove messaging dependency
false // Do not use disk caching because this will get out of sync with the renderer service false // Do not use disk caching because this will get out of sync with the renderer service
); );

View File

@ -11,8 +11,10 @@ export class ElectronRendererStorageService implements AbstractStorageService {
get valuesRequireDeserialization(): boolean { get valuesRequireDeserialization(): boolean {
return true; return true;
} }
get updates$() { updates$;
return this.updatesSubject.asObservable();
constructor() {
this.updates$ = this.updatesSubject.asObservable();
} }
get<T>(key: string): Promise<T> { get<T>(key: string): Promise<T> {

View File

@ -40,6 +40,7 @@ type Options = BaseOptions<"get"> | BaseOptions<"has"> | SaveOptions | BaseOptio
export class ElectronStorageService implements AbstractStorageService { export class ElectronStorageService implements AbstractStorageService {
private store: ElectronStore; private store: ElectronStore;
private updatesSubject = new Subject<StorageUpdate>(); private updatesSubject = new Subject<StorageUpdate>();
updates$;
constructor(dir: string, defaults = {}) { constructor(dir: string, defaults = {}) {
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
@ -50,6 +51,7 @@ export class ElectronStorageService implements AbstractStorageService {
name: "data", name: "data",
}; };
this.store = new Store(storeConfig); this.store = new Store(storeConfig);
this.updates$ = this.updatesSubject.asObservable();
ipcMain.handle("storageService", (event, options: Options) => { ipcMain.handle("storageService", (event, options: Options) => {
switch (options.action) { switch (options.action) {
@ -68,9 +70,6 @@ export class ElectronStorageService implements AbstractStorageService {
get valuesRequireDeserialization(): boolean { get valuesRequireDeserialization(): boolean {
return true; return true;
} }
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string): Promise<T> { get<T>(key: string): Promise<T> {
const val = this.store.get(key) as T; const val = this.store.get(key) as T;

View File

@ -8,6 +8,8 @@ import {
LOCALES_DIRECTORY, LOCALES_DIRECTORY,
SYSTEM_LANGUAGE, SYSTEM_LANGUAGE,
MEMORY_STORAGE, MEMORY_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
} from "@bitwarden/angular/services/injection-tokens"; } from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
@ -74,6 +76,8 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service";
provide: MEMORY_STORAGE, provide: MEMORY_STORAGE,
useClass: MemoryStorageService, useClass: MemoryStorageService,
}, },
{ provide: OBSERVABLE_MEMORY_STORAGE, useExisting: MEMORY_STORAGE },
{ provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService },
{ {
provide: PlatformUtilsServiceAbstraction, provide: PlatformUtilsServiceAbstraction,
useClass: WebPlatformUtilsService, useClass: WebPlatformUtilsService,

View File

@ -19,8 +19,10 @@ export class HtmlStorageService implements AbstractStorageService {
get valuesRequireDeserialization(): boolean { get valuesRequireDeserialization(): boolean {
return true; return true;
} }
get updates$() { updates$;
return this.updatesSubject.asObservable();
constructor() {
this.updates$ = this.updatesSubject.asObservable();
} }
get<T>(key: string, options: StorageOptions = this.defaultOptions): Promise<T> { get<T>(key: string, options: StorageOptions = this.defaultOptions): Promise<T> {

View File

@ -3,10 +3,17 @@ import { InjectionToken } from "@angular/core";
import { import {
AbstractMemoryStorageService, AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
export const WINDOW = new InjectionToken<Window>("WINDOW"); export const WINDOW = new InjectionToken<Window>("WINDOW");
export const OBSERVABLE_MEMORY_STORAGE = new InjectionToken<
AbstractMemoryStorageService & ObservableStorageService
>("OBSERVABLE_MEMORY_STORAGE");
export const OBSERVABLE_DISK_STORAGE = new InjectionToken<
AbstractStorageService & ObservableStorageService
>("OBSERVABLE_DISK_STORAGE");
export const MEMORY_STORAGE = new InjectionToken<AbstractMemoryStorageService>("MEMORY_STORAGE"); export const MEMORY_STORAGE = new InjectionToken<AbstractMemoryStorageService>("MEMORY_STORAGE");
export const SECURE_STORAGE = new InjectionToken<AbstractStorageService>("SECURE_STORAGE"); export const SECURE_STORAGE = new InjectionToken<AbstractStorageService>("SECURE_STORAGE");
export const STATE_FACTORY = new InjectionToken<StateFactory>("STATE_FACTORY"); export const STATE_FACTORY = new InjectionToken<StateFactory>("STATE_FACTORY");

View File

@ -101,6 +101,9 @@ import { NoopNotificationsService } from "@bitwarden/common/platform/services/no
import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StateService } from "@bitwarden/common/platform/services/state.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service"; import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service";
import { ApiService } from "@bitwarden/common/services/api.service"; import { ApiService } from "@bitwarden/common/services/api.service";
@ -170,6 +173,8 @@ import {
LOG_MAC_FAILURES, LOG_MAC_FAILURES,
LOGOUT_CALLBACK, LOGOUT_CALLBACK,
MEMORY_STORAGE, MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
SECURE_STORAGE, SECURE_STORAGE,
STATE_FACTORY, STATE_FACTORY,
STATE_SERVICE_USE_CACHE, STATE_SERVICE_USE_CACHE,
@ -337,7 +342,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
{ {
provide: AccountServiceAbstraction, provide: AccountServiceAbstraction,
useClass: AccountServiceImplementation, useClass: AccountServiceImplementation,
deps: [MessagingServiceAbstraction, LogService], deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider],
}, },
{ {
provide: InternalAccountService, provide: InternalAccountService,
@ -747,6 +752,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
useClass: AuthRequestCryptoServiceImplementation, useClass: AuthRequestCryptoServiceImplementation,
deps: [CryptoServiceAbstraction], deps: [CryptoServiceAbstraction],
}, },
{
provide: GlobalStateProvider,
useClass: DefaultGlobalStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE],
},
], ],
}) })
export class JslibServicesModule {} export class JslibServicesModule {}

View File

@ -3,12 +3,20 @@ import { Observable } from "rxjs";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { AuthenticationStatus } from "../enums/authentication-status"; import { AuthenticationStatus } from "../enums/authentication-status";
/**
* Holds information about an account for use in the AccountService
* if more information is added, be sure to update the equality method.
*/
export type AccountInfo = { export type AccountInfo = {
status: AuthenticationStatus; status: AuthenticationStatus;
email: string; email: string;
name: string | undefined; name: string | undefined;
}; };
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
return a.status == b.status && a.email == b.email && a.name == b.name;
}
export abstract class AccountService { export abstract class AccountService {
accounts$: Observable<Record<UserId, AccountInfo>>; accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>; activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;

View File

@ -1,9 +1,15 @@
import { MockProxy, mock } from "jest-mock-extended"; import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs"; import { BehaviorSubject, firstValueFrom } from "rxjs";
import { trackEmissions } from "../../../spec/utils"; import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service"; import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service"; import { MessagingService } from "../../platform/abstractions/messaging.service";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
GlobalState,
GlobalStateProvider,
} from "../../platform/state";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { AccountInfo } from "../abstractions/account.service"; import { AccountInfo } from "../abstractions/account.service";
import { AuthenticationStatus } from "../enums/authentication-status"; import { AuthenticationStatus } from "../enums/authentication-status";
@ -13,6 +19,11 @@ import { AccountServiceImplementation } from "./account.service";
describe("accountService", () => { describe("accountService", () => {
let messagingService: MockProxy<MessagingService>; let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
let globalStateProvider: MockProxy<GlobalStateProvider>;
let accountsState: MockProxy<GlobalState<Record<UserId, AccountInfo>>>;
let accountsSubject: BehaviorSubject<Record<UserId, AccountInfo>>;
let activeAccountIdState: MockProxy<GlobalState<UserId>>;
let activeAccountIdSubject: BehaviorSubject<UserId>;
let sut: AccountServiceImplementation; let sut: AccountServiceImplementation;
const userId = "userId" as UserId; const userId = "userId" as UserId;
function userInfo(status: AuthenticationStatus): AccountInfo { function userInfo(status: AuthenticationStatus): AccountInfo {
@ -20,10 +31,29 @@ describe("accountService", () => {
} }
beforeEach(() => { beforeEach(() => {
messagingService = mock<MessagingService>(); messagingService = mock();
logService = mock<LogService>(); logService = mock();
globalStateProvider = mock();
accountsState = mock();
activeAccountIdState = mock();
sut = new AccountServiceImplementation(messagingService, logService); accountsSubject = new BehaviorSubject<Record<UserId, AccountInfo>>(null);
accountsState.state$ = accountsSubject.asObservable();
activeAccountIdSubject = new BehaviorSubject<UserId>(null);
activeAccountIdState.state$ = activeAccountIdSubject.asObservable();
globalStateProvider.get.mockImplementation((keyDefinition) => {
switch (keyDefinition) {
case ACCOUNT_ACCOUNTS:
return accountsState;
case ACCOUNT_ACTIVE_ACCOUNT_ID:
return activeAccountIdState;
default:
throw new Error("Unknown key definition");
}
});
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
}); });
afterEach(() => { afterEach(() => {
@ -39,8 +69,8 @@ describe("accountService", () => {
it("should emit the active account and status", async () => { it("should emit the active account and status", async () => {
const emissions = trackEmissions(sut.activeAccount$); const emissions = trackEmissions(sut.activeAccount$);
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
sut.switchAccount(userId); activeAccountIdSubject.next(userId);
expect(emissions).toEqual([ expect(emissions).toEqual([
undefined, // initial value undefined, // initial value
@ -48,9 +78,21 @@ describe("accountService", () => {
]); ]);
}); });
it("should update the status if the account status changes", async () => {
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
const emissions = trackEmissions(sut.activeAccount$);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Locked) });
expect(emissions).toEqual([
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
]);
});
it("should remember the last emitted value", async () => { it("should remember the last emitted value", async () => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
sut.switchAccount(userId); activeAccountIdSubject.next(userId);
expect(await firstValueFrom(sut.activeAccount$)).toEqual({ expect(await firstValueFrom(sut.activeAccount$)).toEqual({
id: userId, id: userId,
@ -59,77 +101,98 @@ describe("accountService", () => {
}); });
}); });
describe("accounts$", () => {
it("should maintain an accounts cache", async () => {
expect(await firstValueFrom(sut.accounts$)).toEqual({});
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
expect(await firstValueFrom(sut.accounts$)).toEqual({
[userId]: userInfo(AuthenticationStatus.Unlocked),
});
});
});
describe("addAccount", () => { describe("addAccount", () => {
it("should emit the new account", () => { it("should emit the new account", () => {
const emissions = trackEmissions(sut.accounts$);
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
expect(emissions).toEqual([ expect(accountsState.update).toHaveBeenCalledTimes(1);
{}, // initial value const callback = accountsState.update.mock.calls[0][0];
{ [userId]: userInfo(AuthenticationStatus.Unlocked) }, expect(callback({}, null)).toEqual({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
]);
}); });
}); });
describe("setAccountName", () => { describe("setAccountName", () => {
beforeEach(() => { beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
}); });
it("should emit the updated account", () => { it("should update the account", async () => {
const emissions = trackEmissions(sut.accounts$);
sut.setAccountName(userId, "new name"); sut.setAccountName(userId, "new name");
expect(emissions).toEqual([ const callback = accountsState.update.mock.calls[0][0];
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "name" } },
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" } }, expect(callback(accountsSubject.value, null)).toEqual({
]); [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" },
});
});
it("should not update if the name is the same", async () => {
sut.setAccountName(userId, "name");
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
}); });
}); });
describe("setAccountEmail", () => { describe("setAccountEmail", () => {
beforeEach(() => { beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
}); });
it("should emit the updated account", () => { it("should update the account", () => {
const emissions = trackEmissions(sut.accounts$);
sut.setAccountEmail(userId, "new email"); sut.setAccountEmail(userId, "new email");
expect(emissions).toEqual([ const callback = accountsState.update.mock.calls[0][0];
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "email" } },
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" } }, expect(callback(accountsSubject.value, null)).toEqual({
]); [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" },
});
});
it("should not update if the email is the same", () => {
sut.setAccountEmail(userId, "email");
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
}); });
}); });
describe("setAccountStatus", () => { describe("setAccountStatus", () => {
beforeEach(() => { beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
}); });
it("should not emit if the status is the same", async () => { it("should update the account", () => {
const emissions = trackEmissions(sut.accounts$); sut.setAccountStatus(userId, AuthenticationStatus.Locked);
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
expect(emissions).toEqual([{ userId: userInfo(AuthenticationStatus.Unlocked) }]); const callback = accountsState.update.mock.calls[0][0];
});
it("should maintain an accounts cache", async () => { expect(callback(accountsSubject.value, null)).toEqual({
expect(await firstValueFrom(sut.accounts$)).toEqual({ [userId]: {
[userId]: userInfo(AuthenticationStatus.Unlocked), ...userInfo(AuthenticationStatus.Unlocked),
status: AuthenticationStatus.Locked,
},
}); });
}); });
it("should emit if the status is different", () => { it("should not update if the status is the same", () => {
const emissions = trackEmissions(sut.accounts$); sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
expect(emissions).toEqual([ const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
{ userId: userInfo(AuthenticationStatus.Unlocked) }, // initial value from beforeEach
{ userId: userInfo(AuthenticationStatus.Locked) }, expect(callback(accountsSubject.value, null)).toBe(false);
]);
}); });
it("should emit logout if the status is logged out", () => { it("should emit logout if the status is logged out", () => {
@ -148,34 +211,20 @@ describe("accountService", () => {
}); });
describe("switchAccount", () => { describe("switchAccount", () => {
let emissions: { id: string; status: AuthenticationStatus }[];
beforeEach(() => { beforeEach(() => {
emissions = []; accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
sut.activeAccount$.subscribe((value) => emissions.push(value));
}); });
it("should emit undefined if no account is provided", () => { it("should emit undefined if no account is provided", () => {
sut.switchAccount(undefined); sut.switchAccount(null);
const callback = activeAccountIdState.update.mock.calls[0][0];
expect(emissions).toEqual([undefined]); expect(callback(userId, accountsSubject.value)).toBeUndefined();
}); });
it("should emit the active account and status", () => { it("should throw if the account does not exist", () => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); sut.switchAccount("unknown" as UserId);
sut.switchAccount(userId); const callback = activeAccountIdState.update.mock.calls[0][0];
sut.setAccountStatus(userId, AuthenticationStatus.Locked); expect(() => callback(userId, accountsSubject.value)).toThrowError("Account does not exist");
sut.switchAccount(undefined);
sut.switchAccount(undefined);
expect(emissions).toEqual([
undefined, // initial value
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
]);
});
it("should throw if switched to an unknown account", () => {
expect(() => sut.switchAccount(userId)).toThrowError("Account does not exist");
}); });
}); });
}); });

View File

@ -1,50 +1,80 @@
import { import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs";
BehaviorSubject, import { Jsonify } from "type-fest";
Subject,
combineLatestWith,
map,
distinctUntilChanged,
shareReplay,
} from "rxjs";
import { AccountInfo, InternalAccountService } from "../../auth/abstractions/account.service"; import {
AccountInfo,
InternalAccountService,
accountInfoEqual,
} from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service"; import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service"; import { MessagingService } from "../../platform/abstractions/messaging.service";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
GlobalState,
GlobalStateProvider,
} from "../../platform/state";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { AuthenticationStatus } from "../enums/authentication-status"; import { AuthenticationStatus } from "../enums/authentication-status";
export function AccountsDeserializer(
accounts: Jsonify<Record<UserId, AccountInfo> | null>
): Record<UserId, AccountInfo> {
if (accounts == null) {
return {};
}
return accounts;
}
export class AccountServiceImplementation implements InternalAccountService { export class AccountServiceImplementation implements InternalAccountService {
private accounts = new BehaviorSubject<Record<UserId, AccountInfo>>({});
private activeAccountId = new BehaviorSubject<UserId | undefined>(undefined);
private lock = new Subject<UserId>(); private lock = new Subject<UserId>();
private logout = new Subject<UserId>(); private logout = new Subject<UserId>();
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
accounts$ = this.accounts.asObservable(); accounts$;
activeAccount$ = this.activeAccountId.pipe( activeAccount$;
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false })
);
accountLock$ = this.lock.asObservable(); accountLock$ = this.lock.asObservable();
accountLogout$ = this.logout.asObservable(); accountLogout$ = this.logout.asObservable();
constructor(private messagingService: MessagingService, private logService: LogService) {}
constructor(
private messagingService: MessagingService,
private logService: LogService,
private globalStateProvider: GlobalStateProvider
) {
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
this.accounts$ = this.accountsState.state$.pipe(
map((accounts) => (accounts == null ? {} : accounts))
);
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false })
);
}
addAccount(userId: UserId, accountData: AccountInfo): void { addAccount(userId: UserId, accountData: AccountInfo): void {
this.accounts.value[userId] = accountData; this.accountsState.update((accounts) => {
this.accounts.next(this.accounts.value); accounts ||= {};
accounts[userId] = accountData;
return accounts;
});
} }
setAccountName(userId: UserId, name: string): void { setAccountName(userId: UserId, name: string): void {
this.setAccountInfo(userId, { ...this.accounts.value[userId], name }); this.setAccountInfo(userId, { name });
} }
setAccountEmail(userId: UserId, email: string): void { setAccountEmail(userId: UserId, email: string): void {
this.setAccountInfo(userId, { ...this.accounts.value[userId], email }); this.setAccountInfo(userId, { email });
} }
setAccountStatus(userId: UserId, status: AuthenticationStatus): void { setAccountStatus(userId: UserId, status: AuthenticationStatus): void {
this.setAccountInfo(userId, { ...this.accounts.value[userId], status }); this.setAccountInfo(userId, { status });
if (status === AuthenticationStatus.LoggedOut) { if (status === AuthenticationStatus.LoggedOut) {
this.logout.next(userId); this.logout.next(userId);
@ -54,16 +84,22 @@ export class AccountServiceImplementation implements InternalAccountService {
} }
switchAccount(userId: UserId) { switchAccount(userId: UserId) {
if (userId == null) { this.activeAccountIdState.update(
// indicates no account is active (_, accounts) => {
this.activeAccountId.next(undefined); if (userId == null) {
return; // indicates no account is active
} return undefined;
}
if (this.accounts.value[userId] == null) { if (accounts?.[userId] == null) {
throw new Error("Account does not exist"); throw new Error("Account does not exist");
} }
this.activeAccountId.next(userId); return userId;
},
{
combineLatestWith: this.accounts$,
}
);
} }
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow // TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
@ -76,18 +112,26 @@ export class AccountServiceImplementation implements InternalAccountService {
} }
} }
private setAccountInfo(userId: UserId, accountInfo: AccountInfo) { private setAccountInfo(userId: UserId, update: Partial<AccountInfo>) {
if (this.accounts.value[userId] == null) { function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
throw new Error("Account does not exist"); return { ...oldAccountInfo, ...update };
} }
this.accountsState.update(
(accounts) => {
accounts[userId] = newAccountInfo(accounts[userId]);
return accounts;
},
{
// Avoid unnecessary updates
// TODO: Faster comparison, maybe include a hash on the objects?
shouldUpdate: (accounts) => {
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
// Avoid unnecessary updates return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
// TODO: Faster comparison, maybe include a hash on the objects? },
if (JSON.stringify(this.accounts.value[userId]) === JSON.stringify(accountInfo)) { }
return; );
}
this.accounts.value[userId] = accountInfo;
this.accounts.next(this.accounts.value);
} }
} }

View File

@ -8,14 +8,17 @@ export type StorageUpdate = {
updateType: StorageUpdateType; updateType: StorageUpdateType;
}; };
export abstract class AbstractStorageService { export interface ObservableStorageService {
abstract get valuesRequireDeserialization(): boolean;
/** /**
* Provides an {@link Observable} that represents a stream of updates that * Provides an {@link Observable} that represents a stream of updates that
* have happened in this storage service or in the storage this service provides * have happened in this storage service or in the storage this service provides
* an interface to. * an interface to.
*/ */
abstract get updates$(): Observable<StorageUpdate>; get updates$(): Observable<StorageUpdate>;
}
export abstract class AbstractStorageService {
abstract get valuesRequireDeserialization(): boolean;
abstract get<T>(key: string, options?: StorageOptions): Promise<T>; abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
abstract has(key: string, options?: StorageOptions): Promise<boolean>; abstract has(key: string, options?: StorageOptions): Promise<boolean>;
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>; abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;

View File

@ -3,7 +3,7 @@ import { Subject } from "rxjs";
import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service"; import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service";
export class MemoryStorageService extends AbstractMemoryStorageService { export class MemoryStorageService extends AbstractMemoryStorageService {
private store = new Map<string, unknown>(); protected store = new Map<string, unknown>();
private updatesSubject = new Subject<StorageUpdate>(); private updatesSubject = new Subject<StorageUpdate>();
get valuesRequireDeserialization(): boolean { get valuesRequireDeserialization(): boolean {

View File

@ -1,6 +1,7 @@
import { import {
AbstractMemoryStorageService, AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service"; } from "../../abstractions/storage.service";
import { GlobalState } from "../global-state"; import { GlobalState } from "../global-state";
import { GlobalStateProvider } from "../global-state.provider"; import { GlobalStateProvider } from "../global-state.provider";
@ -13,8 +14,8 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
private globalStateCache: Record<string, GlobalState<unknown>> = {}; private globalStateCache: Record<string, GlobalState<unknown>> = {};
constructor( constructor(
private memoryStorage: AbstractMemoryStorageService, private memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
private diskStorage: AbstractStorageService private diskStorage: AbstractStorageService & ObservableStorageService
) {} ) {}
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> { get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {

View File

@ -10,7 +10,10 @@ import {
timeout, timeout,
} from "rxjs"; } from "rxjs";
import { AbstractStorageService } from "../../abstractions/storage.service"; import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { GlobalState } from "../global-state"; import { GlobalState } from "../global-state";
import { KeyDefinition, globalKeyBuilder } from "../key-definition"; import { KeyDefinition, globalKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
@ -29,7 +32,7 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
constructor( constructor(
private keyDefinition: KeyDefinition<T>, private keyDefinition: KeyDefinition<T>,
private chosenLocation: AbstractStorageService private chosenLocation: AbstractStorageService & ObservableStorageService
) { ) {
this.storageKey = globalKeyBuilder(this.keyDefinition); this.storageKey = globalKeyBuilder(this.keyDefinition);

View File

@ -3,6 +3,7 @@ import { EncryptService } from "../../abstractions/encrypt.service";
import { import {
AbstractMemoryStorageService, AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service"; } from "../../abstractions/storage.service";
import { KeyDefinition } from "../key-definition"; import { KeyDefinition } from "../key-definition";
import { StorageLocation } from "../state-definition"; import { StorageLocation } from "../state-definition";
@ -17,8 +18,8 @@ export class DefaultUserStateProvider implements UserStateProvider {
constructor( constructor(
protected accountService: AccountService, protected accountService: AccountService,
protected encryptService: EncryptService, protected encryptService: EncryptService,
protected memoryStorage: AbstractMemoryStorageService, protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
protected diskStorage: AbstractStorageService protected diskStorage: AbstractStorageService & ObservableStorageService
) {} ) {}
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> { get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {

View File

@ -15,7 +15,10 @@ import {
import { AccountService } from "../../../auth/abstractions/account.service"; import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptService } from "../../abstractions/encrypt.service";
import { AbstractStorageService } from "../../abstractions/storage.service"; import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { DerivedUserState } from "../derived-user-state"; import { DerivedUserState } from "../derived-user-state";
import { KeyDefinition, userKeyBuilder } from "../key-definition"; import { KeyDefinition, userKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
@ -40,7 +43,7 @@ export class DefaultUserState<T> implements UserState<T> {
protected keyDefinition: KeyDefinition<T>, protected keyDefinition: KeyDefinition<T>,
private accountService: AccountService, private accountService: AccountService,
private encryptService: EncryptService, private encryptService: EncryptService,
private chosenStorageLocation: AbstractStorageService private chosenStorageLocation: AbstractStorageService & ObservableStorageService
) { ) {
this.formattedKey$ = this.accountService.activeAccount$.pipe( this.formattedKey$ = this.accountService.activeAccount$.pipe(
map((account) => map((account) =>

View File

@ -1,3 +1,7 @@
export { DerivedUserState } from "./derived-user-state"; export { DerivedUserState } from "./derived-user-state";
export { DefaultGlobalStateProvider } from "./implementations/default-global-state.provider"; export { GlobalState } from "./global-state";
export { DefaultUserStateProvider } from "./implementations/default-user-state.provider"; export { GlobalStateProvider } from "./global-state.provider";
export { UserState } from "./user-state";
export { UserStateProvider } from "./user-state.provider";
export * from "./key-definitions";

View File

@ -0,0 +1,18 @@
import { AccountInfo } from "../../auth/abstractions/account.service";
import { AccountsDeserializer } from "../../auth/services/account.service";
import { UserId } from "../../types/guid";
import { KeyDefinition } from "./key-definition";
import { StateDefinition } from "./state-definition";
const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const ACCOUNT_ACCOUNTS = new KeyDefinition<Record<UserId, AccountInfo>>(
ACCOUNT_MEMORY,
"accounts",
{
deserializer: (obj) => AccountsDeserializer(obj),
}
);
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_MEMORY, "activeAccountId", {
deserializer: (id: UserId) => id,
});

View File

@ -34,5 +34,5 @@
"useDefineForClassFields": false "useDefineForClassFields": false
}, },
"include": ["apps/web/src/**/*", "libs/*/src/**/*", "bitwarden_license/bit-web/src/**/*"], "include": ["apps/web/src/**/*", "libs/*/src/**/*", "bitwarden_license/bit-web/src/**/*"],
"exclude": ["apps/web/src/**/*.spec.ts", "libs/*/src/**/*.spec.ts"] "exclude": ["apps/web/src/**/*.spec.ts", "libs/*/src/**/*.spec.ts", "**/*.spec-util.ts"]
} }