From 54b2be37078b37488ef8fdbd98a7c1963c504198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 18 Dec 2024 12:39:23 -0500 Subject: [PATCH] introduce integration service --- .../src/platform/state/state-definitions.ts | 1 + libs/common/src/tools/dependencies.ts | 5 ++ .../src/tools/integration/integration-key.ts | 48 ++++++++++ .../tools/integration/integration.service.ts | 58 +++++++++++++ .../src/tools/integration/metadata/data.ts | 34 ++++++++ .../tools/integration/metadata/extension.ts | 9 ++ .../src/tools/integration/metadata/factory.ts | 35 ++++++++ .../tools/integration/metadata/registry.ts | 87 +++++++++++++++++++ .../src/tools/integration/metadata/type.ts | 63 ++++++++++++++ .../integration/metadata/vendor/addy-io.ts | 22 +++++ .../metadata/vendor/duck-duck-go.ts | 22 +++++ .../integration/metadata/vendor/fastmail.ts | 23 +++++ .../metadata/vendor/forward-email.ts | 22 +++++ .../integration/metadata/vendor/mozilla.ts | 23 +++++ .../metadata/vendor/simple-login.ts | 22 +++++ 15 files changed, 474 insertions(+) create mode 100644 libs/common/src/tools/integration/integration-key.ts create mode 100644 libs/common/src/tools/integration/integration.service.ts create mode 100644 libs/common/src/tools/integration/metadata/data.ts create mode 100644 libs/common/src/tools/integration/metadata/extension.ts create mode 100644 libs/common/src/tools/integration/metadata/factory.ts create mode 100644 libs/common/src/tools/integration/metadata/registry.ts create mode 100644 libs/common/src/tools/integration/metadata/type.ts create mode 100644 libs/common/src/tools/integration/metadata/vendor/addy-io.ts create mode 100644 libs/common/src/tools/integration/metadata/vendor/duck-duck-go.ts create mode 100644 libs/common/src/tools/integration/metadata/vendor/fastmail.ts create mode 100644 libs/common/src/tools/integration/metadata/vendor/forward-email.ts create mode 100644 libs/common/src/tools/integration/metadata/vendor/mozilla.ts create mode 100644 libs/common/src/tools/integration/metadata/vendor/simple-login.ts diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index a600901df4f..5656f19a287 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -133,6 +133,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", { // Tools +export const INTEGRATION_DISK = new StateDefinition("integration", "disk"); export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); export const BROWSER_SEND_MEMORY = new StateDefinition("sendBrowser", "memory"); diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index cdae45bc94a..e919567af01 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -5,6 +5,7 @@ import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction"; import { UserEncryptor } from "./cryptography/user-encryptor.abstraction"; +import { VendorId } from "./integration/metadata/type"; /** error emitted when the `SingleUserDependency` changes Ids */ export type UserChangedError = { @@ -151,6 +152,10 @@ export type SingleUserDependency = { singleUserId$: Observable; }; +export type SingleVendorDependency = { + singleVendorId$: Observable; +}; + /** A pattern for types that emit values exclusively when the dependency * emits a message. * diff --git a/libs/common/src/tools/integration/integration-key.ts b/libs/common/src/tools/integration/integration-key.ts new file mode 100644 index 00000000000..436e78da866 --- /dev/null +++ b/libs/common/src/tools/integration/integration-key.ts @@ -0,0 +1,48 @@ +import { Jsonify } from "type-fest"; + +import { INTEGRATION_DISK } from "@bitwarden/common/platform/state"; + +import { PrivateClassifier } from "../private-classifier"; +import { ObjectKey } from "../state/object-key"; + +import { IntegrationRegistry } from "./metadata/registry"; +import { ExtensionSite, VendorId } from "./metadata/type"; + +export class IntegrationKey { + constructor( + private site: ExtensionSite, + private deserialize: (json: Jsonify) => T, + private initial?: T, + ) {} + + /** Constructs an object key + * @remarks this method should only be called by the integration service. + * It is not provided for general use. + */ + toObjectKey(vendor: VendorId, registry: IntegrationRegistry): ObjectKey { + const integration = registry.getIntegration(this.site, vendor); + + // an integrator can only store fields that are exported from the + // extension point *and* that were requested by the extension. All + // of the data stored by the extension is, for the moment, private. + const fields: any[] = integration.extension.availableFields.filter((available) => + integration.requestedFields.includes(available), + ); + const classifier = new PrivateClassifier(fields); + + const objectKey: ObjectKey = { + target: "object", + key: `${integration.extension.id}.${integration.product.vendor.id}`, + state: INTEGRATION_DISK, + classifier, + format: "classified", + options: { + deserializer: this.deserialize, + clearOn: ["logout"], + }, + initial: this.initial, + }; + + return objectKey; + } +} diff --git a/libs/common/src/tools/integration/integration.service.ts b/libs/common/src/tools/integration/integration.service.ts new file mode 100644 index 00000000000..dd46bb53df0 --- /dev/null +++ b/libs/common/src/tools/integration/integration.service.ts @@ -0,0 +1,58 @@ +import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable } from "rxjs"; + +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { LegacyEncryptorProvider } from "../cryptography/legacy-encryptor-provider"; +import { SingleUserDependency, SingleVendorDependency } from "../dependencies"; +import { UserStateSubject } from "../state/user-state-subject"; + +import { IntegrationKey } from "./integration-key"; +import { IntegrationRegistry } from "./metadata/registry"; +import { ExtensionSite, IntegrationMetadata } from "./metadata/type"; + +const DEFAULT_INTEGRATION_STORAGE_FRAME = 512; + +export class IntegrationService { + constructor( + private readonly registry: IntegrationRegistry, + private readonly stateProvider: StateProvider, + private readonly encryptorProvider: LegacyEncryptorProvider, + ) {} + + /** Observe the integrations available at an extension site */ + integrations$(site: ExtensionSite): Observable { + const integrations = this.registry.getIntegrations(site); + + // slot into a behavior subject so that the integration data is always available + // downstream; once dynamic registrations are supported this could be extended + // to publish updated lists without changing the signature + const integrations$ = new BehaviorSubject(integrations); + + return integrations$.asObservable(); + } + + /** Create a subject monitoring the integration's private data store */ + state$( + key: IntegrationKey, + dependencies: SingleUserDependency & SingleVendorDependency, + ): Promise> { + const singleUserEncryptor$ = this.encryptorProvider.userEncryptor$( + DEFAULT_INTEGRATION_STORAGE_FRAME, + dependencies, + ); + + const result$ = combineLatest([singleUserEncryptor$, dependencies.singleVendorId$]).pipe( + map(([encryptor, vendor]) => { + const subject = new UserStateSubject>( + key.toObjectKey(vendor, this.registry), + (key) => this.stateProvider.getUser(encryptor.userId, key), + { singleUserEncryptor$ }, + ); + + return subject; + }), + ); + + return firstValueFrom(result$); + } +} diff --git a/libs/common/src/tools/integration/metadata/data.ts b/libs/common/src/tools/integration/metadata/data.ts new file mode 100644 index 00000000000..ef8abf8a641 --- /dev/null +++ b/libs/common/src/tools/integration/metadata/data.ts @@ -0,0 +1,34 @@ +export const Site = Object.freeze({ + forwarder: "forwarder", +} as const); + +export const Field = Object.freeze({ + token: "token", + baseUrl: "baseUrl", + domain: "domain", + prefix: "prefix", +} as const); + +export const FieldsBySite = { + [Site.forwarder]: [Field.token, Field.baseUrl, Field.domain, Field.prefix], +} as const; + +export const Vendor = Object.freeze({ + anonaddy: "anonaddy", + duckduckgo: "duckduckgo", + fastmail: "fastmail", + mozilla: "mozilla", + forwardemail: "forwardemail", + simplelogin: "simplelogin", +} as const); + +export const VendorsByExtension = { + [Site.forwarder]: [ + Vendor.anonaddy, + Vendor.duckduckgo, + Vendor.fastmail, + Vendor.mozilla, + Vendor.forwardemail, + Vendor.simplelogin, + ] as const, +} as const; diff --git a/libs/common/src/tools/integration/metadata/extension.ts b/libs/common/src/tools/integration/metadata/extension.ts new file mode 100644 index 00000000000..8b98dc31428 --- /dev/null +++ b/libs/common/src/tools/integration/metadata/extension.ts @@ -0,0 +1,9 @@ +import { Field, Site } from "./data"; +import { ExtensionMetadata } from "./type"; + +export const Extension: Record = { + [Site.forwarder]: { + id: Site.forwarder, + availableFields: [Field.baseUrl, Field.domain, Field.prefix, Field.token], + }, +}; diff --git a/libs/common/src/tools/integration/metadata/factory.ts b/libs/common/src/tools/integration/metadata/factory.ts new file mode 100644 index 00000000000..41a3c0214e7 --- /dev/null +++ b/libs/common/src/tools/integration/metadata/factory.ts @@ -0,0 +1,35 @@ +import { Extension } from "./extension"; +import { IntegrationRegistry } from "./registry"; +import { IntegrationMetadata, VendorMetadata } from "./type"; +import { AddyIo, AddyIoIntegrations } from "./vendor/addy-io"; +import { DuckDuckGo, DuckDuckGoIntegrations } from "./vendor/duck-duck-go"; +import { Fastmail, FastmailIntegrations } from "./vendor/fastmail"; +import { ForwardEmail, ForwardEmailIntegrations } from "./vendor/forward-email"; +import { Mozilla, MozillaIntegrations } from "./vendor/mozilla"; +import { SimpleLogin, SimpleLoginIntegrations } from "./vendor/simple-login"; + +/** Constructs the integration registry */ +export function buildRegistry() { + // FIXME: find a better way to build the registry than a hard-coded factory function + function registerAll(vendor: VendorMetadata, integrations: IntegrationMetadata[]) { + registry.registerVendor(vendor); + for (const integration of integrations) { + registry.registerIntegration(integration); + } + } + + const registry = new IntegrationRegistry(); + + for (const site of Reflect.ownKeys(Extension) as string[]) { + registry.registerExtension(Extension[site]); + } + + registerAll(AddyIo, AddyIoIntegrations); + registerAll(DuckDuckGo, DuckDuckGoIntegrations); + registerAll(Fastmail, FastmailIntegrations); + registerAll(ForwardEmail, ForwardEmailIntegrations); + registerAll(Mozilla, MozillaIntegrations); + registerAll(SimpleLogin, SimpleLoginIntegrations); + + return registry; +} diff --git a/libs/common/src/tools/integration/metadata/registry.ts b/libs/common/src/tools/integration/metadata/registry.ts new file mode 100644 index 00000000000..7708445520a --- /dev/null +++ b/libs/common/src/tools/integration/metadata/registry.ts @@ -0,0 +1,87 @@ +import { ExtensionPointId } from "../extension-point-id"; + +import { + ExtensionMetadata, + ExtensionSite, + IntegrationMetadata, + VendorId, + VendorMetadata, +} from "./type"; + +/** Tracks extension points and the integrations that extend them. */ +export class IntegrationRegistry { + private extensions = new Map(); + private vendors = new Map(); + private integrations = new Map>(); + + /** Registers a site supporting extensibility. + * @param site - identifies the site being extended + * @param meta - configures the extension site + * @return self for method chaining. + */ + registerExtension(meta: ExtensionMetadata): IntegrationRegistry { + if (!this.extensions.has(meta.id)) { + this.extensions.set(meta.id, meta); + } + + return this; + } + + getExtension(site: ExtensionPointId): ExtensionMetadata | undefined { + const extension = this.extensions.get(site); + return extension ? extension : undefined; + } + + /** Registers a site supporting extensibility. + * @param site - identifies the site being extended + * @param meta - configures the extension site + * @return self for method chaining. + */ + registerVendor(meta: VendorMetadata): IntegrationRegistry { + if (!this.vendors.has(meta.id)) { + this.vendors.set(meta.id, meta); + } + + return this; + } + + getVendor(vendorId: VendorId): VendorMetadata | undefined { + const vendor = this.vendors.get(vendorId); + return vendor ? vendor : undefined; + } + + /** Registers an integration provided by a vendor to an extension site. + * The vendor and site MUST be registered before the integration. + * @param site - identifies the site being extended + * @param meta - configures the extension site + * @return self for method chaining. + */ + registerIntegration(meta: IntegrationMetadata): IntegrationRegistry { + if (!this.extensions.has(meta.extension.id)) { + throw new Error(`Unrecognized site: ${meta.extension.id}`); + } else if (!this.vendors.has(meta.product.vendor.id)) { + throw new Error(`Unrecognized vendor: ${meta.product.vendor.id}`); + } + + let integrations = this.integrations.get(meta.extension.id); + if (!integrations) { + integrations = new Map(); + this.integrations.set(meta.extension.id, integrations); + } + + if (!integrations.has(meta.product.vendor.id)) { + integrations.set(meta.product.vendor.id, meta); + } + + return this; + } + + getIntegration(site: ExtensionPointId, vendor: VendorId): IntegrationMetadata | undefined { + return this.integrations.get(site)?.get(vendor) ?? undefined; + } + + getIntegrations(site: ExtensionPointId): IntegrationMetadata[] { + const integrations = Array.from(this.integrations.get(site)?.values() ?? []); + return integrations; + } +} diff --git a/libs/common/src/tools/integration/metadata/type.ts b/libs/common/src/tools/integration/metadata/type.ts new file mode 100644 index 00000000000..9e277123dbc --- /dev/null +++ b/libs/common/src/tools/integration/metadata/type.ts @@ -0,0 +1,63 @@ +import { FieldsBySite, Site, VendorsByExtension } from "./data"; + +/** well-known name for a feature extensible through an integration. */ +export type ExtensionSite = keyof typeof Site; + +/** well-known name for a field surfaced from an extension site to a vendor. */ +export type DisclosedField = (typeof FieldsBySite)[ExtensionSite][number]; + +/** Identifies a vendor integrated into bitwarden */ +export type VendorId = (typeof VendorsByExtension)[ExtensionSite][number]; + +/** The capabilities and descriptive content for an integration */ +export type ExtensionMetadata = { + /** Uniquely identifies the integrator. */ + id: ExtensionSite; + + /** Lists the fields disclosed by the extension to the vendor */ + availableFields: DisclosedField[]; +}; + +/** Catalogues an integration's hosting status. + * selfHost: "never" always uses the service's base URL + * selfHost: "maybe" allows the user to override the service's + * base URL with their own. + * selfHost: "always" requires a base URL. + */ +export type ApiHost = + | { selfHost: "never"; baseUrl: string } + | { selfHost: "maybe"; baseUrl: string } + | { selfHost: "always" }; + +/** The capabilities and descriptive content for an integration */ +export type VendorMetadata = { + /** Uniquely identifies the integrator. */ + id: VendorId; + + /** Brand name of the integrator. */ + name: string; +}; + +/** Describes an integration provided by a vendor */ +export type IntegrationMetadata = { + /** The part of Bitwarden extended by the vendor's services */ + extension: ExtensionMetadata; + + /** Product description */ + product: { + /** The vendor providing the extension */ + vendor: VendorMetadata; + + /** The branded name of the product, if it varies from the Vendor name */ + name?: string; + }; + + /** Hosting provider capabilities required by the integration */ + host: ApiHost; + + /** Lists the fields disclosed by the extension to the vendor. + * This should be a subset of the `availableFields` listed in + * the extension. + */ + requestedFields: DisclosedField[]; +}; diff --git a/libs/common/src/tools/integration/metadata/vendor/addy-io.ts b/libs/common/src/tools/integration/metadata/vendor/addy-io.ts new file mode 100644 index 00000000000..677deec95fd --- /dev/null +++ b/libs/common/src/tools/integration/metadata/vendor/addy-io.ts @@ -0,0 +1,22 @@ +import { Field, Vendor } from "../data"; +import { Extension } from "../extension"; +import { IntegrationMetadata } from "../type"; + +export const AddyIo = { + id: Vendor.anonaddy, + name: "Addy.io", +}; + +export const AddyIoIntegrations: IntegrationMetadata[] = [ + { + extension: Extension.forwarder, + product: { + vendor: AddyIo, + }, + host: { + selfHost: "maybe", + baseUrl: "https://app.addy.io", + }, + requestedFields: [Field.token, Field.baseUrl, Field.domain], + }, +]; diff --git a/libs/common/src/tools/integration/metadata/vendor/duck-duck-go.ts b/libs/common/src/tools/integration/metadata/vendor/duck-duck-go.ts new file mode 100644 index 00000000000..a3475a55fd3 --- /dev/null +++ b/libs/common/src/tools/integration/metadata/vendor/duck-duck-go.ts @@ -0,0 +1,22 @@ +import { Field, Vendor } from "../data"; +import { Extension } from "../extension"; +import { IntegrationMetadata } from "../type"; + +export const DuckDuckGo = { + id: Vendor.duckduckgo, + name: "DuckDuckGo", +}; + +export const DuckDuckGoIntegrations: IntegrationMetadata[] = [ + { + extension: Extension.forwarder, + product: { + vendor: DuckDuckGo, + }, + host: { + selfHost: "never", + baseUrl: "https://quack.duckduckgo.com/api", + }, + requestedFields: [Field.token], + }, +]; diff --git a/libs/common/src/tools/integration/metadata/vendor/fastmail.ts b/libs/common/src/tools/integration/metadata/vendor/fastmail.ts new file mode 100644 index 00000000000..839ba740e90 --- /dev/null +++ b/libs/common/src/tools/integration/metadata/vendor/fastmail.ts @@ -0,0 +1,23 @@ +import { Field, Vendor } from "../data"; +import { Extension } from "../extension"; +import { IntegrationMetadata } from "../type"; + +export const Fastmail = { + id: Vendor.fastmail, + name: "Fastmail", +}; + +// integration-wide configuration +export const FastmailIntegrations: IntegrationMetadata[] = [ + { + extension: Extension.forwarder, + product: { + vendor: Fastmail, + }, + host: { + selfHost: "maybe", + baseUrl: "https://api.fastmail.com", + }, + requestedFields: [Field.token], + }, +]; diff --git a/libs/common/src/tools/integration/metadata/vendor/forward-email.ts b/libs/common/src/tools/integration/metadata/vendor/forward-email.ts new file mode 100644 index 00000000000..d3d2a23b492 --- /dev/null +++ b/libs/common/src/tools/integration/metadata/vendor/forward-email.ts @@ -0,0 +1,22 @@ +import { Field, Vendor } from "../data"; +import { Extension } from "../extension"; +import { IntegrationMetadata } from "../type"; + +export const ForwardEmail = { + id: Vendor.forwardemail, + name: "Forward Email", +}; + +export const ForwardEmailIntegrations: IntegrationMetadata[] = [ + { + extension: Extension.forwarder, + product: { + vendor: ForwardEmail, + }, + host: { + selfHost: "never", + baseUrl: "https://api.forwardemail.net", + }, + requestedFields: [Field.domain, Field.token], + }, +]; diff --git a/libs/common/src/tools/integration/metadata/vendor/mozilla.ts b/libs/common/src/tools/integration/metadata/vendor/mozilla.ts new file mode 100644 index 00000000000..1f95e863b0e --- /dev/null +++ b/libs/common/src/tools/integration/metadata/vendor/mozilla.ts @@ -0,0 +1,23 @@ +import { Field, Vendor } from "../data"; +import { Extension } from "../extension"; +import { IntegrationMetadata } from "../type"; + +export const Mozilla = { + id: Vendor.mozilla, + name: "Mozilla", +}; + +export const MozillaIntegrations: IntegrationMetadata[] = [ + { + extension: Extension.forwarder, + product: { + vendor: Mozilla, + name: "Firefox Relay", + }, + host: { + selfHost: "never", + baseUrl: "https://relay.firefox.com/api", + }, + requestedFields: [Field.token], + }, +]; diff --git a/libs/common/src/tools/integration/metadata/vendor/simple-login.ts b/libs/common/src/tools/integration/metadata/vendor/simple-login.ts new file mode 100644 index 00000000000..3f046e83573 --- /dev/null +++ b/libs/common/src/tools/integration/metadata/vendor/simple-login.ts @@ -0,0 +1,22 @@ +import { Field, Vendor } from "../data"; +import { Extension } from "../extension"; +import { IntegrationMetadata, VendorMetadata } from "../type"; + +export const SimpleLogin: VendorMetadata = { + id: Vendor.simplelogin, + name: "SimpleLogin", +}; + +export const SimpleLoginIntegrations: IntegrationMetadata[] = [ + { + extension: Extension.forwarder, + product: { + vendor: SimpleLogin, + }, + host: { + selfHost: "maybe", + baseUrl: "https://app.simplelogin.io", + }, + requestedFields: [Field.baseUrl, Field.token, Field.domain], + }, +];