1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 13:10:17 +00:00

introduce extension service

This commit is contained in:
✨ Audrey ✨
2025-02-26 15:26:29 -05:00
parent 19326609e3
commit 19c51ca31e
32 changed files with 679 additions and 76 deletions

View File

@@ -1,6 +1,6 @@
import { Observable } from "rxjs";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "../types/guid";
/** error emitted when the `SingleUserDependency` changes Ids */
export type UserChangedError = {

View File

@@ -0,0 +1,62 @@
import { EMPTY, Observable, defer, of, 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 { ExtensionSite } from "./extension-site";
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 {
constructor(
private registry: ExtensionRegistry,
private readonly providers: UserStateSubjectDependencyProvider,
) {
this.log = providers.log({
type: "ExtensionService",
});
}
private log: SemanticLogger;
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 }));
const subject = new UserStateSubject(key, this.providers, { account$ });
return subject;
}
site(site: SiteId) {
return this.registry.build(site);
}
/** Look up extension metadata for a site.
* @param site defines the site to retrieve.
* @returns an observable that emits the extension sites available at the
* moment of subscription and then completes. If the extension site is not
* available, the observable completes without emitting.
*/
site$(site: SiteId): Observable<ExtensionSite> {
return defer(() => {
const extensions = this.registry.build(site);
return extensions ? of(extensions) : EMPTY;
});
}
}

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,25 @@ export type ExtensionSet =
*/
all: true;
};
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,33 @@
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";
/** Binds an extension profile to an extension site */
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;
}

View File

@@ -10,4 +10,4 @@ export const IntegrationIds = [
] as const;
/** Identifies a vendor integrated into bitwarden */
export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">;
export type IntegrationId = Opaque<string, "IntegrationId">;

View File

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

View File

