1
0
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:
✨ Audrey ✨
2025-03-06 11:32:42 -05:00
committed by GitHub
parent 6f4a1ea37f
commit 9761588a2a
19 changed files with 461 additions and 39 deletions

View 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);
});
});
});

View 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);
}
}

View File

@@ -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>;
};

View 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);
});
});

View 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;
}