mirror of
https://github.com/bitwarden/browser
synced 2026-02-27 18:13:29 +00:00
Merge remote-tracking branch 'origin' into auth/pm-19877/notification-processing
This commit is contained in:
@@ -5,5 +5,5 @@ export abstract class ConfigApiServiceAbstraction {
|
||||
/**
|
||||
* 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>;
|
||||
abstract get(userId: UserId | null): Promise<ServerConfigResponse>;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,13 @@ export interface Environment {
|
||||
*/
|
||||
export abstract class EnvironmentService {
|
||||
abstract environment$: Observable<Environment>;
|
||||
|
||||
/**
|
||||
* The environment stored in global state, when a user signs in the state stored here will become
|
||||
* their user environment.
|
||||
*/
|
||||
abstract globalEnvironment$: Observable<Environment>;
|
||||
|
||||
abstract cloudWebVaultUrl$: Observable<string>;
|
||||
|
||||
/**
|
||||
@@ -125,12 +132,12 @@ export abstract class EnvironmentService {
|
||||
* @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.
|
||||
*/
|
||||
abstract setCloudRegion(userId: UserId, region: Region): Promise<void>;
|
||||
abstract setCloudRegion(userId: UserId | null, region: Region): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the environment from state. Useful if you need to get the environment for another user.
|
||||
*/
|
||||
abstract getEnvironment$(userId: UserId): Observable<Environment | undefined>;
|
||||
abstract getEnvironment$(userId: UserId): Observable<Environment>;
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link getEnvironment$} instead.
|
||||
|
||||
@@ -70,7 +70,7 @@ export class Fido2AuthenticatorError extends Error {
|
||||
}
|
||||
|
||||
export interface PublicKeyCredentialDescriptor {
|
||||
id: Uint8Array;
|
||||
id: ArrayBuffer;
|
||||
transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[];
|
||||
type: "public-key";
|
||||
}
|
||||
@@ -155,9 +155,9 @@ export interface Fido2AuthenticatorGetAssertionParams {
|
||||
|
||||
export interface Fido2AuthenticatorGetAssertionResult {
|
||||
selectedCredential: {
|
||||
id: Uint8Array;
|
||||
userHandle?: Uint8Array;
|
||||
id: ArrayBuffer;
|
||||
userHandle?: ArrayBuffer;
|
||||
};
|
||||
authenticatorData: Uint8Array;
|
||||
signature: Uint8Array;
|
||||
authenticatorData: ArrayBuffer;
|
||||
signature: ArrayBuffer;
|
||||
}
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
import { LogLevelType } from "../enums/log-level-type.enum";
|
||||
|
||||
export abstract class LogService {
|
||||
abstract debug(message?: any, ...optionalParams: any[]): void;
|
||||
abstract info(message?: any, ...optionalParams: any[]): void;
|
||||
abstract warning(message?: any, ...optionalParams: any[]): void;
|
||||
abstract error(message?: any, ...optionalParams: any[]): void;
|
||||
abstract write(level: LogLevelType, message?: any, ...optionalParams: any[]): void;
|
||||
}
|
||||
export { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -28,6 +28,15 @@ export abstract class PlatformUtilsService {
|
||||
abstract getApplicationVersionNumber(): Promise<string>;
|
||||
abstract supportsWebAuthn(win: Window): boolean;
|
||||
abstract supportsDuo(): boolean;
|
||||
/**
|
||||
* Returns true if the device supports autofill functionality
|
||||
*/
|
||||
abstract supportsAutofill(): boolean;
|
||||
/**
|
||||
* Returns true if the device supports native file downloads without
|
||||
* the need for `target="_blank"`
|
||||
*/
|
||||
abstract supportsFileDownloads(): boolean;
|
||||
/**
|
||||
* @deprecated use `@bitwarden/components/ToastService.showToast` instead
|
||||
*
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { StorageOptions } from "../models/domain/storage-options";
|
||||
|
||||
export type StorageUpdateType = "save" | "remove";
|
||||
export type StorageUpdate = {
|
||||
key: string;
|
||||
updateType: StorageUpdateType;
|
||||
};
|
||||
|
||||
export interface ObservableStorageService {
|
||||
/**
|
||||
* Provides an {@link Observable} that represents a stream of updates that
|
||||
* have happened in this storage service or in the storage this service provides
|
||||
* an interface to.
|
||||
*/
|
||||
get updates$(): Observable<StorageUpdate>;
|
||||
}
|
||||
|
||||
export abstract class AbstractStorageService {
|
||||
abstract get valuesRequireDeserialization(): boolean;
|
||||
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
|
||||
abstract has(key: string, options?: StorageOptions): Promise<boolean>;
|
||||
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
|
||||
abstract remove(key: string, options?: StorageOptions): Promise<void>;
|
||||
}
|
||||
export {
|
||||
StorageUpdateType,
|
||||
StorageUpdate,
|
||||
ObservableStorageService,
|
||||
AbstractStorageService,
|
||||
} from "@bitwarden/storage-core";
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum HtmlStorageLocation {
|
||||
Local = "local",
|
||||
Memory = "memory",
|
||||
Session = "session",
|
||||
}
|
||||
export { HtmlStorageLocation } from "@bitwarden/storage-core";
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum LogLevelType {
|
||||
Debug,
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
export { LogLevel as LogLevelType } from "@bitwarden/logging";
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum StorageLocation {
|
||||
Both = "both",
|
||||
Disk = "disk",
|
||||
Memory = "memory",
|
||||
}
|
||||
export { StorageLocationEnum as StorageLocation } from "@bitwarden/storage-core";
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { OutgoingMessage } from "@bitwarden/sdk-internal";
|
||||
|
||||
export interface IpcMessage {
|
||||
type: "bitwarden-ipc-message";
|
||||
message: Omit<OutgoingMessage, "free">;
|
||||
message: SerializedOutgoingMessage;
|
||||
}
|
||||
|
||||
export interface SerializedOutgoingMessage extends Omit<OutgoingMessage, "free" | "payload"> {
|
||||
payload: number[];
|
||||
}
|
||||
|
||||
export function isIpcMessage(message: any): message is IpcMessage {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IpcClient, IncomingMessage, OutgoingMessage } from "@bitwarden/sdk-inte
|
||||
|
||||
export abstract class IpcService {
|
||||
private _client?: IpcClient;
|
||||
protected get client(): IpcClient {
|
||||
get client(): IpcClient {
|
||||
if (!this._client) {
|
||||
throw new Error("IpcService not initialized");
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export abstract class IpcService {
|
||||
|
||||
protected async initWithClient(client: IpcClient): Promise<void> {
|
||||
this._client = client;
|
||||
await this._client.start();
|
||||
|
||||
this._messages$ = new Observable<IncomingMessage>((subscriber) => {
|
||||
let isSubscribed = true;
|
||||
const receiveLoop = async () => {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { throttle } from "./throttle";
|
||||
|
||||
describe("throttle decorator", () => {
|
||||
it("should call the function once at a time", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should call the function once at a time for each object", async () => {
|
||||
const foo = new Foo();
|
||||
const foo2 = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
promises.push(foo2.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
expect(foo2.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should call the function limit at a time", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.baz(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should call the function limit at a time for each object", async () => {
|
||||
const foo = new Foo();
|
||||
const foo2 = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.baz(1));
|
||||
promises.push(foo2.baz(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
expect(foo2.calls).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
class Foo {
|
||||
calls = 0;
|
||||
inflight = 0;
|
||||
|
||||
@throttle(1, () => "bar")
|
||||
bar(a: number) {
|
||||
this.calls++;
|
||||
this.inflight++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
expect(this.inflight).toBe(1);
|
||||
this.inflight--;
|
||||
res(a * 2);
|
||||
}, Math.random() * 10);
|
||||
});
|
||||
}
|
||||
|
||||
@throttle(5, () => "baz")
|
||||
baz(a: number) {
|
||||
this.calls++;
|
||||
this.inflight++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
expect(this.inflight).toBeLessThanOrEqual(5);
|
||||
this.inflight--;
|
||||
res(a * 3);
|
||||
}, Math.random() * 10);
|
||||
});
|
||||
}
|
||||
|
||||
@throttle(1, () => "qux")
|
||||
qux(a: number) {
|
||||
this.calls++;
|
||||
this.inflight++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
expect(this.inflight).toBe(1);
|
||||
this.inflight--;
|
||||
res(a * 3);
|
||||
}, Math.random() * 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
/**
|
||||
* Use as a Decorator on async functions, it will limit how many times the function can be
|
||||
* in-flight at a time.
|
||||
*
|
||||
* Calls beyond the limit will be queued, and run when one of the active calls finishes
|
||||
*/
|
||||
export function throttle(limit: number, throttleKey: (args: any[]) => string) {
|
||||
return <T>(
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: TypedPropertyDescriptor<(...args: any[]) => Promise<T>>,
|
||||
) => {
|
||||
const originalMethod: () => Promise<T> = descriptor.value;
|
||||
const allThrottles = new Map<any, Map<string, (() => void)[]>>();
|
||||
|
||||
const getThrottles = (obj: any) => {
|
||||
let throttles = allThrottles.get(obj);
|
||||
if (throttles != null) {
|
||||
return throttles;
|
||||
}
|
||||
throttles = new Map<string, (() => void)[]>();
|
||||
allThrottles.set(obj, throttles);
|
||||
return throttles;
|
||||
};
|
||||
|
||||
return {
|
||||
value: function (...args: any[]) {
|
||||
const throttles = getThrottles(this);
|
||||
const argsThrottleKey = throttleKey(args);
|
||||
let queue = throttles.get(argsThrottleKey);
|
||||
if (queue == null) {
|
||||
queue = [];
|
||||
throttles.set(argsThrottleKey, queue);
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const exec = () => {
|
||||
const onFinally = () => {
|
||||
queue.splice(queue.indexOf(exec), 1);
|
||||
if (queue.length >= limit) {
|
||||
queue[limit - 1]();
|
||||
} else if (queue.length === 0) {
|
||||
throttles.delete(argsThrottleKey);
|
||||
if (throttles.size === 0) {
|
||||
allThrottles.delete(this);
|
||||
}
|
||||
}
|
||||
};
|
||||
originalMethod
|
||||
.apply(this, args)
|
||||
.then((val: any) => {
|
||||
onFinally();
|
||||
return val;
|
||||
})
|
||||
.catch((err: any) => {
|
||||
onFinally();
|
||||
throw err;
|
||||
})
|
||||
.then(resolve, reject);
|
||||
};
|
||||
queue.push(exec);
|
||||
if (queue.length <= limit) {
|
||||
exec();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -260,7 +260,7 @@ export class Utils {
|
||||
});
|
||||
}
|
||||
|
||||
static guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
static guidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/;
|
||||
|
||||
static isGuid(id: string) {
|
||||
return RegExp(Utils.guidRegex, "i").test(id);
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
import { HtmlStorageLocation, StorageLocation } from "../../enums";
|
||||
|
||||
export type StorageOptions = {
|
||||
storageLocation?: StorageLocation;
|
||||
useSecureStorage?: boolean;
|
||||
userId?: string;
|
||||
htmlStorageLocation?: HtmlStorageLocation;
|
||||
keySuffix?: string;
|
||||
};
|
||||
export type { StorageOptions } from "@bitwarden/storage-core";
|
||||
|
||||
@@ -108,14 +108,19 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
|
||||
return this.webPushConnectionService.supportStatus$(userId);
|
||||
}),
|
||||
supportSwitch({
|
||||
supported: (service) =>
|
||||
service.notifications$.pipe(
|
||||
supported: (service) => {
|
||||
this.logService.info("Using WebPush for notifications");
|
||||
return service.notifications$.pipe(
|
||||
catchError((err: unknown) => {
|
||||
this.logService.warning("Issue with web push, falling back to SignalR", err);
|
||||
return this.connectSignalR$(userId, notificationsUrl);
|
||||
}),
|
||||
),
|
||||
notSupported: () => this.connectSignalR$(userId, notificationsUrl),
|
||||
);
|
||||
},
|
||||
notSupported: () => {
|
||||
this.logService.info("Using SignalR for notifications");
|
||||
return this.connectSignalR$(userId, notificationsUrl);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,22 +31,35 @@ export type TimeoutManager = {
|
||||
class SignalRLogger implements ILogger {
|
||||
constructor(private readonly logService: LogService) {}
|
||||
|
||||
redactMessage(message: string): string {
|
||||
const ACCESS_TOKEN_TEXT = "access_token=";
|
||||
// Redact the access token from the logs if it exists.
|
||||
const accessTokenIndex = message.indexOf(ACCESS_TOKEN_TEXT);
|
||||
if (accessTokenIndex !== -1) {
|
||||
return message.substring(0, accessTokenIndex + ACCESS_TOKEN_TEXT.length) + "[REDACTED]";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
log(logLevel: LogLevel, message: string): void {
|
||||
const redactedMessage = `[SignalR] ${this.redactMessage(message)}`;
|
||||
|
||||
switch (logLevel) {
|
||||
case LogLevel.Critical:
|
||||
this.logService.error(message);
|
||||
this.logService.error(redactedMessage);
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
this.logService.error(message);
|
||||
this.logService.error(redactedMessage);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
this.logService.warning(message);
|
||||
this.logService.warning(redactedMessage);
|
||||
break;
|
||||
case LogLevel.Information:
|
||||
this.logService.info(message);
|
||||
this.logService.info(redactedMessage);
|
||||
break;
|
||||
case LogLevel.Debug:
|
||||
this.logService.debug(message);
|
||||
this.logService.debug(redactedMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import {
|
||||
awaitAsync,
|
||||
FakeGlobalState,
|
||||
FakeStateProvider,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { PushTechnology } from "../../../enums/push-technology.enum";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { Supported } from "../../misc/support-status";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
import { PushSettingsConfigResponse } from "../../models/response/server-config.response";
|
||||
import { KeyDefinition } from "../../state";
|
||||
|
||||
import { WebPushNotificationsApiService } from "./web-push-notifications-api.service";
|
||||
import { WebPushConnector } from "./webpush-connection.service";
|
||||
import {
|
||||
WEB_PUSH_SUBSCRIPTION_USERS,
|
||||
WorkerWebPushConnectionService,
|
||||
} from "./worker-webpush-connection.service";
|
||||
|
||||
const mockUser1 = "testUser1" as UserId;
|
||||
|
||||
const createSub = (key: string) => {
|
||||
return {
|
||||
options: { applicationServerKey: Utils.fromUrlB64ToArray(key), userVisibleOnly: true },
|
||||
endpoint: `web.push.endpoint/?${Utils.newGuid()}`,
|
||||
expirationTime: 5,
|
||||
getKey: () => null,
|
||||
toJSON: () => ({ endpoint: "something", keys: {}, expirationTime: 5 }),
|
||||
unsubscribe: () => Promise.resolve(true),
|
||||
} satisfies PushSubscription;
|
||||
};
|
||||
|
||||
describe("WorkerWebpushConnectionService", () => {
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let webPushApiService: MockProxy<WebPushNotificationsApiService>;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let pushManager: MockProxy<PushManager>;
|
||||
const userId = "testUser1" as UserId;
|
||||
|
||||
let sut: WorkerWebPushConnectionService;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = mock();
|
||||
webPushApiService = mock();
|
||||
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
|
||||
pushManager = mock();
|
||||
|
||||
sut = new WorkerWebPushConnectionService(
|
||||
configService,
|
||||
webPushApiService,
|
||||
mock<ServiceWorkerRegistration>({ pushManager: pushManager }),
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
type ExtractKeyDefinitionType<T> = T extends KeyDefinition<infer U> ? U : never;
|
||||
describe("supportStatus$", () => {
|
||||
let fakeGlobalState: FakeGlobalState<
|
||||
ExtractKeyDefinitionType<typeof WEB_PUSH_SUBSCRIPTION_USERS>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeGlobalState = stateProvider.getGlobal(WEB_PUSH_SUBSCRIPTION_USERS) as FakeGlobalState<
|
||||
ExtractKeyDefinitionType<typeof WEB_PUSH_SUBSCRIPTION_USERS>
|
||||
>;
|
||||
});
|
||||
|
||||
test("when web push is supported, have an existing subscription, and we've already registered the user, should not call API", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.WebPush,
|
||||
vapidPublicKey: "dGVzdA",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const existingSubscription = createSub("dGVzdA");
|
||||
await fakeGlobalState.nextState({ [existingSubscription.endpoint]: [userId] });
|
||||
|
||||
pushManager.getSubscription.mockResolvedValue(existingSubscription);
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("supported");
|
||||
const service = (supportStatus as Supported<WebPushConnector>).service;
|
||||
expect(service).not.toBeFalsy();
|
||||
|
||||
const notificationsSub = service.notifications$.subscribe();
|
||||
|
||||
await awaitAsync(2);
|
||||
|
||||
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(0);
|
||||
|
||||
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
notificationsSub.unsubscribe();
|
||||
});
|
||||
|
||||
test("when web push is supported, have an existing subscription, and we haven't registered the user, should call API", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.WebPush,
|
||||
vapidPublicKey: "dGVzdA",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const existingSubscription = createSub("dGVzdA");
|
||||
await fakeGlobalState.nextState({
|
||||
[existingSubscription.endpoint]: ["otherUserId" as UserId],
|
||||
});
|
||||
|
||||
pushManager.getSubscription.mockResolvedValue(existingSubscription);
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("supported");
|
||||
const service = (supportStatus as Supported<WebPushConnector>).service;
|
||||
expect(service).not.toBeFalsy();
|
||||
|
||||
const notificationsSub = service.notifications$.subscribe();
|
||||
|
||||
await awaitAsync(2);
|
||||
|
||||
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(1);
|
||||
expect(fakeGlobalState.nextMock).toHaveBeenCalledWith({
|
||||
[existingSubscription.endpoint]: ["otherUserId", mockUser1],
|
||||
});
|
||||
|
||||
notificationsSub.unsubscribe();
|
||||
});
|
||||
|
||||
test("when web push is supported, have an existing subscription, but it isn't in state, should call API and add to state", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.WebPush,
|
||||
vapidPublicKey: "dGVzdA",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const existingSubscription = createSub("dGVzdA");
|
||||
await fakeGlobalState.nextState({
|
||||
[existingSubscription.endpoint]: null!,
|
||||
});
|
||||
|
||||
pushManager.getSubscription.mockResolvedValue(existingSubscription);
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("supported");
|
||||
const service = (supportStatus as Supported<WebPushConnector>).service;
|
||||
expect(service).not.toBeFalsy();
|
||||
|
||||
const notificationsSub = service.notifications$.subscribe();
|
||||
|
||||
await awaitAsync(2);
|
||||
|
||||
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(1);
|
||||
expect(fakeGlobalState.nextMock).toHaveBeenCalledWith({
|
||||
[existingSubscription.endpoint]: [mockUser1],
|
||||
});
|
||||
|
||||
notificationsSub.unsubscribe();
|
||||
});
|
||||
|
||||
test("when web push is supported, have an existing subscription, but state array is null, should call API and add to state", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.WebPush,
|
||||
vapidPublicKey: "dGVzdA",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const existingSubscription = createSub("dGVzdA");
|
||||
await fakeGlobalState.nextState({});
|
||||
|
||||
pushManager.getSubscription.mockResolvedValue(existingSubscription);
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("supported");
|
||||
const service = (supportStatus as Supported<WebPushConnector>).service;
|
||||
expect(service).not.toBeFalsy();
|
||||
|
||||
const notificationsSub = service.notifications$.subscribe();
|
||||
|
||||
await awaitAsync(2);
|
||||
|
||||
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(1);
|
||||
expect(fakeGlobalState.nextMock).toHaveBeenCalledWith({
|
||||
[existingSubscription.endpoint]: [mockUser1],
|
||||
});
|
||||
|
||||
notificationsSub.unsubscribe();
|
||||
});
|
||||
|
||||
test("when web push is supported, but we don't have an existing subscription, should call the api and wipe out existing state", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.WebPush,
|
||||
vapidPublicKey: "dGVzdA",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const existingState = createSub("dGVzdA");
|
||||
await fakeGlobalState.nextState({ [existingState.endpoint]: [userId] });
|
||||
|
||||
pushManager.getSubscription.mockResolvedValue(null);
|
||||
const newSubscription = createSub("dGVzdA");
|
||||
pushManager.subscribe.mockResolvedValue(newSubscription);
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("supported");
|
||||
const service = (supportStatus as Supported<WebPushConnector>).service;
|
||||
expect(service).not.toBeFalsy();
|
||||
|
||||
const notificationsSub = service.notifications$.subscribe();
|
||||
|
||||
await awaitAsync(2);
|
||||
|
||||
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(1);
|
||||
expect(fakeGlobalState.nextMock).toHaveBeenCalledWith({
|
||||
[newSubscription.endpoint]: [mockUser1],
|
||||
});
|
||||
|
||||
notificationsSub.unsubscribe();
|
||||
});
|
||||
|
||||
test("when web push is supported and no existing subscription, should call API", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.WebPush,
|
||||
vapidPublicKey: "dGVzdA",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
pushManager.getSubscription.mockResolvedValue(null);
|
||||
pushManager.subscribe.mockResolvedValue(createSub("dGVzdA"));
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("supported");
|
||||
const service = (supportStatus as Supported<WebPushConnector>).service;
|
||||
expect(service).not.toBeFalsy();
|
||||
|
||||
const notificationsSub = service.notifications$.subscribe();
|
||||
|
||||
await awaitAsync(2);
|
||||
|
||||
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(pushManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
|
||||
|
||||
notificationsSub.unsubscribe();
|
||||
});
|
||||
|
||||
test("when web push is supported and existing subscription with different key, should call API", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.WebPush,
|
||||
vapidPublicKey: "dGVzdA",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
pushManager.getSubscription.mockResolvedValue(createSub("dGVzdF9hbHQ"));
|
||||
|
||||
pushManager.subscribe.mockResolvedValue(createSub("dGVzdA"));
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("supported");
|
||||
const service = (supportStatus as Supported<WebPushConnector>).service;
|
||||
expect(service).not.toBeFalsy();
|
||||
|
||||
const notificationsSub = service.notifications$.subscribe();
|
||||
|
||||
await awaitAsync(2);
|
||||
|
||||
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(pushManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
|
||||
|
||||
notificationsSub.unsubscribe();
|
||||
});
|
||||
|
||||
test("when server config emits multiple times quickly while api call takes a long time will only call API once", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.WebPush,
|
||||
vapidPublicKey: "dGVzdA",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
pushManager.getSubscription.mockResolvedValue(createSub("dGVzdF9hbHQ"));
|
||||
|
||||
pushManager.subscribe.mockResolvedValue(createSub("dGVzdA"));
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("supported");
|
||||
const service = (supportStatus as Supported<WebPushConnector>).service;
|
||||
expect(service).not.toBeFalsy();
|
||||
|
||||
const notificationsSub = service.notifications$.subscribe();
|
||||
|
||||
await awaitAsync(2);
|
||||
|
||||
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(pushManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
|
||||
|
||||
notificationsSub.unsubscribe();
|
||||
});
|
||||
|
||||
it("server config shows SignalR support should return not-supported", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.SignalR,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("not-supported");
|
||||
});
|
||||
|
||||
it("server config shows web push but no public key support should return not-supported", async () => {
|
||||
configService.serverConfig$ = of(
|
||||
new ServerConfig(
|
||||
new ServerConfigData({
|
||||
push: new PushSettingsConfigResponse({
|
||||
pushTechnology: PushTechnology.WebPush,
|
||||
vapidPublicKey: null,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
|
||||
expect(supportStatus.type).toBe("not-supported");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Subject,
|
||||
Subscription,
|
||||
switchMap,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
|
||||
import { PushTechnology } from "../../../enums/push-technology.enum";
|
||||
@@ -17,6 +18,7 @@ import { UserId } from "../../../types/guid";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import { SupportStatus } from "../../misc/support-status";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { KeyDefinition, StateProvider, WEB_PUSH_SUBSCRIPTION } from "../../state";
|
||||
|
||||
import { WebPushNotificationsApiService } from "./web-push-notifications-api.service";
|
||||
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
|
||||
@@ -48,6 +50,7 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService
|
||||
private readonly configService: ConfigService,
|
||||
private readonly webPushApiService: WebPushNotificationsApiService,
|
||||
private readonly serviceWorkerRegistration: ServiceWorkerRegistration,
|
||||
private readonly stateProvider: StateProvider,
|
||||
) {}
|
||||
|
||||
start(): Subscription {
|
||||
@@ -97,6 +100,7 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService
|
||||
this.serviceWorkerRegistration,
|
||||
this.pushEvent,
|
||||
this.pushChangeEvent,
|
||||
this.stateProvider,
|
||||
),
|
||||
} satisfies SupportStatus<WebPushConnector>;
|
||||
}),
|
||||
@@ -114,20 +118,36 @@ class MyWebPushConnector implements WebPushConnector {
|
||||
private readonly serviceWorkerRegistration: ServiceWorkerRegistration,
|
||||
private readonly pushEvent$: Observable<PushEvent>,
|
||||
private readonly pushChangeEvent$: Observable<PushSubscriptionChangeEvent>,
|
||||
private readonly stateProvider: StateProvider,
|
||||
) {
|
||||
const subscriptionUsersState = this.stateProvider.getGlobal(WEB_PUSH_SUBSCRIPTION_USERS);
|
||||
this.notifications$ = this.getOrCreateSubscription$(this.vapidPublicKey).pipe(
|
||||
concatMap((subscription) => {
|
||||
return defer(() => {
|
||||
if (subscription == null) {
|
||||
throw new Error("Expected a non-null subscription.");
|
||||
}
|
||||
return this.webPushApiService.putSubscription(subscription.toJSON());
|
||||
}).pipe(
|
||||
switchMap(() => this.pushEvent$),
|
||||
map((e) => {
|
||||
return new NotificationResponse(e.data.json().data);
|
||||
}),
|
||||
);
|
||||
withLatestFrom(subscriptionUsersState.state$.pipe(map((x) => x ?? {}))),
|
||||
concatMap(async ([[isExistingSubscription, subscription], subscriptionUsers]) => {
|
||||
if (subscription == null) {
|
||||
throw new Error("Expected a non-null subscription.");
|
||||
}
|
||||
|
||||
// If this is a new subscription, we can clear state and start over
|
||||
if (!isExistingSubscription) {
|
||||
subscriptionUsers = {};
|
||||
}
|
||||
|
||||
// If the user is already subscribed, we don't need to do anything
|
||||
if (subscriptionUsers[subscription.endpoint]?.includes(this.userId)) {
|
||||
return;
|
||||
}
|
||||
subscriptionUsers[subscription.endpoint] ??= [];
|
||||
subscriptionUsers[subscription.endpoint].push(this.userId);
|
||||
// Update the state with the new subscription-user association
|
||||
await subscriptionUsersState.update(() => subscriptionUsers);
|
||||
|
||||
// Inform the server about the new subscription-user association
|
||||
await this.webPushApiService.putSubscription(subscription.toJSON());
|
||||
}),
|
||||
switchMap(() => this.pushEvent$),
|
||||
map((e) => {
|
||||
return new NotificationResponse(e.data.json().data);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -146,7 +166,7 @@ class MyWebPushConnector implements WebPushConnector {
|
||||
await this.serviceWorkerRegistration.pushManager.getSubscription();
|
||||
|
||||
if (existingSubscription == null) {
|
||||
return await this.pushManagerSubscribe(key);
|
||||
return [false, await this.pushManagerSubscribe(key)] as const;
|
||||
}
|
||||
|
||||
const subscriptionKey = Utils.fromBufferToUrlB64(
|
||||
@@ -159,12 +179,30 @@ class MyWebPushConnector implements WebPushConnector {
|
||||
if (subscriptionKey !== key) {
|
||||
// There is a subscription, but it's not for the current server, unsubscribe and then make a new one
|
||||
await existingSubscription.unsubscribe();
|
||||
return await this.pushManagerSubscribe(key);
|
||||
return [false, await this.pushManagerSubscribe(key)] as const;
|
||||
}
|
||||
|
||||
return existingSubscription;
|
||||
return [true, existingSubscription] as const;
|
||||
}),
|
||||
this.pushChangeEvent$.pipe(map((event) => event.newSubscription)),
|
||||
this.pushChangeEvent$.pipe(map((event) => [false, event.newSubscription] as const)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const WEB_PUSH_SUBSCRIPTION_USERS = new KeyDefinition<Record<string, UserId[]>>(
|
||||
WEB_PUSH_SUBSCRIPTION,
|
||||
"subUsers",
|
||||
{
|
||||
deserializer: (obj) => {
|
||||
if (obj == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, UserId[]> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = Array.isArray(value) ? value : [];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ export class ConfigApiService implements ConfigApiServiceAbstraction {
|
||||
private tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
async get(userId: UserId | undefined): Promise<ServerConfigResponse> {
|
||||
async get(userId: UserId | null): 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 =
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
FakeStateProvider,
|
||||
awaitAsync,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { Matrix } from "../../../../spec/matrix";
|
||||
import { subscribeTo } from "../../../../spec/observable-tracker";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
@@ -74,7 +74,8 @@ describe("ConfigService", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
environmentService.environment$ = environmentSubject;
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
environmentService.globalEnvironment$ = environmentSubject;
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
@@ -98,9 +99,12 @@ describe("ConfigService", () => {
|
||||
: serverConfigFactory(activeApiUrl + userId, tooOld);
|
||||
const globalStored =
|
||||
configStateDescription === "missing"
|
||||
? {}
|
||||
? {
|
||||
[activeApiUrl]: null,
|
||||
}
|
||||
: {
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl, tooOld),
|
||||
[activeApiUrl + "0"]: serverConfigFactory(activeApiUrl + userId, tooOld),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -108,11 +112,6 @@ describe("ConfigService", () => {
|
||||
userState.nextState(userStored);
|
||||
});
|
||||
|
||||
// sanity check
|
||||
test("authed and unauthorized state are different", () => {
|
||||
expect(globalStored[activeApiUrl]).not.toEqual(userStored);
|
||||
});
|
||||
|
||||
describe("fail to fetch", () => {
|
||||
beforeEach(() => {
|
||||
configApiService.get.mockRejectedValue(new Error("Unable to fetch"));
|
||||
@@ -178,6 +177,7 @@ describe("ConfigService", () => {
|
||||
beforeEach(() => {
|
||||
globalState.stateSubject.next(globalStored);
|
||||
userState.nextState(userStored);
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
});
|
||||
it("does not fetch from server", async () => {
|
||||
await firstValueFrom(sut.serverConfig$);
|
||||
@@ -189,21 +189,13 @@ describe("ConfigService", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("gets global config when there is an locked active user", async () => {
|
||||
await accountService.switchAccount(userId);
|
||||
environmentService.environment$ = of(environmentFactory(activeApiUrl));
|
||||
environmentService.globalEnvironment$ = of(environmentFactory(activeApiUrl));
|
||||
|
||||
globalState.stateSubject.next({
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl + "global"),
|
||||
@@ -236,7 +228,8 @@ describe("ConfigService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
environmentSubject = new Subject<Environment>();
|
||||
environmentService.environment$ = environmentSubject;
|
||||
environmentService.globalEnvironment$ = environmentSubject;
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
@@ -327,7 +320,8 @@ describe("ConfigService", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
const config = serverConfigFactory("existing-data", tooOld);
|
||||
environmentService.environment$ = environmentSubject;
|
||||
environmentService.globalEnvironment$ = environmentSubject;
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
|
||||
globalState.stateSubject.next({ [apiUrl(0)]: config });
|
||||
userState.stateSubject.next({
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
map,
|
||||
mergeWith,
|
||||
NEVER,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
ReplaySubject,
|
||||
share,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
@@ -50,11 +51,15 @@ export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, A
|
||||
},
|
||||
);
|
||||
|
||||
const environmentComparer = (previous: Environment, current: Environment) => {
|
||||
return previous.getApiUrl() === current.getApiUrl();
|
||||
};
|
||||
|
||||
// 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>();
|
||||
private failedFetchFallbackSubject = new Subject<ServerConfig | null>();
|
||||
|
||||
serverConfig$: Observable<ServerConfig>;
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
|
||||
serverSettings$: Observable<ServerSettings>;
|
||||
|
||||
@@ -67,25 +72,51 @@ export class DefaultConfigService implements ConfigService {
|
||||
private stateProvider: StateProvider,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
const userId$ = this.stateProvider.activeUserId$;
|
||||
const authStatus$ = userId$.pipe(
|
||||
switchMap((userId) => (userId == null ? of(null) : this.authService.authStatusFor$(userId))),
|
||||
const globalConfig$ = this.environmentService.globalEnvironment$.pipe(
|
||||
distinctUntilChanged(environmentComparer),
|
||||
switchMap((environment) =>
|
||||
this.globalConfigFor$(environment.getApiUrl()).pipe(
|
||||
map((config) => {
|
||||
return [config, null as UserId | null, environment] as const;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.serverConfig$ = combineLatest([
|
||||
userId$,
|
||||
this.environmentService.environment$,
|
||||
authStatus$,
|
||||
]).pipe(
|
||||
switchMap(([userId, environment, authStatus]) => {
|
||||
if (userId == null || authStatus !== AuthenticationStatus.Unlocked) {
|
||||
return this.globalConfigFor$(environment.getApiUrl()).pipe(
|
||||
map((config) => [config, null, environment] as const),
|
||||
);
|
||||
this.serverConfig$ = this.stateProvider.activeUserId$.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((userId) => {
|
||||
if (userId == null) {
|
||||
// Global
|
||||
return globalConfig$;
|
||||
}
|
||||
|
||||
return this.userConfigFor$(userId).pipe(
|
||||
map((config) => [config, userId, environment] as const),
|
||||
return this.authService.authStatusFor$(userId).pipe(
|
||||
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
|
||||
distinctUntilChanged(),
|
||||
switchMap((isUnlocked) => {
|
||||
if (!isUnlocked) {
|
||||
return globalConfig$;
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.environmentService
|
||||
.getEnvironment$(userId)
|
||||
.pipe(distinctUntilChanged(environmentComparer)),
|
||||
this.userConfigFor$(userId),
|
||||
]).pipe(
|
||||
switchMap(([environment, config]) => {
|
||||
if (config == null) {
|
||||
// If the user doesn't have any config yet, use the global config for that url as the fallback
|
||||
return this.globalConfigFor$(environment.getApiUrl()).pipe(
|
||||
map((globalConfig) => [globalConfig, userId, environment] as const),
|
||||
);
|
||||
}
|
||||
|
||||
return of([config, userId, environment] as const);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
tap(async (rec) => {
|
||||
@@ -106,7 +137,7 @@ export class DefaultConfigService implements ConfigService {
|
||||
}),
|
||||
// If fetch fails, we'll emit on this subject to fallback to the existing config
|
||||
mergeWith(this.failedFetchFallbackSubject),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
share({ connector: () => new ReplaySubject(1), resetOnRefCountZero: () => timer(1000) }),
|
||||
);
|
||||
|
||||
this.cloudRegion$ = this.serverConfig$.pipe(
|
||||
@@ -155,8 +186,8 @@ export class DefaultConfigService implements ConfigService {
|
||||
|
||||
// Updates the on-disk configuration with a newly retrieved configuration
|
||||
private async renewConfig(
|
||||
existingConfig: ServerConfig,
|
||||
userId: UserId,
|
||||
existingConfig: ServerConfig | null,
|
||||
userId: UserId | null,
|
||||
environment: Environment,
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -164,9 +195,7 @@ export class DefaultConfigService implements ConfigService {
|
||||
// somewhat quickly even though it may not be accurate, we won't cancel the HTTP request
|
||||
// though so that hopefully it can have finished and hydrated a more accurate value.
|
||||
const handle = setTimeout(() => {
|
||||
this.logService.info(
|
||||
"Self-host environment did not respond in time, emitting previous config.",
|
||||
);
|
||||
this.logService.info("Environment did not respond in time, emitting previous config.");
|
||||
this.failedFetchFallbackSubject.next(existingConfig);
|
||||
}, SLOW_EMISSION_GUARD);
|
||||
const response = await this.configApiService.get(userId);
|
||||
@@ -199,13 +228,13 @@ export class DefaultConfigService implements ConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
private globalConfigFor$(apiUrl: string): Observable<ServerConfig> {
|
||||
private globalConfigFor$(apiUrl: string): Observable<ServerConfig | null> {
|
||||
return this.stateProvider
|
||||
.getGlobal(GLOBAL_SERVER_CONFIGURATIONS)
|
||||
.state$.pipe(map((configs) => configs?.[apiUrl]));
|
||||
.state$.pipe(map((configs) => configs?.[apiUrl] ?? null));
|
||||
}
|
||||
|
||||
private userConfigFor$(userId: UserId): Observable<ServerConfig> {
|
||||
private userConfigFor$(userId: UserId): Observable<ServerConfig | null> {
|
||||
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { interceptConsole, restoreConsole } from "../../../spec";
|
||||
import { ConsoleLogService } from "@bitwarden/logging";
|
||||
|
||||
import { ConsoleLogService } from "./console-log.service";
|
||||
import { interceptConsole, restoreConsole } from "../../../spec";
|
||||
|
||||
describe("ConsoleLogService", () => {
|
||||
const error = new Error("this is an error");
|
||||
|
||||
@@ -1,59 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { LogService as LogServiceAbstraction } from "../abstractions/log.service";
|
||||
import { LogLevelType } from "../enums/log-level-type.enum";
|
||||
|
||||
export class ConsoleLogService implements LogServiceAbstraction {
|
||||
protected timersMap: Map<string, [number, number]> = new Map();
|
||||
|
||||
constructor(
|
||||
protected isDev: boolean,
|
||||
protected filter: (level: LogLevelType) => boolean = null,
|
||||
) {}
|
||||
|
||||
debug(message?: any, ...optionalParams: any[]) {
|
||||
if (!this.isDev) {
|
||||
return;
|
||||
}
|
||||
this.write(LogLevelType.Debug, message, ...optionalParams);
|
||||
}
|
||||
|
||||
info(message?: any, ...optionalParams: any[]) {
|
||||
this.write(LogLevelType.Info, message, ...optionalParams);
|
||||
}
|
||||
|
||||
warning(message?: any, ...optionalParams: any[]) {
|
||||
this.write(LogLevelType.Warning, message, ...optionalParams);
|
||||
}
|
||||
|
||||
error(message?: any, ...optionalParams: any[]) {
|
||||
this.write(LogLevelType.Error, message, ...optionalParams);
|
||||
}
|
||||
|
||||
write(level: LogLevelType, message?: any, ...optionalParams: any[]) {
|
||||
if (this.filter != null && this.filter(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case LogLevelType.Debug:
|
||||
// eslint-disable-next-line
|
||||
console.log(message, ...optionalParams);
|
||||
break;
|
||||
case LogLevelType.Info:
|
||||
// eslint-disable-next-line
|
||||
console.log(message, ...optionalParams);
|
||||
break;
|
||||
case LogLevelType.Warning:
|
||||
// eslint-disable-next-line
|
||||
console.warn(message, ...optionalParams);
|
||||
break;
|
||||
case LogLevelType.Error:
|
||||
// eslint-disable-next-line
|
||||
console.error(message, ...optionalParams);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
export { ConsoleLogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -133,6 +133,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
);
|
||||
|
||||
environment$: Observable<Environment>;
|
||||
globalEnvironment$: Observable<Environment>;
|
||||
cloudWebVaultUrl$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
@@ -148,6 +149,10 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
distinctUntilChanged((oldUserId: UserId, newUserId: UserId) => oldUserId == newUserId),
|
||||
);
|
||||
|
||||
this.globalEnvironment$ = this.stateProvider
|
||||
.getGlobal(GLOBAL_ENVIRONMENT_KEY)
|
||||
.state$.pipe(map((state) => this.buildEnvironment(state?.region, state?.urls)));
|
||||
|
||||
this.environment$ = account$.pipe(
|
||||
switchMap((userId) => {
|
||||
const t = userId
|
||||
@@ -263,7 +268,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
return new SelfHostedEnvironment(urls);
|
||||
}
|
||||
|
||||
async setCloudRegion(userId: UserId, region: CloudRegion) {
|
||||
async setCloudRegion(userId: UserId | null, region: CloudRegion) {
|
||||
if (userId == null) {
|
||||
await this.globalCloudRegionState.update(() => region);
|
||||
} else {
|
||||
@@ -271,7 +276,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
}
|
||||
}
|
||||
|
||||
getEnvironment$(userId: UserId): Observable<Environment | undefined> {
|
||||
getEnvironment$(userId: UserId): Observable<Environment> {
|
||||
return this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$.pipe(
|
||||
map((state) => {
|
||||
return this.buildEnvironment(state?.region, state?.urls);
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("credential-id-utils", () => {
|
||||
new Uint8Array([
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7,
|
||||
]),
|
||||
]).buffer,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("credential-id-utils", () => {
|
||||
new Uint8Array([
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7,
|
||||
]),
|
||||
]).buffer,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { Fido2Utils } from "./fido2-utils";
|
||||
import { guidToRawFormat } from "./guid-utils";
|
||||
|
||||
export function parseCredentialId(encodedCredentialId: string): Uint8Array {
|
||||
export function parseCredentialId(encodedCredentialId: string): ArrayBuffer {
|
||||
try {
|
||||
if (encodedCredentialId.startsWith("b64.")) {
|
||||
return Fido2Utils.stringToBuffer(encodedCredentialId.slice(4));
|
||||
}
|
||||
|
||||
return guidToRawFormat(encodedCredentialId);
|
||||
return guidToRawFormat(encodedCredentialId).buffer;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -18,13 +18,16 @@ export function parseCredentialId(encodedCredentialId: string): Uint8Array {
|
||||
/**
|
||||
* Compares two credential IDs for equality.
|
||||
*/
|
||||
export function compareCredentialIds(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) {
|
||||
export function compareCredentialIds(a: ArrayBuffer, b: ArrayBuffer): boolean {
|
||||
if (a.byteLength !== b.byteLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
const viewA = new Uint8Array(a);
|
||||
const viewB = new Uint8Array(b);
|
||||
|
||||
for (let i = 0; i < viewA.length; i++) {
|
||||
if (viewA[i] !== viewB[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { mockAccountServiceWith } from "../../../../spec";
|
||||
import { Account } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { CipherId, UserId } from "../../../types/guid";
|
||||
import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service";
|
||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../../../vault/enums/cipher-type";
|
||||
import { CipherData } from "../../../vault/models/data/cipher.data";
|
||||
import { Cipher } from "../../../vault/models/domain/cipher";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
|
||||
@@ -36,8 +37,9 @@ type ParentWindowReference = string;
|
||||
const RpId = "bitwarden.com";
|
||||
|
||||
describe("FidoAuthenticatorService", () => {
|
||||
const userId = "testId" as UserId;
|
||||
const activeAccountSubject = new BehaviorSubject<Account | null>({
|
||||
id: "testId" as UserId,
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
@@ -217,9 +219,11 @@ describe("FidoAuthenticatorService", () => {
|
||||
beforeEach(async () => {
|
||||
existingCipher = createCipherView({ type: CipherType.Login });
|
||||
params = await createParams({ requireResidentKey: false });
|
||||
cipherService.get.mockImplementation(async (id) =>
|
||||
id === existingCipher.id ? ({ decrypt: () => existingCipher } as any) : undefined,
|
||||
|
||||
cipherService.ciphers$.mockImplementation((userId: UserId) =>
|
||||
of({ [existingCipher.id as CipherId]: {} as CipherData }),
|
||||
);
|
||||
|
||||
cipherService.getAllDecrypted.mockResolvedValue([existingCipher]);
|
||||
cipherService.decrypt.mockResolvedValue(existingCipher);
|
||||
});
|
||||
@@ -254,7 +258,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext);
|
||||
|
||||
await authenticator.makeCredential(params, windowReference);
|
||||
|
||||
@@ -325,7 +329,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext);
|
||||
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params, windowReference);
|
||||
@@ -350,20 +354,21 @@ describe("FidoAuthenticatorService", () => {
|
||||
cipherId,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.get.mockImplementation(async (cipherId) =>
|
||||
cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined,
|
||||
cipherService.ciphers$.mockImplementation((userId: UserId) =>
|
||||
of({ [cipher.id as CipherId]: {} as CipherData }),
|
||||
);
|
||||
|
||||
cipherService.getAllDecrypted.mockResolvedValue([await cipher]);
|
||||
cipherService.decrypt.mockResolvedValue(cipher);
|
||||
cipherService.encrypt.mockImplementation(async (cipher) => {
|
||||
cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
|
||||
return {} as any;
|
||||
return { cipher: {} as any as Cipher, encryptedFor: userId };
|
||||
});
|
||||
cipherService.createWithServer.mockImplementation(async (cipher) => {
|
||||
cipherService.createWithServer.mockImplementation(async ({ cipher }) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
cipherService.updateWithServer.mockImplementation(async (cipher) => {
|
||||
cipherService.updateWithServer.mockImplementation(async ({ cipher }) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { getUserId } from "../../../auth/services/account.service";
|
||||
import { CipherId } from "../../../types/guid";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../../../vault/enums/cipher-type";
|
||||
import { Cipher } from "../../../vault/models/domain/cipher";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
|
||||
import {
|
||||
@@ -149,7 +151,23 @@ export class Fido2AuthenticatorService<ParentWindowReference>
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
);
|
||||
const encrypted = await this.cipherService.get(cipherId, activeUserId);
|
||||
|
||||
const encrypted = await firstValueFrom(
|
||||
this.cipherService.ciphers$(activeUserId).pipe(
|
||||
map((ciphers) => ciphers[cipherId as CipherId]),
|
||||
filter((c) => c !== undefined),
|
||||
timeout({
|
||||
first: 5000,
|
||||
with: () => {
|
||||
this.logService?.error(
|
||||
`[Fido2Authenticator] Aborting because cipher with ID ${cipherId} could not be found within timeout.`,
|
||||
);
|
||||
throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown);
|
||||
},
|
||||
}),
|
||||
map((c) => new Cipher(c, null)),
|
||||
),
|
||||
);
|
||||
|
||||
cipher = await this.cipherService.decrypt(encrypted, activeUserId);
|
||||
|
||||
@@ -496,7 +514,7 @@ async function getPrivateKeyFromFido2Credential(
|
||||
const keyBuffer = Fido2Utils.stringToBuffer(fido2Credential.keyValue);
|
||||
return await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
keyBuffer,
|
||||
new Uint8Array(keyBuffer),
|
||||
{
|
||||
name: fido2Credential.keyAlgorithm,
|
||||
namedCurve: fido2Credential.keyCurve,
|
||||
|
||||
@@ -92,6 +92,27 @@ describe("FidoAuthenticatorService", () => {
|
||||
});
|
||||
|
||||
describe("createCredential", () => {
|
||||
describe("Mapping params should handle variations in input formats", () => {
|
||||
it.each([
|
||||
[true, true],
|
||||
[false, false],
|
||||
["false", false],
|
||||
["", false],
|
||||
["true", true],
|
||||
])("requireResidentKey should handle %s as boolean %s", async (input, expected) => {
|
||||
const params = createParams({
|
||||
authenticatorSelection: { requireResidentKey: input as any },
|
||||
extensions: { credProps: true },
|
||||
});
|
||||
|
||||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||||
|
||||
const result = await client.createCredential(params, windowReference);
|
||||
|
||||
expect(result.extensions.credProps?.rk).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("input parameters validation", () => {
|
||||
// Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException.
|
||||
it("should throw error if sameOriginWithAncestors is false", async () => {
|
||||
|
||||
@@ -127,9 +127,9 @@ export class Fido2ClientService<ParentWindowReference>
|
||||
}
|
||||
|
||||
const userId = Fido2Utils.stringToBuffer(params.user.id);
|
||||
if (userId.length < 1 || userId.length > 64) {
|
||||
if (userId.byteLength < 1 || userId.byteLength > 64) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.length})`,
|
||||
`[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.byteLength})`,
|
||||
);
|
||||
throw new TypeError("Invalid 'user.id' length");
|
||||
}
|
||||
@@ -483,11 +483,15 @@ function mapToMakeCredentialParams({
|
||||
type: credential.type,
|
||||
})) ?? [];
|
||||
|
||||
/**
|
||||
* Quirk: Accounts for the fact that some RP's mistakenly submits 'requireResidentKey' as a string
|
||||
*/
|
||||
const requireResidentKey =
|
||||
params.authenticatorSelection?.residentKey === "required" ||
|
||||
params.authenticatorSelection?.residentKey === "preferred" ||
|
||||
(params.authenticatorSelection?.residentKey === undefined &&
|
||||
params.authenticatorSelection?.requireResidentKey === true);
|
||||
(params.authenticatorSelection?.requireResidentKey === true ||
|
||||
(params.authenticatorSelection?.requireResidentKey as unknown as string) === "true"));
|
||||
|
||||
const requireUserVerification =
|
||||
params.authenticatorSelection?.userVerification === "required" ||
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
import type {
|
||||
AssertCredentialResult,
|
||||
CreateCredentialResult,
|
||||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
// @ts-strict-ignore
|
||||
export class Fido2Utils {
|
||||
static createResultToJson(result: CreateCredentialResult): any {
|
||||
return {
|
||||
id: result.credentialId,
|
||||
rawId: result.credentialId,
|
||||
response: {
|
||||
clientDataJSON: result.clientDataJSON,
|
||||
authenticatorData: result.authData,
|
||||
transports: result.transports,
|
||||
publicKey: result.publicKey,
|
||||
publicKeyAlgorithm: result.publicKeyAlgorithm,
|
||||
attestationObject: result.attestationObject,
|
||||
},
|
||||
authenticatorAttachment: "platform",
|
||||
clientExtensionResults: result.extensions,
|
||||
type: "public-key",
|
||||
};
|
||||
}
|
||||
|
||||
static getResultToJson(result: AssertCredentialResult): any {
|
||||
return {
|
||||
id: result.credentialId,
|
||||
rawId: result.credentialId,
|
||||
response: {
|
||||
clientDataJSON: result.clientDataJSON,
|
||||
authenticatorData: result.authenticatorData,
|
||||
signature: result.signature,
|
||||
userHandle: result.userHandle,
|
||||
},
|
||||
authenticatorAttachment: "platform",
|
||||
clientExtensionResults: {},
|
||||
type: "public-key",
|
||||
};
|
||||
}
|
||||
|
||||
static bufferToString(bufferSource: BufferSource): string {
|
||||
return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource))
|
||||
.replace(/\+/g, "-")
|
||||
@@ -8,8 +47,8 @@ export class Fido2Utils {
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
static stringToBuffer(str: string): Uint8Array {
|
||||
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str));
|
||||
static stringToBuffer(str: string): ArrayBuffer {
|
||||
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str)).buffer;
|
||||
}
|
||||
|
||||
static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array {
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.*/
|
||||
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
|
||||
/** Private array used for optimization */
|
||||
const byteToHex = Array.from({ length: 256 }, (_, i) => (i + 0x100).toString(16).substring(1));
|
||||
|
||||
/** Convert standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID to raw 16 byte array. */
|
||||
export function guidToRawFormat(guid: string) {
|
||||
if (!isValidGuid(guid)) {
|
||||
if (!Utils.isGuid(guid)) {
|
||||
throw TypeError("GUID parameter is invalid");
|
||||
}
|
||||
|
||||
@@ -81,15 +83,13 @@ export function guidToStandardFormat(bufferSource: BufferSource) {
|
||||
).toLowerCase();
|
||||
|
||||
// Consistency check for valid UUID. If this throws, it's likely due to one
|
||||
// or more input array values not mapping to a hex octet (leading to "undefined" in the uuid)
|
||||
if (!isValidGuid(guid)) {
|
||||
// of the following:
|
||||
// - One or more input array values don't map to a hex octet (leading to
|
||||
// "undefined" in the uuid)
|
||||
// - Invalid input values for the RFC `version` or `variant` fields
|
||||
if (!Utils.isGuid(guid)) {
|
||||
throw TypeError("Converted GUID is invalid");
|
||||
}
|
||||
|
||||
return guid;
|
||||
}
|
||||
|
||||
// Perform format validation, without enforcing any variant restrictions as Utils.isGuid does
|
||||
function isValidGuid(guid: string): boolean {
|
||||
return RegExp(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/, "i").test(guid);
|
||||
}
|
||||
|
||||
@@ -1,47 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { AbstractStorageService, StorageUpdate } from "../abstractions/storage.service";
|
||||
|
||||
export class MemoryStorageService extends AbstractStorageService {
|
||||
protected store = new Map<string, unknown>();
|
||||
private updatesSubject = new Subject<StorageUpdate>();
|
||||
|
||||
get valuesRequireDeserialization(): boolean {
|
||||
return false;
|
||||
}
|
||||
get updates$() {
|
||||
return this.updatesSubject.asObservable();
|
||||
}
|
||||
|
||||
get<T>(key: string): Promise<T> {
|
||||
if (this.store.has(key)) {
|
||||
const obj = this.store.get(key);
|
||||
return Promise.resolve(obj as T);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return (await this.get(key)) != null;
|
||||
}
|
||||
|
||||
save<T>(key: string, obj: T): Promise<void> {
|
||||
if (obj == null) {
|
||||
return this.remove(key);
|
||||
}
|
||||
// TODO: Remove once foreground/background contexts are separated in browser
|
||||
// Needed to ensure ownership of all memory by the context running the storage service
|
||||
const toStore = structuredClone(obj);
|
||||
this.store.set(key, toStore);
|
||||
this.updatesSubject.next({ key, updateType: "save" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
remove(key: string): Promise<void> {
|
||||
this.store.delete(key);
|
||||
this.updatesSubject.next({ key, updateType: "remove" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
export { MemoryStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
BitwardenClient,
|
||||
ClientSettings,
|
||||
DeviceType as SdkDeviceType,
|
||||
TokenProvider,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { EncryptedOrganizationKeyData } from "../../../admin-console/models/data/encrypted-organization-key.data";
|
||||
@@ -41,6 +42,17 @@ import { EncryptedString } from "../../models/domain/enc-string";
|
||||
// blocking the creation of an internal client for that user.
|
||||
const UnsetClient = Symbol("UnsetClient");
|
||||
|
||||
/**
|
||||
* A token provider that exposes the access token to the SDK.
|
||||
*/
|
||||
class JsTokenProvider implements TokenProvider {
|
||||
constructor() {}
|
||||
|
||||
async get_access_token(): Promise<string | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultSdkService implements SdkService {
|
||||
private sdkClientOverrides = new BehaviorSubject<{
|
||||
[userId: UserId]: Rc<BitwardenClient> | typeof UnsetClient;
|
||||
@@ -51,7 +63,7 @@ export class DefaultSdkService implements SdkService {
|
||||
concatMap(async (env) => {
|
||||
await SdkLoadService.Ready;
|
||||
const settings = this.toSettings(env);
|
||||
return await this.sdkClientFactory.createSdkClient(settings);
|
||||
return await this.sdkClientFactory.createSdkClient(new JsTokenProvider(), settings);
|
||||
}),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
@@ -151,7 +163,10 @@ export class DefaultSdkService implements SdkService {
|
||||
}
|
||||
|
||||
const settings = this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(settings);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(),
|
||||
settings,
|
||||
);
|
||||
|
||||
await this.initializeClient(
|
||||
userId,
|
||||
@@ -180,9 +195,7 @@ export class DefaultSdkService implements SdkService {
|
||||
return () => client?.markForDisposal();
|
||||
});
|
||||
}),
|
||||
tap({
|
||||
finalize: () => this.sdkClientCache.delete(userId),
|
||||
}),
|
||||
tap({ finalize: () => this.sdkClientCache.delete(userId) }),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
@@ -205,9 +218,7 @@ export class DefaultSdkService implements SdkService {
|
||||
method: { decryptedKey: { decrypted_user_key: userKey.keyB64 } },
|
||||
kdfParams:
|
||||
kdfParams.kdfType === KdfType.PBKDF2_SHA256
|
||||
? {
|
||||
pBKDF2: { iterations: kdfParams.iterations },
|
||||
}
|
||||
? { pBKDF2: { iterations: kdfParams.iterations } }
|
||||
: {
|
||||
argon2id: {
|
||||
iterations: kdfParams.iterations,
|
||||
@@ -216,6 +227,7 @@ export class DefaultSdkService implements SdkService {
|
||||
},
|
||||
},
|
||||
privateKey,
|
||||
signingKey: undefined,
|
||||
});
|
||||
|
||||
// We initialize the org crypto even if the org_keys are
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
|
||||
|
||||
import { StorageServiceProvider } from "./storage-service.provider";
|
||||
|
||||
describe("StorageServiceProvider", () => {
|
||||
const mockDiskStorage = mock<AbstractStorageService & ObservableStorageService>();
|
||||
const mockMemoryStorage = mock<AbstractStorageService & ObservableStorageService>();
|
||||
|
||||
const sut = new StorageServiceProvider(mockDiskStorage, mockMemoryStorage);
|
||||
|
||||
describe("get", () => {
|
||||
it("gets disk service when default location is disk", () => {
|
||||
const [computedLocation, computedService] = sut.get("disk", {});
|
||||
|
||||
expect(computedLocation).toBe("disk");
|
||||
expect(computedService).toStrictEqual(mockDiskStorage);
|
||||
});
|
||||
|
||||
it("gets memory service when default location is memory", () => {
|
||||
const [computedLocation, computedService] = sut.get("memory", {});
|
||||
|
||||
expect(computedLocation).toBe("memory");
|
||||
expect(computedService).toStrictEqual(mockMemoryStorage);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,2 @@
|
||||
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
import { ClientLocations, StorageLocation } from "../state/state-definition";
|
||||
|
||||
export type PossibleLocation = StorageLocation | ClientLocations[keyof ClientLocations];
|
||||
|
||||
/**
|
||||
* A provider for getting client specific computed storage locations and services.
|
||||
*/
|
||||
export class StorageServiceProvider {
|
||||
constructor(
|
||||
protected readonly diskStorageService: AbstractStorageService & ObservableStorageService,
|
||||
protected readonly memoryStorageService: AbstractStorageService & ObservableStorageService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Computes the location and corresponding service for a given client.
|
||||
*
|
||||
* **NOTE** The default implementation does not respect client overrides and if clients
|
||||
* have special overrides they are responsible for implementing this service.
|
||||
* @param defaultLocation The default location to use if no client specific override is preferred.
|
||||
* @param overrides Client specific overrides
|
||||
* @returns The computed storage location and corresponding storage service to use to get/store state.
|
||||
* @throws If there is no configured storage service for the given inputs.
|
||||
*/
|
||||
get(
|
||||
defaultLocation: PossibleLocation,
|
||||
overrides: Partial<ClientLocations>,
|
||||
): [location: PossibleLocation, service: AbstractStorageService & ObservableStorageService] {
|
||||
switch (defaultLocation) {
|
||||
case "disk":
|
||||
return [defaultLocation, this.diskStorageService];
|
||||
case "memory":
|
||||
return [defaultLocation, this.memoryStorageService];
|
||||
default:
|
||||
throw new Error(`Unexpected location: ${defaultLocation}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
export { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
export type { PossibleLocation } from "@bitwarden/storage-core";
|
||||
|
||||
@@ -6,12 +6,13 @@ import { any, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, map, of, timeout } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { awaitAsync, trackEmissions } from "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { Account } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { GlobalStateProvider } from "../global-state.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { SingleUserState } from "../user-state";
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Observable, combineLatest, of } from "rxjs";
|
||||
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { CombinedState, SingleUserState } from "../user-state";
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { mockAccountServiceWith } from "../../../../spec/fake-account-service";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
} from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { StorageKey } from "../../../types/state";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { DebugOptions } from "../key-definition";
|
||||
import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AbstractStorageService } from "../../abstractions/storage.service";
|
||||
import { AbstractStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
export async function getStoredValue<T>(
|
||||
key: string,
|
||||
|
||||
@@ -1,45 +1,7 @@
|
||||
/**
|
||||
* Default storage location options.
|
||||
*
|
||||
* `disk` generally means state that is accessible between restarts of the application,
|
||||
* with the exception of the web client. In web this means `sessionStorage`. The data
|
||||
* persists through refreshes of the page but not available once that tab is closed or
|
||||
* from any other tabs.
|
||||
*
|
||||
* `memory` means that the information stored there goes away during application
|
||||
* restarts.
|
||||
*/
|
||||
export type StorageLocation = "disk" | "memory";
|
||||
import { StorageLocation, ClientLocations } from "@bitwarden/storage-core";
|
||||
|
||||
/**
|
||||
* *Note*: The property names of this object should match exactly with the string values of the {@link ClientType} enum
|
||||
*/
|
||||
export type ClientLocations = {
|
||||
/**
|
||||
* Overriding storage location for the web client.
|
||||
*
|
||||
* Includes an extra storage location to store data in `localStorage`
|
||||
* that is available from different tabs and after a tab has closed.
|
||||
*/
|
||||
web: StorageLocation | "disk-local";
|
||||
/**
|
||||
* Overriding storage location for browser clients.
|
||||
*
|
||||
* `"memory-large-object"` is used to store non-countable objects in memory. This exists due to limited persistent memory available to browser extensions.
|
||||
*
|
||||
* `"disk-backup-local-storage"` is used to store object in both disk and in `localStorage`. Data is stored in both locations but is only retrieved
|
||||
* from `localStorage` when a null-ish value is retrieved from disk first.
|
||||
*/
|
||||
browser: StorageLocation | "memory-large-object" | "disk-backup-local-storage";
|
||||
/**
|
||||
* Overriding storage location for desktop clients.
|
||||
*/
|
||||
//desktop: StorageLocation;
|
||||
/**
|
||||
* Overriding storage location for CLI clients.
|
||||
*/
|
||||
//cli: StorageLocation;
|
||||
};
|
||||
// To be removed once references are updated to point to @bitwarden/storage-core
|
||||
export { StorageLocation, ClientLocations };
|
||||
|
||||
/**
|
||||
* Defines the base location and instruction of where this state is expected to be located.
|
||||
|
||||
@@ -106,6 +106,9 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne
|
||||
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const BADGE_MEMORY = new StateDefinition("badge", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
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", {
|
||||
@@ -128,6 +131,9 @@ export const EXTENSION_INITIAL_INSTALL_DISK = new StateDefinition(
|
||||
"extensionInitialInstall",
|
||||
"disk",
|
||||
);
|
||||
export const WEB_PUSH_SUBSCRIPTION = new StateDefinition("webPushSubscription", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
// Design System
|
||||
|
||||
@@ -152,6 +158,7 @@ export const SEND_DISK = new StateDefinition("encryptedSend", "disk", {
|
||||
export const SEND_MEMORY = new StateDefinition("decryptedSend", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
export const SEND_ACCESS_AUTH_MEMORY = new StateDefinition("sendAccessAuth", "memory");
|
||||
|
||||
// Vault
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
StorageServiceProvider,
|
||||
} from "@bitwarden/storage-core";
|
||||
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { StateDefinition } from "./state-definition";
|
||||
import { STATE_LOCK_EVENT, StateEventRegistrarService } from "./state-event-registrar.service";
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
StorageServiceProvider,
|
||||
} from "@bitwarden/storage-core";
|
||||
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { STATE_LOCK_EVENT } from "./state-event-registrar.service";
|
||||
import { StateEventRunnerService } from "./state-event-runner.service";
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { GlobalState } from "./global-state";
|
||||
import { GlobalStateProvider } from "./global-state.provider";
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { MemoryStorageService } from "./memory-storage.service";
|
||||
|
||||
describe("MemoryStorageService", () => {
|
||||
let sut: MemoryStorageService;
|
||||
const key = "key";
|
||||
const value = { test: "value" };
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new MemoryStorageService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should return null if the key does not exist", async () => {
|
||||
const result = await sut.get(key);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the value if the key exists", async () => {
|
||||
await sut.save(key, value);
|
||||
const result = await sut.get(key);
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
|
||||
it("should json parse stored values", async () => {
|
||||
sut["store"][key] = JSON.stringify({ test: "value" });
|
||||
const result = await sut.get(key);
|
||||
|
||||
expect(result).toEqual({ test: "value" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("save", () => {
|
||||
it("should store the value as json string", async () => {
|
||||
const value = { test: "value" };
|
||||
await sut.save(key, value);
|
||||
|
||||
expect(sut["store"][key]).toEqual(JSON.stringify(value));
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should remove a value from store", async () => {
|
||||
await sut.save(key, value);
|
||||
await sut.remove(key);
|
||||
|
||||
expect(Object.keys(sut["store"])).not.toContain(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
StorageUpdate,
|
||||
} from "../../abstractions/storage.service";
|
||||
|
||||
export class MemoryStorageService
|
||||
extends AbstractStorageService
|
||||
implements ObservableStorageService
|
||||
{
|
||||
protected store: Record<string, string> = {};
|
||||
private updatesSubject = new Subject<StorageUpdate>();
|
||||
|
||||
get valuesRequireDeserialization(): boolean {
|
||||
return true;
|
||||
}
|
||||
get updates$() {
|
||||
return this.updatesSubject.asObservable();
|
||||
}
|
||||
|
||||
get<T>(key: string): Promise<T> {
|
||||
const json = this.store[key];
|
||||
if (json) {
|
||||
const obj = JSON.parse(json as string);
|
||||
return Promise.resolve(obj as T);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return (await this.get(key)) != null;
|
||||
}
|
||||
|
||||
save<T>(key: string, obj: T): Promise<void> {
|
||||
if (obj == null) {
|
||||
return this.remove(key);
|
||||
}
|
||||
// TODO: Remove once foreground/background contexts are separated in browser
|
||||
// Needed to ensure ownership of all memory by the context running the storage service
|
||||
this.store[key] = JSON.stringify(obj);
|
||||
this.updatesSubject.next({ key, updateType: "save" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
remove(key: string): Promise<void> {
|
||||
delete this.store[key];
|
||||
this.updatesSubject.next({ key, updateType: "remove" });
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
export { SerializedMemoryStorageService as MemoryStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
@@ -225,7 +225,10 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
throw new Error("Stamp has changed");
|
||||
}
|
||||
|
||||
await this.keyService.setMasterKeyEncryptedUserKey(response.key, response.id);
|
||||
// Users with no master password will not have a key.
|
||||
if (response?.key) {
|
||||
await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, response.id);
|
||||
}
|
||||
await this.keyService.setPrivateKey(response.privateKey, response.id);
|
||||
await this.keyService.setProviderKeys(response.providers, response.id);
|
||||
await this.keyService.setOrgKeys(
|
||||
|
||||
Reference in New Issue
Block a user