@@ -6,6 +6,9 @@ import { DefaultSemanticLogger } from "./default-semantic-logger";
import { DisabledSemanticLogger } from "./disabled-semantic-logger";
import { SemanticLogger } from "./semantic-logger.abstraction";
/** A type for injection of a log provider */
export type LogProvider = <Context>(context: Jsonify<Context>) => SemanticLogger;
/** Instantiates a semantic logger that emits nothing when a message
* is logged.
* @param _context a static payload that is cloned when the logger
@@ -25,8 +28,11 @@ export function disabledSemanticLoggerProvider<Context extends object>(
* @param settings specializes how the semantic logger functions.
* If this is omitted, the logger suppresses debug messages.
*/
export function consoleSemanticLoggerProvider(logger: LogService): SemanticLogger {
return new DefaultSemanticLogger(logger, {});
export function consoleSemanticLoggerProvider<Context extends object>(
logger: LogService,
context: Jsonify<Context>,
): SemanticLogger {
return new DefaultSemanticLogger(logger, context);
}
/** Instantiates a semantic logger that emits logs to the console.
@@ -42,7 +48,7 @@ export function ifEnabledSemanticLoggerProvider<Context extends object>(
context: Jsonify<Context>,
) {
if (enable) {
return new DefaultSemanticLogger(logger, context);
return consoleSemanticLoggerProvider(logger, context);
} else {
return disabledSemanticLoggerProvider(context);
}

View File

@@ -0,0 +1,16 @@
import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction";
import { ExtensionService } from "./extension/extension.service";
import { LogProvider } from "./log";
/** Provides access to commonly-used cross-cutting services. */
export type SystemServiceProvider = {
/** Policy configured by the administrative console */
readonly policy: PolicyService;
/** Client extension metadata and profile access */
readonly extension: ExtensionService;
/** Event monitoring and diagnostic interfaces */
readonly log: LogProvider;
};

View File

@@ -81,7 +81,7 @@ const DEFAULT_FRAME_SIZE = 32;
export class UserStateSubject<
State extends object,
Secret = State,
Disclosed = never,
Disclosed = Record<string, never>,
Dependencies = null,
>
extends Observable<State>
@@ -243,7 +243,7 @@ export class UserStateSubject<
// `init$` becomes the accumulator for `scan`
init$.pipe(
first(),
map((init) => [init, null] as const),
map((init) => [init, null] as [State, Dependencies]),
),
input$.pipe(
map((constrained) => constrained.state),
@@ -256,7 +256,7 @@ export class UserStateSubject<
if (shouldUpdate) {
// actual update
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
return [next, dependencies];
return [next, dependencies] as const;
} else {
// false update
this.log.debug("shouldUpdate prevented write");

View File

@@ -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<ApiSettings> {
const maybeForwarder = integrations.get(id);
export function getForwarderConfiguration(
id: IntegrationId | VendorId,
): ForwarderConfiguration<ApiSettings> {
// 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<ApiSettings>;

View File

@@ -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"],

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"],

View File

@@ -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"],

View File

@@ -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 */

View File

@@ -19,11 +19,13 @@ describe("email - catchall generator metadata", () => {
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<CatchallGenerationOptions> = null;
let accountProfile: CoreProfileMetadata<CatchallGenerationOptions> = 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");
}
});

View File

@@ -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<T>.capabilities.fields`.
type ForwarderOptions = Partial<EmailDomainSettings & EmailPrefixSettings & SelfHostedApiSettings>;
// update the extension metadata
export function toForwarderMetadata(
extension: ExtensionMetadata,
): GeneratorMetadata<ForwarderOptions> {
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<ForwarderOptions> = {
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<ForwarderOptions>,
constraints: {
default: {},
create() {
return new IdentityConstraint<ForwarderOptions>();
},
},
} satisfies ForwarderProfileMetadata<ForwarderOptions>,
},
};
return generator;
}

View File

@@ -19,11 +19,13 @@ describe("email - plus address generator metadata", () => {
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<SubaddressGenerationOptions> = null;
let accountProfile: CoreProfileMetadata<SubaddressGenerationOptions> = 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");
}
});

View File

@@ -0,0 +1,11 @@
import { AlgorithmsByType as ABT } from "./data";
import { CredentialType, CredentialAlgorithm } from "./type";
export const AlgorithmsByType: Record<CredentialType, ReadonlyArray<CredentialAlgorithm>> = ABT;
export { Profile, Type } 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";
export { isForwarderProfile, toVendorId, isForwarderExtensionId } from "./util";

View File

@@ -22,19 +22,21 @@ describe("password - eff words generator metadata", () => {
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<PassphraseGenerationOptions> = null;
let accountProfile: CoreProfileMetadata<PassphraseGenerationOptions> | 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);
});
});
});

View File

@@ -22,11 +22,13 @@ describe("password - characters generator metadata", () => {
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<PasswordGenerationOptions> = null;
let accountProfile: CoreProfileMetadata<PasswordGenerationOptions> = 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);
});
});
});

View File

@@ -1,6 +1,6 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { SiteId } from "@bitwarden/common/tools/extension";
import { ExtensionProfileMetadata } from "@bitwarden/common/tools/extension/type";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { Constraints } from "@bitwarden/common/tools/types";
@@ -61,13 +61,7 @@ export type CoreProfileMetadata<Options> = {
* manager. Extension profiles store their data
* 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;
export type ForwarderProfileMetadata<Options> = ExtensionProfileMetadata<Options, "forwarder"> & {
constraints: ProfileConstraints<Options>;
};
@@ -77,4 +71,4 @@ export type ExtensionProfileMetadata<Options, Site extends SiteId> = {
*/
export type ProfileMetadata<Options> =
| CoreProfileMetadata<Options>
| ExtensionProfileMetadata<Options, "forwarder">;
| ForwarderProfileMetadata<Options>;

View File

@@ -20,11 +20,13 @@ describe("username - eff words generator metadata", () => {
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<EffUsernameGenerationOptions> = null;
let accountProfile: CoreProfileMetadata<EffUsernameGenerationOptions> = 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");
}
});

View File

@@ -1,7 +1,12 @@
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { VendorId } from "@bitwarden/common/tools/extension";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { Algorithm, AlgorithmsByType } from "./data";
import { ProfileMetadata } from "./profile-metadata";
import { ProfileContext, ProfileMetadata } from "./profile-metadata";
import {
isPasswordAlgorithm,
isUsernameAlgorithm,
@@ -12,6 +17,19 @@ import {
isForwarderProfile,
} from "./util";
const SomeStorage: ObjectKey<object> = {
target: "object",
key: "arbitrary",
state: GENERATOR_DISK,
classifier: new PrivateClassifier(),
format: "classified",
options: { clearOn: [], deserializer: (value) => value },
};
function createConstraints(policies: Policy[], context: ProfileContext<object>) {
return new IdentityConstraint();
}
describe("credential generator metadata utility functions", () => {
describe("isPasswordAlgorithm", () => {
it("returns `true` when the algorithm is a password algorithm", () => {
@@ -151,10 +169,10 @@ describe("credential generator metadata utility functions", () => {
it("returns `true` when the profile's type is `core`", () => {
const profile: ProfileMetadata<object> = {
type: "core",
storage: null,
storage: SomeStorage,
constraints: {
default: {},
create: () => null,
create: createConstraints,
},
};
@@ -165,9 +183,10 @@ describe("credential generator metadata utility functions", () => {
const profile: ProfileMetadata<object> = {
type: "extension",
site: "forwarder",
storage: SomeStorage,
constraints: {
default: {},
create: () => null,
create: createConstraints,
},
};
@@ -179,10 +198,10 @@ describe("credential generator metadata utility functions", () => {
it("returns `false` when the profile's type is `core`", () => {
const profile: ProfileMetadata<object> = {
type: "core",
storage: null,
storage: SomeStorage,
constraints: {
default: {},
create: () => null,
create: createConstraints,
},
};
@@ -193,9 +212,10 @@ describe("credential generator metadata utility functions", () => {
const profile: ProfileMetadata<object> = {
type: "extension",
site: "forwarder",
storage: SomeStorage,
constraints: {
default: {},
create: () => null,
create: createConstraints,
},
};
@@ -206,9 +226,10 @@ describe("credential generator metadata utility functions", () => {
const profile: ProfileMetadata<object> = {
type: "extension",
site: "not-a-forwarder" as any,
storage: SomeStorage,
constraints: {
default: {},
create: () => null,
create: createConstraints,
},
};

View File

@@ -1,5 +1,7 @@
import { VendorId } from "@bitwarden/common/tools/extension";
import { AlgorithmsByType } from "./data";
import { CoreProfileMetadata, ExtensionProfileMetadata, ProfileMetadata } from "./profile-metadata";
import { CoreProfileMetadata, ForwarderProfileMetadata, ProfileMetadata } from "./profile-metadata";
import {
CredentialAlgorithm,
EmailAlgorithm,
@@ -29,6 +31,12 @@ export function isForwarderExtensionId(
return algorithm && typeof algorithm === "object" && "forwarder" in algorithm;
}
export function toVendorId(algorithm: CredentialAlgorithm): VendorId | undefined {
if (isForwarderExtensionId(algorithm)) {
return algorithm.forwarder as VendorId;
}
}
/** Returns true when the input algorithm is an email algorithm. */
export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm {
return AlgorithmsByType.email.includes(algorithm as any) || isForwarderExtensionId(algorithm);
@@ -55,6 +63,6 @@ export function isCoreProfile<Options>(
/** Returns true when the input describes a forwarder extension profile. */
export function isForwarderProfile<Options>(
value: ProfileMetadata<Options>,
): value is ExtensionProfileMetadata<Options, "forwarder"> {
): value is ForwarderProfileMetadata<Options> {
return value.type === "extension" && value.site === "forwarder";
}

View File

@@ -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(

View File

@@ -0,0 +1,244 @@
import {
Observable,
distinctUntilChanged,
filter,
first,
map,
shareReplay,
switchMap,
takeUntil,
withLatestFrom,
} 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 } 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,
} from "../metadata";
import { availableAlgorithms_vNext } from "../policies/available-algorithms-policy";
import { CredentialPreference } from "../types";
import { PREFERENCES } from "./credential-preferences";
type AlgorithmRequest = { algorithm: CredentialAlgorithm };
type TypeRequest = { category: CredentialType };
type MetadataRequest = Partial<AlgorithmRequest & TypeRequest>;
/** Surfaces contextual information to credential generators */
export class GeneratorMetadataProvider {
/** Instantiates the context provider
* @param providers dependency injectors for user state subjects
* @param policyService settings constraint lookups
*/
constructor(
private readonly providers: UserStateSubjectDependencyProvider,
private readonly system: SystemServiceProvider,
algorithms: GeneratorMetadata<object>[],
) {
this.log = providers.log({ type: "GeneratorMetadataProvider" });
const site = system.extension.site("forwarder");
if (!site) {
this.log.panic("forwarder extension site not found");
}
this.site = site;
this.generators = new Map(algorithms.map((a) => [a.id, a] as const));
}
private readonly site: ExtensionSite;
private readonly log: SemanticLogger;
private generators: Map<CredentialAlgorithm, GeneratorMetadata<unknown & object>>;
// looks up a set of algorithms; does not enforce policy
algorithms(requested: AlgorithmRequest): CredentialAlgorithm[];
algorithms(requested: TypeRequest): CredentialAlgorithm[];
algorithms(requested: MetadataRequest): CredentialAlgorithm[];
algorithms(requested: MetadataRequest): CredentialAlgorithm[] {
let algorithms: CredentialAlgorithm[];
if (requested.category) {
let forwarders: CredentialAlgorithm[] = [];
if (requested.category === Type.email) {
forwarders = Array.from(this.site.extensions.keys()).map((forwarder) => ({ forwarder }));
}
algorithms = AlgorithmsByType[requested.category].concat(forwarders);
} else if (requested.algorithm) {
algorithms = [requested.algorithm];
} else {
this.log.panic(requested, "algorithm or category 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 account$ = dependencies.account$.pipe(
distinctUntilChanged((previous, current) => previous.id === current.id),
shareReplay({ bufferSize: 1, refCount: true }),
);
const available$ = account$.pipe(
switchMap((account) => {
const policies$ = this.system.policy.getAll$(PolicyType.PasswordGenerator, account.id).pipe(
map((p) => new Set(availableAlgorithms_vNext(p))),
// complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely
takeUntil(anyComplete(account$)),
);
return policies$;
}),
map((available) => (a: CredentialAlgorithm) => isForwarderExtensionId(a) || available.has(a)),
);
return available$;
}
// looks up a set of algorithms; enforces policy - emits empty list when there's no algorithm available
available$(
requested: AlgorithmRequest,
dependencies: BoundDependency<"account", Account>,
): Observable<GeneratorMetadata<object>[]>;
available$(
requested: TypeRequest,
dependencies: BoundDependency<"account", Account>,
): Observable<GeneratorMetadata<object>[]>;
available$(
requested: MetadataRequest,
dependencies: BoundDependency<"account", Account>,
): Observable<GeneratorMetadata<object>[]> {
let available$: Observable<CredentialAlgorithm[]>;
if (requested.category) {
const { category } = requested;
available$ = this.isAvailable$(dependencies).pipe(
map((isAvailable) => AlgorithmsByType[category].filter(isAvailable)),
);
} else if (requested.algorithm) {
const { algorithm } = requested;
available$ = this.isAvailable$(dependencies).pipe(
map((isAvailable) => (isAvailable(algorithm) ? [algorithm] : [])),
);
} else {
this.log.panic(requested, "algorithm or category required");
}
const result$ = available$.pipe(
map((available) => available.map((algorithm) => this.getMetadata(algorithm))),
);
return result$;
}
// looks up a specific algorithm; enforces policy - observable completes without emission when there's no algorithm available.
algorithm$(
requested: AlgorithmRequest,
dependencies: BoundDependency<"account", Account>,
): Observable<GeneratorMetadata<object>>;
algorithm$(
requested: TypeRequest,
dependencies: BoundDependency<"account", Account>,
): Observable<GeneratorMetadata<object>>;
algorithm$(
requested: MetadataRequest,
dependencies: BoundDependency<"account", Account>,
): Observable<GeneratorMetadata<object>> {
const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
let algorithm$: Observable<CredentialAlgorithm | undefined>;
if (requested.category) {
this.log.debug(requested, "retrieving algorithm metadata by category");
const { category } = requested;
algorithm$ = this.preferences({ account$ }).pipe(
withLatestFrom(this.isAvailable$({ account$ })),
map(([preferences, isAvailable]) => {
let algorithm: CredentialAlgorithm | undefined = preferences[category].algorithm;
if (isAvailable(algorithm)) {
return algorithm;
}
const algorithms = AlgorithmsByType[category];
algorithm = algorithms.find(isAvailable)!;
this.log.debug(
{ algorithm, category },
"preference not available; defaulting the generator algorithm",
);
return algorithm;
}),
);
} else if (requested.algorithm) {
this.log.debug(requested, "retrieving algorithm metadata by algorithm");
const { algorithm } = requested;
algorithm$ = this.isAvailable$({ account$ }).pipe(
map((isAvailable) => (isAvailable(algorithm) ? algorithm : undefined)),
first(),
);
} else {
this.log.panic(requested, "algorithm or category required");
}
const result$ = algorithm$.pipe(
filter((value) => !!value),
map((algorithm) => this.getMetadata(algorithm!)),
);
return result$;
}
private getMetadata(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.generators.get(algorithm);
}
if (!result) {
this.log.panic({ algorithm }, "failed to load metadata");
}
return result;
}
/** 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 category (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<CredentialPreference> {
// FIXME: enforce policy
const subject = new UserStateSubject(PREFERENCES, this.providers, dependencies);
return subject;
}
}

View File

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