mirror of
https://github.com/bitwarden/browser
synced 2026-02-26 01:23:24 +00:00
[PM-16790] introduce extension service (#13590)
This commit is contained in:
136
libs/common/src/tools/extension/extension.service.spec.ts
Normal file
136
libs/common/src/tools/extension/extension.service.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, awaitAsync } from "../../../spec";
|
||||
import { Account } from "../../auth/abstractions/account.service";
|
||||
import { EXTENSION_DISK, UserKeyDefinition } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { LegacyEncryptorProvider } from "../cryptography/legacy-encryptor-provider";
|
||||
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
|
||||
import { disabledSemanticLoggerProvider } from "../log";
|
||||
import { UserStateSubjectDependencyProvider } from "../state/user-state-subject-dependency-provider";
|
||||
|
||||
import { Site } from "./data";
|
||||
import { ExtensionRegistry } from "./extension-registry.abstraction";
|
||||
import { ExtensionSite } from "./extension-site";
|
||||
import { ExtensionService } from "./extension.service";
|
||||
import { ExtensionMetadata, ExtensionProfileMetadata, ExtensionStorageKey } from "./type";
|
||||
import { Vendor } from "./vendor/data";
|
||||
import { SimpleLogin } from "./vendor/simplelogin";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomeAccount = {
|
||||
id: SomeUser,
|
||||
email: "someone@example.com",
|
||||
emailVerified: true,
|
||||
name: "Someone",
|
||||
};
|
||||
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);
|
||||
|
||||
type TestType = { foo: string };
|
||||
|
||||
const SomeEncryptor: UserEncryptor = {
|
||||
userId: SomeUser,
|
||||
|
||||
encrypt(secret) {
|
||||
const tmp: any = secret;
|
||||
return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any);
|
||||
},
|
||||
|
||||
decrypt(secret) {
|
||||
const tmp: any = JSON.parse(secret.encryptedString!);
|
||||
return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any);
|
||||
},
|
||||
};
|
||||
|
||||
const SomeAccountService = new FakeAccountService({
|
||||
[SomeUser]: SomeAccount,
|
||||
});
|
||||
|
||||
const SomeStateProvider = new FakeStateProvider(SomeAccountService);
|
||||
|
||||
const SomeProvider = {
|
||||
encryptor: {
|
||||
userEncryptor$: () => {
|
||||
return new BehaviorSubject({ encryptor: SomeEncryptor, userId: SomeUser }).asObservable();
|
||||
},
|
||||
organizationEncryptor$() {
|
||||
throw new Error("`organizationEncryptor$` should never be invoked.");
|
||||
},
|
||||
} as LegacyEncryptorProvider,
|
||||
state: SomeStateProvider,
|
||||
log: disabledSemanticLoggerProvider,
|
||||
} as UserStateSubjectDependencyProvider;
|
||||
|
||||
const SomeExtension: ExtensionMetadata = {
|
||||
site: { id: "forwarder", availableFields: [] },
|
||||
product: { vendor: SimpleLogin },
|
||||
host: {
|
||||
selfHost: "maybe",
|
||||
baseUrl: "https://www.example.com/",
|
||||
authentication: true,
|
||||
},
|
||||
requestedFields: [],
|
||||
};
|
||||
|
||||
const SomeRegistry = mock<ExtensionRegistry>();
|
||||
|
||||
const SomeProfileMetadata = {
|
||||
type: "extension",
|
||||
site: Site.forwarder,
|
||||
storage: {
|
||||
key: "someProfile",
|
||||
options: {
|
||||
deserializer: (value) => value as TestType,
|
||||
clearOn: [],
|
||||
},
|
||||
} as ExtensionStorageKey<TestType>,
|
||||
} satisfies ExtensionProfileMetadata<TestType, "forwarder">;
|
||||
|
||||
describe("ExtensionService", () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
it("writes to the user's state", async () => {
|
||||
const extension = new ExtensionService(SomeRegistry, SomeProvider);
|
||||
SomeRegistry.extension.mockReturnValue(SomeExtension);
|
||||
const subject = extension.settings(SomeProfileMetadata, Vendor.simplelogin, {
|
||||
account$: SomeAccount$,
|
||||
});
|
||||
|
||||
subject.next({ foo: "next value" });
|
||||
await awaitAsync();
|
||||
|
||||
// if the write succeeded, then the storage location should contain an object;
|
||||
// the precise value isn't tested to avoid coupling the test to the storage format
|
||||
const expectedKey = new UserKeyDefinition(
|
||||
EXTENSION_DISK,
|
||||
"forwarder.simplelogin.someProfile",
|
||||
SomeProfileMetadata.storage.options,
|
||||
);
|
||||
const result = await firstValueFrom(SomeStateProvider.getUserState$(expectedKey, SomeUser));
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("panics when the extension metadata isn't available", async () => {
|
||||
const extension = new ExtensionService(SomeRegistry, SomeProvider);
|
||||
expect(() =>
|
||||
extension.settings(SomeProfileMetadata, Vendor.bitwarden, { account$: SomeAccount$ }),
|
||||
).toThrow("extension not defined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("site", () => {
|
||||
it("returns an extension site", () => {
|
||||
const expected = new ExtensionSite(SomeExtension.site, new Map());
|
||||
SomeRegistry.build.mockReturnValueOnce(expected);
|
||||
const extension = new ExtensionService(SomeRegistry, SomeProvider);
|
||||
|
||||
const site = extension.site(Site.forwarder);
|
||||
|
||||
expect(site).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
libs/common/src/tools/extension/extension.service.ts
Normal file
63
libs/common/src/tools/extension/extension.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { shareReplay } from "rxjs";
|
||||
|
||||
import { Account } from "../../auth/abstractions/account.service";
|
||||
import { BoundDependency } from "../dependencies";
|
||||
import { SemanticLogger } from "../log";
|
||||
import { UserStateSubject } from "../state/user-state-subject";
|
||||
import { UserStateSubjectDependencyProvider } from "../state/user-state-subject-dependency-provider";
|
||||
|
||||
import { ExtensionRegistry } from "./extension-registry.abstraction";
|
||||
import { ExtensionProfileMetadata, SiteId, VendorId } from "./type";
|
||||
import { toObjectKey } from "./util";
|
||||
|
||||
/** Provides configuration and storage support for Bitwarden client extensions.
|
||||
* These extensions integrate 3rd party services into Bitwarden.
|
||||
*/
|
||||
export class ExtensionService {
|
||||
/** Instantiate the extension service.
|
||||
* @param registry provides runtime status for extension sites
|
||||
* @param providers provide persistent data
|
||||
*/
|
||||
constructor(
|
||||
private registry: ExtensionRegistry,
|
||||
private readonly providers: UserStateSubjectDependencyProvider,
|
||||
) {
|
||||
this.log = providers.log({
|
||||
type: "ExtensionService",
|
||||
});
|
||||
}
|
||||
|
||||
private log: SemanticLogger;
|
||||
|
||||
/** Get a subject bound to a user's extension settings
|
||||
* @param profile the site's extension profile
|
||||
* @param vendor the vendor integrated at the extension site
|
||||
* @param dependencies.account$ the account to which the settings are bound
|
||||
* @returns a subject bound to the requested user's generator settings
|
||||
*/
|
||||
settings<Settings extends object, Site extends SiteId>(
|
||||
profile: ExtensionProfileMetadata<Settings, Site>,
|
||||
vendor: VendorId,
|
||||
dependencies: BoundDependency<"account", Account>,
|
||||
): UserStateSubject<Settings> {
|
||||
const metadata = this.registry.extension(profile.site, vendor);
|
||||
if (!metadata) {
|
||||
this.log.panic({ site: profile.site as string, vendor }, "extension not defined");
|
||||
}
|
||||
|
||||
const key = toObjectKey(profile, metadata);
|
||||
const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
// FIXME: load and apply constraints
|
||||
const subject = new UserStateSubject(key, this.providers, { account$ });
|
||||
|
||||
return subject;
|
||||
}
|
||||
|
||||
/** Look up extension metadata for a site
|
||||
* @param site defines the site to retrieve.
|
||||
* @returns the extensions available at the site.
|
||||
*/
|
||||
site(site: SiteId) {
|
||||
return this.registry.build(site);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { ObjectKey } from "../state/object-key";
|
||||
|
||||
import { Site, Field, Permission } from "./data";
|
||||
|
||||
/** well-known name for a feature extensible through an extension. */
|
||||
@@ -17,6 +19,11 @@ export type ExtensionId = { site: SiteId; vendor: VendorId };
|
||||
/** Permission levels for metadata. */
|
||||
export type ExtensionPermission = keyof typeof Permission;
|
||||
|
||||
/** The preferred vendor to use at each site. */
|
||||
export type ExtensionPreferences = {
|
||||
[key in SiteId]?: { vendor: VendorId; updated: Date };
|
||||
};
|
||||
|
||||
/** The capabilities and descriptive content for an extension */
|
||||
export type SiteMetadata = {
|
||||
/** Uniquely identifies the extension site. */
|
||||
@@ -107,3 +114,29 @@ export type ExtensionSet =
|
||||
*/
|
||||
all: true;
|
||||
};
|
||||
|
||||
/** A key for storing JavaScript objects (`{ an: "example" }`)
|
||||
* in the extension profile system.
|
||||
* @remarks The omitted keys are filled by the extension service.
|
||||
*/
|
||||
export type ExtensionStorageKey<Options> = Omit<
|
||||
ObjectKey<Options>,
|
||||
"target" | "state" | "format" | "classifier"
|
||||
>;
|
||||
|
||||
/** Extension profiles encapsulate data storage using the extension system.
|
||||
*/
|
||||
export type ExtensionProfileMetadata<Options, Site extends SiteId> = {
|
||||
/** distinguishes profile metadata types */
|
||||
type: "extension";
|
||||
|
||||
/** The extension site described by this metadata */
|
||||
site: Site;
|
||||
|
||||
/** persistent storage location; `storage.key` is used to construct
|
||||
* the extension key in the format `${extension.site}.${extension.vendor}.${storage.key}`,
|
||||
* where `extension.`-prefixed fields are read from extension metadata. Extension
|
||||
* settings always use the "classified" format and keep all fields private.
|
||||
*/
|
||||
storage: ExtensionStorageKey<Options>;
|
||||
};
|
||||
|
||||
54
libs/common/src/tools/extension/util.spec.ts
Normal file
54
libs/common/src/tools/extension/util.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { EXTENSION_DISK } from "../../platform/state";
|
||||
import { PrivateClassifier } from "../private-classifier";
|
||||
import { deepFreeze } from "../util";
|
||||
|
||||
import { Site } from "./data";
|
||||
import { ExtensionMetadata, ExtensionProfileMetadata } from "./type";
|
||||
import { toObjectKey } from "./util";
|
||||
import { Bitwarden } from "./vendor/bitwarden";
|
||||
|
||||
const ExampleProfile: ExtensionProfileMetadata<object, "forwarder"> = deepFreeze({
|
||||
type: "extension",
|
||||
site: "forwarder",
|
||||
storage: {
|
||||
key: "example",
|
||||
options: {
|
||||
clearOn: [],
|
||||
deserializer: (value) => value as any,
|
||||
},
|
||||
initial: {},
|
||||
frame: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const ExampleMetadata: ExtensionMetadata = {
|
||||
site: { id: Site.forwarder, availableFields: [] },
|
||||
product: { vendor: Bitwarden },
|
||||
host: { authentication: true, selfHost: "maybe", baseUrl: "http://example.com" },
|
||||
requestedFields: [],
|
||||
};
|
||||
|
||||
describe("toObjectKey", () => {
|
||||
it("sets static fields", () => {
|
||||
const result = toObjectKey(ExampleProfile, ExampleMetadata);
|
||||
|
||||
expect(result.target).toEqual("object");
|
||||
expect(result.format).toEqual("classified");
|
||||
expect(result.state).toBe(EXTENSION_DISK);
|
||||
expect(result.classifier).toBeInstanceOf(PrivateClassifier);
|
||||
});
|
||||
|
||||
it("creates a dynamic object key", () => {
|
||||
const result = toObjectKey(ExampleProfile, ExampleMetadata);
|
||||
|
||||
expect(result.key).toEqual("forwarder.bitwarden.example");
|
||||
});
|
||||
|
||||
it("copies the profile storage metadata", () => {
|
||||
const result = toObjectKey(ExampleProfile, ExampleMetadata);
|
||||
|
||||
expect(result.frame).toEqual(ExampleProfile.storage.frame);
|
||||
expect(result.options).toBe(ExampleProfile.storage.options);
|
||||
expect(result.initial).toBe(ExampleProfile.storage.initial);
|
||||
});
|
||||
});
|
||||
36
libs/common/src/tools/extension/util.ts
Normal file
36
libs/common/src/tools/extension/util.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { EXTENSION_DISK } from "../../platform/state";
|
||||
import { PrivateClassifier } from "../private-classifier";
|
||||
import { Classifier } from "../state/classifier";
|
||||
import { ObjectKey } from "../state/object-key";
|
||||
|
||||
import { ExtensionMetadata, ExtensionProfileMetadata, SiteId } from "./type";
|
||||
|
||||
/** Create an object key from an extension instance and a site profile.
|
||||
* @param profile the extension profile to bind
|
||||
* @param extension the extension metadata to bind
|
||||
*/
|
||||
export function toObjectKey<Settings extends object, Site extends SiteId>(
|
||||
profile: ExtensionProfileMetadata<Settings, Site>,
|
||||
extension: ExtensionMetadata,
|
||||
) {
|
||||
// FIXME: eliminate this cast
|
||||
const classifier = new PrivateClassifier<Settings>() as Classifier<
|
||||
Settings,
|
||||
Record<string, never>,
|
||||
Settings
|
||||
>;
|
||||
|
||||
const result: ObjectKey<Settings> = {
|
||||
// copy storage to retain extensibility
|
||||
...profile.storage,
|
||||
|
||||
// fields controlled by the extension system override those in the profile
|
||||
target: "object",
|
||||
key: `${extension.site.id}.${extension.product.vendor.id}.${profile.storage.key}`,
|
||||
state: EXTENSION_DISK,
|
||||
classifier,
|
||||
format: "classified",
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user