From 15b2b46b85b16e3e477dc562ccac80e2a9f2bdab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 25 Mar 2025 17:08:30 -0400 Subject: [PATCH] [PM-18665] introduce metadata provider (#13744) --- .../integration/integration-context.spec.ts | 15 +- .../tools/integration/integration-metadata.ts | 4 +- .../src/tools/log/disabled-semantic-logger.ts | 8 +- .../tools/log/semantic-logger.abstraction.ts | 1 + .../generator/core/src/data/integrations.ts | 8 +- .../generator/core/src/integration/addy-io.ts | 3 +- .../core/src/integration/duck-duck-go.ts | 3 +- .../core/src/integration/fastmail.ts | 3 +- .../core/src/integration/firefox-relay.ts | 3 +- .../core/src/integration/forward-email.ts | 3 +- .../core/src/integration/simple-login.ts | 3 +- .../core/src/metadata/algorithm-metadata.ts | 16 +- .../core/src/metadata/email/catchall.spec.ts | 6 +- .../core/src/metadata/email/forwarder.ts | 79 +++- .../src/metadata/email/plus-address.spec.ts | 6 +- .../generator/core/src/metadata/index.ts | 18 +- .../metadata/password/eff-word-list.spec.ts | 28 +- .../metadata/password/random-password.spec.ts | 12 +- .../metadata/username/eff-word-list.spec.ts | 6 +- .../policies/available-algorithms-policy.ts | 32 +- .../generator-metadata-provider.spec.ts | 438 ++++++++++++++++++ .../services/generator-metadata-provider.ts | 252 ++++++++++ .../credential-generator-configuration.ts | 4 +- .../core/src/types/generator-type.ts | 8 +- .../core/src/types/metadata-request.ts | 13 + 25 files changed, 910 insertions(+), 62 deletions(-) create mode 100644 libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts create mode 100644 libs/tools/generator/core/src/services/generator-metadata-provider.ts create mode 100644 libs/tools/generator/core/src/types/metadata-request.ts diff --git a/libs/common/src/tools/integration/integration-context.spec.ts b/libs/common/src/tools/integration/integration-context.spec.ts index 42581c08dee..67a40afb337 100644 --- a/libs/common/src/tools/integration/integration-context.spec.ts +++ b/libs/common/src/tools/integration/integration-context.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { I18nService } from "../../platform/abstractions/i18n.service"; +import { VendorId } from "../extension"; import { IntegrationContext } from "./integration-context"; import { IntegrationId } from "./integration-id"; @@ -8,7 +9,7 @@ import { IntegrationMetadata } from "./integration-metadata"; const EXAMPLE_META = Object.freeze({ // arbitrary - id: "simplelogin" as IntegrationId, + id: "simplelogin" as IntegrationId & VendorId, name: "Example", // arbitrary extends: ["forwarder"], @@ -34,7 +35,7 @@ describe("IntegrationContext", () => { it("throws when the baseurl isn't defined in metadata", () => { const noBaseUrl: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary selfHost: "maybe", @@ -56,7 +57,7 @@ describe("IntegrationContext", () => { it("ignores settings when selfhost is 'never'", () => { const selfHostNever: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", @@ -71,7 +72,7 @@ describe("IntegrationContext", () => { it("always reads the settings when selfhost is 'always'", () => { const selfHostAlways: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", @@ -86,7 +87,7 @@ describe("IntegrationContext", () => { it("fails when the settings are empty and selfhost is 'always'", () => { const selfHostAlways: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", @@ -101,7 +102,7 @@ describe("IntegrationContext", () => { it("reads from the metadata by default when selfhost is 'maybe'", () => { const selfHostMaybe: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", @@ -117,7 +118,7 @@ describe("IntegrationContext", () => { it("overrides the metadata when selfhost is 'maybe'", () => { const selfHostMaybe: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", diff --git a/libs/common/src/tools/integration/integration-metadata.ts b/libs/common/src/tools/integration/integration-metadata.ts index e460aae828c..2073b16feb0 100644 --- a/libs/common/src/tools/integration/integration-metadata.ts +++ b/libs/common/src/tools/integration/integration-metadata.ts @@ -1,10 +1,12 @@ +import { VendorId } from "../extension"; + import { ExtensionPointId } from "./extension-point-id"; import { IntegrationId } from "./integration-id"; /** The capabilities and descriptive content for an integration */ export type IntegrationMetadata = { /** Uniquely identifies the integrator. */ - id: IntegrationId; + id: IntegrationId & VendorId; /** Brand name of the integrator. */ name: string; diff --git a/libs/common/src/tools/log/disabled-semantic-logger.ts b/libs/common/src/tools/log/disabled-semantic-logger.ts index 054c3ed390b..21ea48bbe51 100644 --- a/libs/common/src/tools/log/disabled-semantic-logger.ts +++ b/libs/common/src/tools/log/disabled-semantic-logger.ts @@ -12,7 +12,11 @@ export class DisabledSemanticLogger implements SemanticLogger { error(_content: Jsonify, _message?: string): void {} - panic(_content: Jsonify, message?: string): never { - throw new Error(message); + panic(content: Jsonify, message?: string): never { + if (typeof content === "string" && !message) { + throw new Error(content); + } else { + throw new Error(message); + } } } diff --git a/libs/common/src/tools/log/semantic-logger.abstraction.ts b/libs/common/src/tools/log/semantic-logger.abstraction.ts index 196d1f3f12c..51aaa917378 100644 --- a/libs/common/src/tools/log/semantic-logger.abstraction.ts +++ b/libs/common/src/tools/log/semantic-logger.abstraction.ts @@ -9,6 +9,7 @@ export interface SemanticLogger { */ debug(message: string): void; + // FIXME: replace Jsonify parameter with structural logging schema type /** Logs the content at debug priority. * Debug messages are used for diagnostics, and are typically disabled * in production builds. diff --git a/libs/tools/generator/core/src/data/integrations.ts b/libs/tools/generator/core/src/data/integrations.ts index 21c883cae02..ffe4676fcd7 100644 --- a/libs/tools/generator/core/src/data/integrations.ts +++ b/libs/tools/generator/core/src/data/integrations.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; @@ -29,8 +30,11 @@ export const Integrations = Object.freeze({ const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i])); -export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration { - const maybeForwarder = integrations.get(id); +export function getForwarderConfiguration( + id: IntegrationId | VendorId, +): ForwarderConfiguration { + // these casts are for compatibility; `IntegrationId` is the old form of `VendorId` + const maybeForwarder = integrations.get(id as string as IntegrationId & VendorId); if (maybeForwarder && "forwarder" in maybeForwarder) { return maybeForwarder as ForwarderConfiguration; diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts index d9f2b9f121d..631c5fdb510 100644 --- a/libs/tools/generator/core/src/integration/addy-io.ts +++ b/libs/tools/generator/core/src/integration/addy-io.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -100,7 +101,7 @@ const forwarder = Object.freeze({ export const AddyIo = Object.freeze({ // integration - id: "anonaddy" as IntegrationId, + id: "anonaddy" as IntegrationId & VendorId, name: "Addy.io", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/integration/duck-duck-go.ts b/libs/tools/generator/core/src/integration/duck-duck-go.ts index 0bcdd560503..d2bd6173a14 100644 --- a/libs/tools/generator/core/src/integration/duck-duck-go.ts +++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -89,7 +90,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const DuckDuckGo = Object.freeze({ - id: "duckduckgo" as IntegrationId, + id: "duckduckgo" as IntegrationId & VendorId, name: "DuckDuckGo", baseUrl: "https://quack.duckduckgo.com/api", selfHost: "never", diff --git a/libs/tools/generator/core/src/integration/fastmail.ts b/libs/tools/generator/core/src/integration/fastmail.ts index 69b908badc9..bfde1aa70f5 100644 --- a/libs/tools/generator/core/src/integration/fastmail.ts +++ b/libs/tools/generator/core/src/integration/fastmail.ts @@ -5,6 +5,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -159,7 +160,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const Fastmail = Object.freeze({ - id: "fastmail" as IntegrationId, + id: "fastmail" as IntegrationId & VendorId, name: "Fastmail", baseUrl: "https://api.fastmail.com", selfHost: "maybe", diff --git a/libs/tools/generator/core/src/integration/firefox-relay.ts b/libs/tools/generator/core/src/integration/firefox-relay.ts index ae65611905f..9f40a3631ff 100644 --- a/libs/tools/generator/core/src/integration/firefox-relay.ts +++ b/libs/tools/generator/core/src/integration/firefox-relay.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -97,7 +98,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const FirefoxRelay = Object.freeze({ - id: "firefoxrelay" as IntegrationId, + id: "firefoxrelay" as IntegrationId & VendorId, name: "Firefox Relay", baseUrl: "https://relay.firefox.com/api", selfHost: "never", diff --git a/libs/tools/generator/core/src/integration/forward-email.ts b/libs/tools/generator/core/src/integration/forward-email.ts index d67b8d588bf..34b4602b94b 100644 --- a/libs/tools/generator/core/src/integration/forward-email.ts +++ b/libs/tools/generator/core/src/integration/forward-email.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -101,7 +102,7 @@ const forwarder = Object.freeze({ export const ForwardEmail = Object.freeze({ // integration metadata - id: "forwardemail" as IntegrationId, + id: "forwardemail" as IntegrationId & VendorId, name: "Forward Email", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/integration/simple-login.ts b/libs/tools/generator/core/src/integration/simple-login.ts index 1581f3861f5..efbac69cec2 100644 --- a/libs/tools/generator/core/src/integration/simple-login.ts +++ b/libs/tools/generator/core/src/integration/simple-login.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -103,7 +104,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const SimpleLogin = Object.freeze({ - id: "simplelogin" as IntegrationId, + id: "simplelogin" as IntegrationId & VendorId, name: "SimpleLogin", selfHost: "maybe", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts index f776dd76e54..c07deef5535 100644 --- a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts +++ b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts @@ -1,5 +1,7 @@ import { CredentialAlgorithm, CredentialType } from "./type"; +type I18nKeyOrLiteral = string | { literal: string }; + /** Credential generator metadata common across credential generators */ export type AlgorithmMetadata = { /** Uniquely identifies the credential configuration @@ -23,25 +25,25 @@ export type AlgorithmMetadata = { /** Localization keys */ i18nKeys: { /** descriptive name of the algorithm */ - name: string; + name: I18nKeyOrLiteral; /** explanatory text for the algorithm */ - description?: string; + description?: I18nKeyOrLiteral; /** labels the generate action */ - generateCredential: string; + generateCredential: I18nKeyOrLiteral; /** message informing users when the generator produces a new credential */ - credentialGenerated: string; + credentialGenerated: I18nKeyOrLiteral; /* labels the action that assigns a generated value to a domain object */ - useCredential: string; + useCredential: I18nKeyOrLiteral; /** labels the generated output */ - credentialType: string; + credentialType: I18nKeyOrLiteral; /** labels the copy output action */ - copyCredential: string; + copyCredential: I18nKeyOrLiteral; }; /** fine-tunings for generator user experiences */ diff --git a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts index f63f141842c..d6cc1795e0b 100644 --- a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts +++ b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts @@ -19,11 +19,13 @@ describe("email - catchall generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = catchall.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); diff --git a/libs/tools/generator/core/src/metadata/email/forwarder.ts b/libs/tools/generator/core/src/metadata/email/forwarder.ts index 1dfc219d466..f4f150f33fa 100644 --- a/libs/tools/generator/core/src/metadata/email/forwarder.ts +++ b/libs/tools/generator/core/src/metadata/email/forwarder.ts @@ -1,4 +1,75 @@ -// Forwarders are pending integration with the extension API -// -// They use the 300-block of weights and derive their metadata -// using logic similar to `toCredentialGeneratorConfiguration` +import { ExtensionMetadata, ExtensionStorageKey } from "@bitwarden/common/tools/extension/type"; +import { SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc"; +import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; + +import { getForwarderConfiguration } from "../../data"; +import { EmailDomainSettings, EmailPrefixSettings } from "../../engine"; +import { Forwarder } from "../../engine/forwarder"; +import { GeneratorDependencyProvider } from "../../types"; +import { Profile, Type } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; +import { ForwarderProfileMetadata } from "../profile-metadata"; + +// These options are used by all forwarders; each forwarder uses a different set, +// as defined by `GeneratorMetadata.capabilities.fields`. +type ForwarderOptions = Partial; + +// update the extension metadata +export function toForwarderMetadata( + extension: ExtensionMetadata, +): GeneratorMetadata { + if (extension.site.id !== "forwarder") { + throw new Error( + `expected forwarder extension; received ${extension.site.id} (${extension.product.vendor.id})`, + ); + } + + const name = { literal: extension.product.name ?? extension.product.vendor.name }; + + const generator: GeneratorMetadata = { + id: { forwarder: extension.product.vendor.id }, + category: Type.email, + weight: 300, + i18nKeys: { + name, + description: "forwardedEmailDesc", + generateCredential: "generateEmail", + credentialGenerated: "emailGenerated", + useCredential: "useThisEmail", + credentialType: "email", + copyCredential: "copyEmail", + }, + capabilities: { + autogenerate: false, + fields: [...extension.requestedFields], + }, + engine: { + create(dependencies: GeneratorDependencyProvider) { + const config = getForwarderConfiguration(extension.product.vendor.id); + return new Forwarder(config, dependencies.client, dependencies.i18nService); + }, + }, + profiles: { + [Profile.account]: { + type: "extension", + site: "forwarder", + storage: { + key: "forwarder", + frame: 512, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ExtensionStorageKey, + constraints: { + default: {}, + create() { + return new IdentityConstraint(); + }, + }, + } satisfies ForwarderProfileMetadata, + }, + }; + + return generator; +} diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts index 2ac7645ed30..063cb71c23a 100644 --- a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts +++ b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts @@ -19,11 +19,13 @@ describe("email - plus address generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = plusAddress.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); diff --git a/libs/tools/generator/core/src/metadata/index.ts b/libs/tools/generator/core/src/metadata/index.ts index 79806fd1bcc..d9437822270 100644 --- a/libs/tools/generator/core/src/metadata/index.ts +++ b/libs/tools/generator/core/src/metadata/index.ts @@ -1,12 +1,24 @@ -import { AlgorithmsByType as ABT } from "./data"; +import { + Algorithm as AlgorithmData, + AlgorithmsByType as AlgorithmsByTypeData, + Type as TypeData, +} from "./data"; import { CredentialType, CredentialAlgorithm } from "./type"; // `CredentialAlgorithm` is defined in terms of `ABT`; supplying // type information in the barrel file breaks a circular dependency. /** Credential generation algorithms grouped by purpose. */ -export const AlgorithmsByType: Record> = ABT; +export const AlgorithmsByType: Record< + CredentialType, + ReadonlyArray +> = AlgorithmsByTypeData; +export const Algorithms: ReadonlyArray = Object.freeze( + Object.values(AlgorithmData), +); +export const Types: ReadonlyArray = Object.freeze(Object.values(TypeData)); -export { Profile, Type } from "./data"; +export { Profile, Type, Algorithm } from "./data"; +export { toForwarderMetadata } from "./email/forwarder"; export { GeneratorMetadata } from "./generator-metadata"; export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata"; export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type"; diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts index 57961a60033..e02d63d3d59 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts @@ -22,19 +22,21 @@ describe("password - eff words generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata | null = null; beforeEach(() => { const profile = effPassphrase.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + accountProfile = null; } }); describe("storage.options.deserializer", () => { it("returns its input", () => { - const value: PassphraseGenerationOptions = { ...accountProfile.storage.initial }; + const value: PassphraseGenerationOptions = { ...accountProfile!.storage.initial }; - const result = accountProfile.storage.options.deserializer(value); + const result = accountProfile!.storage.options.deserializer(value); expect(result).toBe(value); }); @@ -46,15 +48,15 @@ describe("password - eff words generator metadata", () => { // enclosed behaviors change. it("creates a passphrase policy constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; + const context = { defaultConstraints: accountProfile!.constraints.default }; - const constraints = accountProfile.constraints.create([], context); + const constraints = accountProfile!.constraints.create([], context); expect(constraints).toBeInstanceOf(PassphrasePolicyConstraints); }); it("forwards the policy to the constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; + const context = { defaultConstraints: accountProfile!.constraints.default }; const policies = [ { type: PolicyType.PasswordGenerator, @@ -66,13 +68,13 @@ describe("password - eff words generator metadata", () => { }, ] as Policy[]; - const constraints = accountProfile.constraints.create(policies, context); + const constraints = accountProfile!.constraints.create(policies, context); - expect(constraints.constraints.numWords.min).toEqual(6); + expect(constraints.constraints.numWords?.min).toEqual(6); }); it("combines multiple policies in the constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; + const context = { defaultConstraints: accountProfile!.constraints.default }; const policies = [ { type: PolicyType.PasswordGenerator, @@ -92,10 +94,10 @@ describe("password - eff words generator metadata", () => { }, ] as Policy[]; - const constraints = accountProfile.constraints.create(policies, context); + const constraints = accountProfile!.constraints.create(policies, context); - expect(constraints.constraints.numWords.min).toEqual(6); - expect(constraints.constraints.capitalize.requiredValue).toEqual(true); + expect(constraints.constraints.numWords?.min).toEqual(6); + expect(constraints.constraints.capitalize?.requiredValue).toEqual(true); }); }); }); diff --git a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts index d91ceaac248..9e38c50ee2a 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts @@ -22,11 +22,13 @@ describe("password - characters generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = password.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); @@ -69,7 +71,7 @@ describe("password - characters generator metadata", () => { const constraints = accountProfile.constraints.create(policies, context); - expect(constraints.constraints.length.min).toEqual(10); + expect(constraints.constraints.length?.min).toEqual(10); }); it("combines multiple policies in the constraints", () => { @@ -97,8 +99,8 @@ describe("password - characters generator metadata", () => { const constraints = accountProfile.constraints.create(policies, context); - expect(constraints.constraints.length.min).toEqual(14); - expect(constraints.constraints.special.requiredValue).toEqual(true); + expect(constraints.constraints.length?.min).toEqual(14); + expect(constraints.constraints.special?.requiredValue).toEqual(true); }); }); }); diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts index aba9680a448..d47d5ec9fcb 100644 --- a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts @@ -20,11 +20,13 @@ describe("username - eff words generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = effWordList.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts index f37a8b21a3f..0c44a1a0408 100644 --- a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts @@ -5,13 +5,41 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; // implement ADR-0002 import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { CredentialAlgorithm, EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from ".."; +import { + CredentialAlgorithm as LegacyAlgorithm, + EmailAlgorithms, + PasswordAlgorithms, + UsernameAlgorithms, +} from ".."; +import { CredentialAlgorithm } from "../metadata"; /** Reduces policies to a set of available algorithms * @param policies the policies to reduce * @returns the resulting `AlgorithmAvailabilityPolicy` */ -export function availableAlgorithms(policies: Policy[]): CredentialAlgorithm[] { +export function availableAlgorithms(policies: Policy[]): LegacyAlgorithm[] { + const overridePassword = policies + .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) + .reduce( + (type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)), + null as LegacyAlgorithm, + ); + + const policy: LegacyAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms]; + if (overridePassword) { + policy.push(overridePassword); + } else { + policy.push(...PasswordAlgorithms); + } + + return policy; +} + +/** Reduces policies to a set of available algorithms + * @param policies the policies to reduce + * @returns the resulting `AlgorithmAvailabilityPolicy` + */ +export function availableAlgorithms_vNext(policies: Policy[]): CredentialAlgorithm[] { const overridePassword = policies .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) .reduce( diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts b/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts new file mode 100644 index 00000000000..958e5608449 --- /dev/null +++ b/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts @@ -0,0 +1,438 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, ReplaySubject, firstValueFrom } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; +import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction"; +import { + ExtensionMetadata, + ExtensionSite, + Site, + SiteId, + SiteMetadata, +} from "@bitwarden/common/tools/extension"; +import { ExtensionService } from "@bitwarden/common/tools/extension/extension.service"; +import { Bitwarden } from "@bitwarden/common/tools/extension/vendor/bitwarden"; +import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; +import { deepFreeze } from "@bitwarden/common/tools/util"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { FakeAccountService, FakeStateProvider } from "../../../../../common/spec"; +import { Algorithm, AlgorithmsByType, CredentialAlgorithm, Type, Types } from "../metadata"; +import catchall from "../metadata/email/catchall"; +import plusAddress from "../metadata/email/plus-address"; +import passphrase from "../metadata/password/eff-word-list"; +import password from "../metadata/password/random-password"; +import effWordList from "../metadata/username/eff-word-list"; +import { CredentialPreference } from "../types"; + +import { PREFERENCES } from "./credential-preferences"; +import { GeneratorMetadataProvider } from "./generator-metadata-provider"; + +const SomeUser = "some user" as UserId; +const SomeAccount = { + id: SomeUser, + email: "someone@example.com", + emailVerified: true, + name: "Someone", +}; +const SomeAccount$ = new BehaviorSubject(SomeAccount); + +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 SystemProvider = { + 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 SomeSiteId: SiteId = Site.forwarder; + +const SomeSite: SiteMetadata = Object.freeze({ + id: SomeSiteId, + availableFields: [], +}); + +const SomePolicyService = mock(); + +const SomeExtensionService = mock(); + +const ApplicationProvider = { + /** Policy configured by the administrative console */ + policy: SomePolicyService, + + /** Client extension metadata and profile access */ + extension: SomeExtensionService, + + /** Event monitoring and diagnostic interfaces */ + log: disabledSemanticLoggerProvider, +} as SystemServiceProvider; + +describe("GeneratorMetadataProvider", () => { + beforeEach(() => { + jest.resetAllMocks(); + SomeExtensionService.site.mockImplementation(() => new ExtensionSite(SomeSite, new Map())); + }); + + describe("constructor", () => { + it("throws when the forwarder site isn't defined by the extension service", () => { + SomeExtensionService.site.mockReturnValue(undefined); + expect(() => new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [])).toThrow( + "forwarder extension site not found", + ); + }); + }); + + describe("metadata", () => { + it("returns algorithm metadata", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + password, + ]); + + const metadata = provider.metadata(password.id); + + expect(metadata).toEqual(password); + }); + + it("returns forwarder metadata", async () => { + const extensionMetadata: ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [], + }; + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])), + }), + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + const metadata = provider.metadata({ forwarder: Bitwarden.id }); + + expect(metadata.id).toEqual({ forwarder: Bitwarden.id }); + }); + + it("panics when metadata not found", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + expect(() => provider.metadata("not found" as any)).toThrow("metadata not found"); + }); + + it("panics when an extension not found", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + expect(() => provider.metadata({ forwarder: "not found" as any })).toThrow( + "extension not found", + ); + }); + }); + + describe("types", () => { + it("returns the credential types", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.types(); + + expect(result).toEqual(expect.arrayContaining(Types)); + }); + }); + + describe("algorithms", () => { + it("returns the password category's algorithms", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ type: Type.password }); + + expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.password])); + }); + + it("returns the username category's algorithms", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ type: Type.username }); + + expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.username])); + }); + + it("returns the email category's algorithms", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ type: Type.email }); + + expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.email])); + }); + + it("includes forwarder vendors in the email category's algorithms", () => { + const extensionMetadata: ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [], + }; + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])), + }), + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + const result = provider.algorithms({ type: Type.email }); + + expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }])); + }); + + it.each([ + [Algorithm.catchall], + [Algorithm.passphrase], + [Algorithm.password], + [Algorithm.plusAddress], + [Algorithm.username], + ])("returns explicit algorithms (=%p)", (algorithm) => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ algorithm }); + + expect(result).toEqual([algorithm]); + }); + + it("returns explicit forwarders", () => { + const extensionMetadata: ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [], + }; + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])), + }), + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + const result = provider.algorithms({ algorithm: { forwarder: Bitwarden.id } }); + + expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }])); + }); + + it("returns an empty array when the algorithm is invalid", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + // `any` cast required because this test subverts the type system + const result = provider.algorithms({ algorithm: "an invalid algorithm" as any }); + + expect(result).toEqual([]); + }); + + it("returns an empty array when the forwarder is invalid", () => { + const extensionMetadata: ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [], + }; + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])), + }), + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + // `any` cast required because this test subverts the type system + const result = provider.algorithms({ + algorithm: { forwarder: "an invalid forwarder" as any }, + }); + + expect(result).toEqual([]); + }); + + it("panics when neither an algorithm nor a category is specified", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + // `any` cast required because this test subverts the type system + expect(() => provider.algorithms({} as any)).toThrow("algorithm or type required"); + }); + }); + + describe("algorithms$", () => { + it.each([ + [Algorithm.catchall, catchall], + [Algorithm.username, effWordList], + [Algorithm.password, password], + ])("gets a specific algorithm", async (algorithm, metadata) => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + metadata, + ]); + const result = new ReplaySubject(1); + + provider.algorithms$({ algorithm }, { account$: SomeAccount$ }).subscribe(result); + + await expect(firstValueFrom(result)).resolves.toEqual([algorithm]); + }); + + it.each([ + [Type.email, [catchall, plusAddress]], + [Type.username, [effWordList]], + [Type.password, [password, passphrase]], + ])("gets a category of algorithms", async (category, metadata) => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, metadata); + const result = new ReplaySubject(1); + + provider.algorithms$({ type: category }, { account$: SomeAccount$ }).subscribe(result); + + const expectedAlgorithms = expect.arrayContaining(metadata.map((m) => m.id)); + await expect(firstValueFrom(result)).resolves.toEqual(expectedAlgorithms); + }); + + it("omits algorithms blocked by policy", async () => { + const policy = new Policy({ + type: PolicyType.PasswordGenerator, + enabled: true, + data: { + overridePasswordType: Algorithm.password, + }, + } as any); + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([policy])); + const metadata = [password, passphrase]; + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, metadata); + const algorithmResult = new ReplaySubject(1); + const categoryResult = new ReplaySubject(1); + + provider + .algorithms$({ algorithm: Algorithm.passphrase }, { account$: SomeAccount$ }) + .subscribe(algorithmResult); + provider + .algorithms$({ type: Type.password }, { account$: SomeAccount$ }) + .subscribe(categoryResult); + + await expect(firstValueFrom(algorithmResult)).resolves.toEqual([]); + await expect(firstValueFrom(categoryResult)).resolves.toEqual([password.id]); + }); + + it("omits algorithms whose metadata is unavailable", async () => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + password, + ]); + const algorithmResult = new ReplaySubject(1); + const categoryResult = new ReplaySubject(1); + + provider + .algorithms$({ algorithm: Algorithm.passphrase }, { account$: SomeAccount$ }) + .subscribe(algorithmResult); + provider + .algorithms$({ type: Type.password }, { account$: SomeAccount$ }) + .subscribe(categoryResult); + + await expect(firstValueFrom(algorithmResult)).resolves.toEqual([]); + await expect(firstValueFrom(categoryResult)).resolves.toEqual([password.id]); + }); + + it("panics when neither algorithm nor category are specified", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + expect(() => provider.algorithms$({} as any, { account$: SomeAccount$ })).toThrow( + "algorithm or type required", + ); + }); + }); + + describe("preference$", () => { + const preferences: CredentialPreference = deepFreeze({ + [Type.email]: { algorithm: Algorithm.catchall, updated: new Date() }, + [Type.username]: { algorithm: Algorithm.username, updated: new Date() }, + [Type.password]: { algorithm: Algorithm.password, updated: new Date() }, + }); + beforeEach(async () => { + await SomeStateProvider.setUserState(PREFERENCES, preferences, SomeAccount.id); + }); + + it.each([ + [Type.email, catchall], + [Type.username, effWordList], + [Type.password, password], + ])("emits the user's %s preference", async (type, metadata) => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + metadata, + ]); + const result = new ReplaySubject(1); + + provider.preference$(type, { account$: SomeAccount$ }).subscribe(result); + + await expect(firstValueFrom(result)).resolves.toEqual(preferences[type].algorithm); + }); + + it("emits a default when the user's preference is unavailable", async () => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + plusAddress, + ]); + const result = new ReplaySubject(1); + + // precondition: the preferred email is excluded from the provided metadata + expect(preferences.email.algorithm).not.toEqual(plusAddress.id); + + provider.preference$(Type.email, { account$: SomeAccount$ }).subscribe(result); + + await expect(firstValueFrom(result)).resolves.toEqual(plusAddress.id); + }); + + it("emits undefined when the user's preference is unavailable and there is no metadata", async () => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + const result = new ReplaySubject(1); + + provider.preference$(Type.email, { account$: SomeAccount$ }).subscribe(result); + + await expect(firstValueFrom(result)).resolves.toBeUndefined(); + }); + }); + + describe("preferences", () => { + it("returns a user state subject", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const subject = provider.preferences({ account$: SomeAccount$ }); + + expect(subject).toBeInstanceOf(UserStateSubject); + }); + }); +}); diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.ts b/libs/tools/generator/core/src/services/generator-metadata-provider.ts new file mode 100644 index 00000000000..f8c07283f5a --- /dev/null +++ b/libs/tools/generator/core/src/services/generator-metadata-provider.ts @@ -0,0 +1,252 @@ +import { + Observable, + combineLatestWith, + distinctUntilChanged, + map, + shareReplay, + switchMap, + takeUntil, +} from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BoundDependency } from "@bitwarden/common/tools/dependencies"; +import { ExtensionSite } from "@bitwarden/common/tools/extension"; +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { anyComplete, pin } from "@bitwarden/common/tools/rx"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; + +import { + GeneratorMetadata, + AlgorithmsByType, + CredentialAlgorithm, + CredentialType, + isForwarderExtensionId, + toForwarderMetadata, + Type, + Algorithms, + Types, +} from "../metadata"; +import { availableAlgorithms_vNext } from "../policies/available-algorithms-policy"; +import { CredentialPreference } from "../types"; +import { + AlgorithmRequest, + TypeRequest, + MetadataRequest, + isAlgorithmRequest, + isTypeRequest, +} from "../types/metadata-request"; + +import { PREFERENCES } from "./credential-preferences"; + +/** Surfaces contextual information to credential generators */ +export class GeneratorMetadataProvider { + /** Instantiates the context provider + * @param system dependency providers for user state subjects + * @param application dependency providers for system services + */ + constructor( + private readonly system: UserStateSubjectDependencyProvider, + private readonly application: SystemServiceProvider, + algorithms: ReadonlyArray>, + ) { + this.log = system.log({ type: "GeneratorMetadataProvider" }); + + const site = application.extension.site("forwarder"); + if (!site) { + this.log.panic("forwarder extension site not found"); + } + this.site = site; + + this._metadata = new Map(algorithms.map((a) => [a.id, a] as const)); + } + + private readonly site: ExtensionSite; + private readonly log: SemanticLogger; + + private _metadata: Map>; + + /** Retrieve an algorithm's generator metadata + * @param algorithm identifies the algorithm + * @returns the algorithm's generator metadata + * @throws when the algorithm doesn't identify a known metadata entry + */ + metadata(algorithm: CredentialAlgorithm) { + let result = null; + if (isForwarderExtensionId(algorithm)) { + const extension = this.site.extensions.get(algorithm.forwarder); + if (!extension) { + this.log.panic(algorithm, "extension not found"); + } + + result = toForwarderMetadata(extension); + } else { + result = this._metadata.get(algorithm); + } + + if (!result) { + this.log.panic({ algorithm }, "metadata not found"); + } + + return result; + } + + /** retrieve credential types */ + types(): ReadonlyArray { + return Types; + } + + /** Retrieve the credential algorithm ids that match the request. + * @param requested when this has a `type` property, the method + * returns all algorithms with the same credential type. When this has an `algorithm` + * property, the method returns 0 or 1 matching algorithms. + * @returns the matching algorithms. This method always returns an array; + * the array is empty when no algorithms match the input criteria. + * @throws when neither `requested.algorithm` nor `requested.type` contains + * a value. + * @remarks this method enforces technical requirements only. + * If you want these algorithms with policy controls applied, use `algorithms$`. + */ + algorithms(requested: AlgorithmRequest): CredentialAlgorithm[]; + algorithms(requested: TypeRequest): CredentialAlgorithm[]; + algorithms(requested: MetadataRequest): CredentialAlgorithm[] { + let algorithms: CredentialAlgorithm[]; + if (isTypeRequest(requested)) { + let forwarders: CredentialAlgorithm[] = []; + if (requested.type === Type.email) { + forwarders = Array.from(this.site.extensions.keys()).map((forwarder) => ({ forwarder })); + } + + algorithms = AlgorithmsByType[requested.type].concat(forwarders); + } else if (isAlgorithmRequest(requested) && isForwarderExtensionId(requested.algorithm)) { + algorithms = this.site.extensions.has(requested.algorithm.forwarder) + ? [requested.algorithm] + : []; + } else if (isAlgorithmRequest(requested)) { + algorithms = Algorithms.includes(requested.algorithm) ? [requested.algorithm] : []; + } else { + this.log.panic(requested, "algorithm or type required"); + } + + return algorithms; + } + + // emits a function that returns `true` when the input algorithm is available + private isAvailable$( + dependencies: BoundDependency<"account", Account>, + ): Observable<(a: CredentialAlgorithm) => boolean> { + const id$ = dependencies.account$.pipe( + map((account) => account.id), + pin(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + const available$ = id$.pipe( + switchMap((id) => { + const policies$ = this.application.policy.getAll$(PolicyType.PasswordGenerator, id).pipe( + map((p) => availableAlgorithms_vNext(p).filter((a) => this._metadata.has(a))), + map((p) => new Set(p)), + // complete policy emissions otherwise `switchMap` holds `available$` open indefinitely + takeUntil(anyComplete(id$)), + ); + return policies$; + }), + map( + (available) => + function (a: CredentialAlgorithm) { + return isForwarderExtensionId(a) || available.has(a); + }, + ), + ); + + return available$; + } + + /** Retrieve credential algorithms filtered by the user's active policy. + * @param requested when this has a `type` property, the method + * returns all algorithms with a matching credential type. When this has an `algorithm` + * property, the method returns 0 or 1 matching algorithms. + * @param dependencies.account the account requesting algorithm access; + * this parameter controls which policy, if any, is applied. + * @returns an observable that emits matching algorithms. When no algorithms + * match the request, an empty array is emitted. + * @throws when neither `requested.algorithm` nor `requested.type` contains + * a value. + * @remarks this method applies policy controls. In particular, it excludes + * algorithms prohibited by a policy control. If you want lists of algorithms + * supported by the client, use `algorithms`. + */ + algorithms$( + requested: AlgorithmRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable; + algorithms$( + requested: TypeRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable; + algorithms$( + requested: MetadataRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable { + if (isTypeRequest(requested)) { + const { type } = requested; + return this.isAvailable$(dependencies).pipe( + map((isAvailable) => this.algorithms({ type }).filter(isAvailable)), + ); + } else if (isAlgorithmRequest(requested)) { + const { algorithm } = requested; + return this.isAvailable$(dependencies).pipe( + map((isAvailable) => (isAvailable(algorithm) ? [algorithm] : [])), + ); + } else { + this.log.panic(requested, "algorithm or type required"); + } + } + + preference$(type: CredentialType, dependencies: BoundDependency<"account", Account>) { + const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); + + const algorithm$ = this.preferences({ account$ }).pipe( + combineLatestWith(this.isAvailable$({ account$ })), + map(([preferences, isAvailable]) => { + const algorithm: CredentialAlgorithm = preferences[type].algorithm; + if (isAvailable(algorithm)) { + return algorithm; + } + + const algorithms = type ? this.algorithms({ type: type }) : []; + // `?? null` because logging types must be `Jsonify` + const defaultAlgorithm = algorithms.find(isAvailable) ?? null; + this.log.debug( + { algorithm, defaultAlgorithm, credentialType: type }, + "preference not available; defaulting the generator algorithm", + ); + + // `?? undefined` so that interface is ADR-14 compliant + return defaultAlgorithm ?? undefined; + }), + distinctUntilChanged(), + ); + + return algorithm$; + } + + /** Get a subject bound to credential generator preferences. + * @param dependencies.account$ identifies the account to which the preferences are bound + * @returns a subject bound to the user's preferences + * @remarks Preferences determine which algorithms are used when generating a + * credential from a credential type (e.g. `PassX` or `Username`). Preferences + * should not be used to hold navigation history. Use @bitwarden/generator-navigation + * instead. + */ + preferences( + dependencies: BoundDependency<"account", Account>, + ): UserStateSubject { + // FIXME: enforce policy + const subject = new UserStateSubject(PREFERENCES, this.system, dependencies); + + return subject; + } +} diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts index 08aec48a9e7..36b0f3046a9 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -133,7 +133,9 @@ export type CredentialGeneratorConfiguration = CredentialGener }; /** Defines the stored parameters for credential generation */ settings: { - /** value used when an account's settings haven't been initialized */ + /** value used when an account's settings haven't been initialized + * @deprecated use `ObjectKey.initial` for your desired storage property instead + */ initial: Readonly>; /** Application-global constraints that apply to account settings */ diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts index 5b74d17fa4a..c75e4329610 100644 --- a/libs/tools/generator/core/src/types/generator-type.ts +++ b/libs/tools/generator/core/src/types/generator-type.ts @@ -1,6 +1,8 @@ +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationId } from "@bitwarden/common/tools/integration"; import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types"; +import { AlgorithmsByType, CredentialType } from "../metadata"; /** A type of password that may be generated by the credential generator. */ export type PasswordAlgorithm = (typeof PasswordAlgorithms)[number]; @@ -11,7 +13,7 @@ export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number]; /** A type of email address that may be generated by the credential generator. */ export type EmailAlgorithm = (typeof EmailAlgorithms)[number]; -export type ForwarderIntegration = { forwarder: IntegrationId }; +export type ForwarderIntegration = { forwarder: IntegrationId & VendorId }; /** Returns true when the input algorithm is a forwarder integration. */ export function isForwarderIntegration( @@ -74,8 +76,8 @@ export type CredentialCategory = keyof typeof CredentialCategories; /** The kind of credential to generate using a compound configuration. */ // FIXME: extend the preferences to include a preferred forwarder export type CredentialPreference = { - [Key in CredentialCategory]: { - algorithm: (typeof CredentialCategories)[Key][number]; + [Key in CredentialType & CredentialCategory]: { + algorithm: CredentialAlgorithm & (typeof AlgorithmsByType)[Key][number]; updated: Date; }; }; diff --git a/libs/tools/generator/core/src/types/metadata-request.ts b/libs/tools/generator/core/src/types/metadata-request.ts new file mode 100644 index 00000000000..e9cae7060f0 --- /dev/null +++ b/libs/tools/generator/core/src/types/metadata-request.ts @@ -0,0 +1,13 @@ +import { CredentialAlgorithm, CredentialType } from "../metadata"; + +export type AlgorithmRequest = { algorithm: CredentialAlgorithm }; +export type TypeRequest = { type: CredentialType }; +export type MetadataRequest = Partial; + +export function isAlgorithmRequest(request: MetadataRequest): request is AlgorithmRequest { + return !!request.algorithm; +} + +export function isTypeRequest(request: MetadataRequest): request is TypeRequest { + return !!request.type; +}