1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-03-12 13:39:14 +01:00

[PM-16790] introduce extension service (#13590)

This commit is contained in:
✨ Audrey ✨ 2025-03-06 11:32:42 -05:00 committed by GitHub
parent 6f4a1ea37f
commit 9761588a2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 461 additions and 39 deletions

View File

@ -144,6 +144,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", {
// Tools
export const EXTENSION_DISK = new StateDefinition("extension", "disk");
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
export const BROWSER_SEND_MEMORY = new StateDefinition("sendBrowser", "memory");

View File

@ -0,0 +1,136 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, awaitAsync } from "../../../spec";
import { Account } from "../../auth/abstractions/account.service";
import { EXTENSION_DISK, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid";
import { LegacyEncryptorProvider } from "../cryptography/legacy-encryptor-provider";
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
import { disabledSemanticLoggerProvider } from "../log";
import { UserStateSubjectDependencyProvider } from "../state/user-state-subject-dependency-provider";
import { Site } from "./data";
import { ExtensionRegistry } from "./extension-registry.abstraction";
import { ExtensionSite } from "./extension-site";
import { ExtensionService } from "./extension.service";
import { ExtensionMetadata, ExtensionProfileMetadata, ExtensionStorageKey } from "./type";
import { Vendor } from "./vendor/data";
import { SimpleLogin } from "./vendor/simplelogin";
const SomeUser = "some user" as UserId;
const SomeAccount = {
id: SomeUser,
email: "someone@example.com",
emailVerified: true,
name: "Someone",
};
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);
type TestType = { foo: string };
const SomeEncryptor: UserEncryptor = {
userId: SomeUser,
encrypt(secret) {
const tmp: any = secret;
return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any);
},
decrypt(secret) {
const tmp: any = JSON.parse(secret.encryptedString!);
return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any);
},
};
const SomeAccountService = new FakeAccountService({
[SomeUser]: SomeAccount,
});
const SomeStateProvider = new FakeStateProvider(SomeAccountService);
const SomeProvider = {
encryptor: {
userEncryptor$: () => {
return new BehaviorSubject({ encryptor: SomeEncryptor, userId: SomeUser }).asObservable();
},
organizationEncryptor$() {
throw new Error("`organizationEncryptor$` should never be invoked.");
},
} as LegacyEncryptorProvider,
state: SomeStateProvider,
log: disabledSemanticLoggerProvider,
} as UserStateSubjectDependencyProvider;
const SomeExtension: ExtensionMetadata = {
site: { id: "forwarder", availableFields: [] },
product: { vendor: SimpleLogin },
host: {
selfHost: "maybe",
baseUrl: "https://www.example.com/",
authentication: true,
},
requestedFields: [],
};
const SomeRegistry = mock<ExtensionRegistry>();
const SomeProfileMetadata = {
type: "extension",
site: Site.forwarder,
storage: {
key: "someProfile",
options: {
deserializer: (value) => value as TestType,
clearOn: [],
},
} as ExtensionStorageKey<TestType>,
} satisfies ExtensionProfileMetadata<TestType, "forwarder">;
describe("ExtensionService", () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe("settings", () => {
it("writes to the user's state", async () => {
const extension = new ExtensionService(SomeRegistry, SomeProvider);
SomeRegistry.extension.mockReturnValue(SomeExtension);
const subject = extension.settings(SomeProfileMetadata, Vendor.simplelogin, {
account$: SomeAccount$,
});
subject.next({ foo: "next value" });
await awaitAsync();
// if the write succeeded, then the storage location should contain an object;
// the precise value isn't tested to avoid coupling the test to the storage format
const expectedKey = new UserKeyDefinition(
EXTENSION_DISK,
"forwarder.simplelogin.someProfile",
SomeProfileMetadata.storage.options,
);
const result = await firstValueFrom(SomeStateProvider.getUserState$(expectedKey, SomeUser));
expect(result).toBeTruthy();
});
it("panics when the extension metadata isn't available", async () => {
const extension = new ExtensionService(SomeRegistry, SomeProvider);
expect(() =>
extension.settings(SomeProfileMetadata, Vendor.bitwarden, { account$: SomeAccount$ }),
).toThrow("extension not defined");
});
});
describe("site", () => {
it("returns an extension site", () => {
const expected = new ExtensionSite(SomeExtension.site, new Map());
SomeRegistry.build.mockReturnValueOnce(expected);
const extension = new ExtensionService(SomeRegistry, SomeProvider);
const site = extension.site(Site.forwarder);
expect(site).toEqual(expected);
});
});
});

View File

@ -0,0 +1,63 @@
import { shareReplay } from "rxjs";
import { Account } from "../../auth/abstractions/account.service";
import { BoundDependency } from "../dependencies";
import { SemanticLogger } from "../log";
import { UserStateSubject } from "../state/user-state-subject";
import { UserStateSubjectDependencyProvider } from "../state/user-state-subject-dependency-provider";
import { ExtensionRegistry } from "./extension-registry.abstraction";
import { ExtensionProfileMetadata, SiteId, VendorId } from "./type";
import { toObjectKey } from "./util";
/** Provides configuration and storage support for Bitwarden client extensions.
* These extensions integrate 3rd party services into Bitwarden.
*/
export class ExtensionService {
/** Instantiate the extension service.
* @param registry provides runtime status for extension sites
* @param providers provide persistent data
*/
constructor(
private registry: ExtensionRegistry,
private readonly providers: UserStateSubjectDependencyProvider,
) {
this.log = providers.log({
type: "ExtensionService",
});
}
private log: SemanticLogger;
/** Get a subject bound to a user's extension settings
* @param profile the site's extension profile
* @param vendor the vendor integrated at the extension site
* @param dependencies.account$ the account to which the settings are bound
* @returns a subject bound to the requested user's generator settings
*/
settings<Settings extends object, Site extends SiteId>(
profile: ExtensionProfileMetadata<Settings, Site>,
vendor: VendorId,
dependencies: BoundDependency<"account", Account>,
): UserStateSubject<Settings> {
const metadata = this.registry.extension(profile.site, vendor);
if (!metadata) {
this.log.panic({ site: profile.site as string, vendor }, "extension not defined");
}
const key = toObjectKey(profile, metadata);
const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
// FIXME: load and apply constraints
const subject = new UserStateSubject(key, this.providers, { account$ });
return subject;
}
/** Look up extension metadata for a site
* @param site defines the site to retrieve.
* @returns the extensions available at the site.
*/
site(site: SiteId) {
return this.registry.build(site);
}
}

View File

@ -1,5 +1,7 @@
import { Opaque } from "type-fest";
import { ObjectKey } from "../state/object-key";
import { Site, Field, Permission } from "./data";
/** well-known name for a feature extensible through an extension. */
@ -17,6 +19,11 @@ export type ExtensionId = { site: SiteId; vendor: VendorId };
/** Permission levels for metadata. */
export type ExtensionPermission = keyof typeof Permission;
/** The preferred vendor to use at each site. */
export type ExtensionPreferences = {
[key in SiteId]?: { vendor: VendorId; updated: Date };
};
/** The capabilities and descriptive content for an extension */
export type SiteMetadata = {
/** Uniquely identifies the extension site. */
@ -107,3 +114,29 @@ export type ExtensionSet =
*/
all: true;
};
/** A key for storing JavaScript objects (`{ an: "example" }`)
* in the extension profile system.
* @remarks The omitted keys are filled by the extension service.
*/
export type ExtensionStorageKey<Options> = Omit<
ObjectKey<Options>,
"target" | "state" | "format" | "classifier"
>;
/** Extension profiles encapsulate data storage using the extension system.
*/
export type ExtensionProfileMetadata<Options, Site extends SiteId> = {
/** distinguishes profile metadata types */
type: "extension";
/** The extension site described by this metadata */
site: Site;
/** persistent storage location; `storage.key` is used to construct
* the extension key in the format `${extension.site}.${extension.vendor}.${storage.key}`,
* where `extension.`-prefixed fields are read from extension metadata. Extension
* settings always use the "classified" format and keep all fields private.
*/
storage: ExtensionStorageKey<Options>;
};

View File

@ -0,0 +1,54 @@
import { EXTENSION_DISK } from "../../platform/state";
import { PrivateClassifier } from "../private-classifier";
import { deepFreeze } from "../util";
import { Site } from "./data";
import { ExtensionMetadata, ExtensionProfileMetadata } from "./type";
import { toObjectKey } from "./util";
import { Bitwarden } from "./vendor/bitwarden";
const ExampleProfile: ExtensionProfileMetadata<object, "forwarder"> = deepFreeze({
type: "extension",
site: "forwarder",
storage: {
key: "example",
options: {
clearOn: [],
deserializer: (value) => value as any,
},
initial: {},
frame: 1,
},
});
const ExampleMetadata: ExtensionMetadata = {
site: { id: Site.forwarder, availableFields: [] },
product: { vendor: Bitwarden },
host: { authentication: true, selfHost: "maybe", baseUrl: "http://example.com" },
requestedFields: [],
};
describe("toObjectKey", () => {
it("sets static fields", () => {
const result = toObjectKey(ExampleProfile, ExampleMetadata);
expect(result.target).toEqual("object");
expect(result.format).toEqual("classified");
expect(result.state).toBe(EXTENSION_DISK);
expect(result.classifier).toBeInstanceOf(PrivateClassifier);
});
it("creates a dynamic object key", () => {
const result = toObjectKey(ExampleProfile, ExampleMetadata);
expect(result.key).toEqual("forwarder.bitwarden.example");
});
it("copies the profile storage metadata", () => {
const result = toObjectKey(ExampleProfile, ExampleMetadata);
expect(result.frame).toEqual(ExampleProfile.storage.frame);
expect(result.options).toBe(ExampleProfile.storage.options);
expect(result.initial).toBe(ExampleProfile.storage.initial);
});
});

View File

@ -0,0 +1,36 @@
import { EXTENSION_DISK } from "../../platform/state";
import { PrivateClassifier } from "../private-classifier";
import { Classifier } from "../state/classifier";
import { ObjectKey } from "../state/object-key";
import { ExtensionMetadata, ExtensionProfileMetadata, SiteId } from "./type";
/** Create an object key from an extension instance and a site profile.
* @param profile the extension profile to bind
* @param extension the extension metadata to bind
*/
export function toObjectKey<Settings extends object, Site extends SiteId>(
profile: ExtensionProfileMetadata<Settings, Site>,
extension: ExtensionMetadata,
) {
// FIXME: eliminate this cast
const classifier = new PrivateClassifier<Settings>() as Classifier<
Settings,
Record<string, never>,
Settings
>;
const result: ObjectKey<Settings> = {
// copy storage to retain extensibility
...profile.storage,
// fields controlled by the extension system override those in the profile
target: "object",
key: `${extension.site.id}.${extension.product.vendor.id}.${profile.storage.key}`,
state: EXTENSION_DISK,
classifier,
format: "classified",
};
return result;
}

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

@ -14,33 +14,36 @@ describe("DefaultSemanticLogger", () => {
describe("debug", () => {
it("writes structural log messages to console.log", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.debug("this is a debug message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, {
"@timestamp": 0,
message: "this is a debug message",
level: "debug",
});
});
it("writes structural content to console.log", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.debug({ example: "this is content" });
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, {
"@timestamp": 0,
content: { example: "this is content" },
level: "debug",
});
});
it("writes structural content to console.log with a message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.info({ example: "this is content" }, "this is a message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is a message",
level: "information",
@ -50,33 +53,36 @@ describe("DefaultSemanticLogger", () => {
describe("info", () => {
it("writes structural log messages to console.log", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.info("this is an info message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
"@timestamp": 0,
message: "this is an info message",
level: "information",
});
});
it("writes structural content to console.log", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.info({ example: "this is content" });
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
"@timestamp": 0,
content: { example: "this is content" },
level: "information",
});
});
it("writes structural content to console.log with a message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.info({ example: "this is content" }, "this is a message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is a message",
level: "information",
@ -86,33 +92,36 @@ describe("DefaultSemanticLogger", () => {
describe("warn", () => {
it("writes structural log messages to console.warn", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.warn("this is a warning message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, {
"@timestamp": 0,
message: "this is a warning message",
level: "warning",
});
});
it("writes structural content to console.warn", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.warn({ example: "this is content" });
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, {
"@timestamp": 0,
content: { example: "this is content" },
level: "warning",
});
});
it("writes structural content to console.warn with a message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.warn({ example: "this is content" }, "this is a message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is a message",
level: "warning",
@ -122,33 +131,36 @@ describe("DefaultSemanticLogger", () => {
describe("error", () => {
it("writes structural log messages to console.error", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.error("this is an error message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
message: "this is an error message",
level: "error",
});
});
it("writes structural content to console.error", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.error({ example: "this is content" });
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
content: { example: "this is content" },
level: "error",
});
});
it("writes structural content to console.error with a message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.error({ example: "this is content" }, "this is a message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is a message",
level: "error",
@ -158,24 +170,26 @@ describe("DefaultSemanticLogger", () => {
describe("panic", () => {
it("writes structural log messages to console.error before throwing the message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
expect(() => log.panic("this is an error message")).toThrow("this is an error message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
message: "this is an error message",
level: "error",
});
});
it("writes structural log messages to console.error with a message before throwing the message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
expect(() => log.panic({ example: "this is content" }, "this is an error message")).toThrow(
"this is an error message",
);
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is an error message",
level: "error",
@ -183,13 +197,14 @@ describe("DefaultSemanticLogger", () => {
});
it("writes structural log messages to console.error with a content before throwing the message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
expect(() => log.panic("this is content", "this is an error message")).toThrow(
"this is an error message",
);
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
content: "this is content",
message: "this is an error message",
level: "error",

View File

@ -18,6 +18,7 @@ export class DefaultSemanticLogger<Context extends object> implements SemanticLo
constructor(
private logger: LogService,
context: Jsonify<Context>,
private now = () => Date.now(),
) {
this.context = context && typeof context === "object" ? context : {};
}
@ -53,6 +54,7 @@ export class DefaultSemanticLogger<Context extends object> implements SemanticLo
message,
content: content ?? undefined,
level: stringifyLevel(level),
"@timestamp": this.now(),
};
if (typeof content === "string" && !message) {

View File

@ -17,7 +17,7 @@ export class PrivateClassifier<Data> implements Classifier<Data, Record<string,
}
const secret = picked as Jsonify<Data>;
return { disclosed: null, secret };
return { disclosed: {}, secret };
}
declassify(_disclosed: Jsonify<Record<keyof Data, never>>, secret: Jsonify<Data>) {

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

@ -16,7 +16,7 @@ export class PublicClassifier<Data> implements Classifier<Data, Data, Record<str
}
const disclosed = picked as Jsonify<Data>;
return { disclosed, secret: null };
return { disclosed, secret: "" };
}
declassify(disclosed: Jsonify<Data>, _secret: Jsonify<Record<keyof Data, never>>) {

View File

@ -0,0 +1,27 @@
import { isClassifiedFormat } from "./classified-format";
describe("isClassifiedFormat", () => {
it("returns `false` when the argument is `null`", () => {
expect(isClassifiedFormat(null)).toEqual(false);
});
it.each([
[{ id: true, secret: "" }],
[{ secret: "", disclosed: {} }],
[{ id: true, disclosed: {} }],
])("returns `false` when the argument is missing a required member (=%p).", (value) => {
expect(isClassifiedFormat(value)).toEqual(false);
});
it("returns `false` when 'secret' is not a string", () => {
expect(isClassifiedFormat({ id: true, secret: false, disclosed: {} })).toEqual(false);
});
it("returns `false` when 'disclosed' is not an object", () => {
expect(isClassifiedFormat({ id: true, secret: "", disclosed: false })).toEqual(false);
});
it("returns `true` when the argument has a `secret`, `disclosed`, and `id`.", () => {
expect(isClassifiedFormat({ id: true, secret: "", disclosed: {} })).toEqual(true);
});
});

View File

@ -21,5 +21,12 @@ export type ClassifiedFormat<Id, Disclosed> = {
export function isClassifiedFormat<Id, Disclosed>(
value: any,
): value is ClassifiedFormat<Id, Disclosed> {
return "id" in value && "secret" in value && "disclosed" in value;
return (
!!value &&
"id" in value &&
"secret" in value &&
"disclosed" in value &&
typeof value.secret === "string" &&
typeof value.disclosed === "object"
);
}

View File

@ -523,6 +523,7 @@ export class UserStateSubject<
private onError(value: any) {
if (!this.isDisposed) {
this.log.debug(value, "forwarding error to subscribers");
this.output.error(value);
}

View File

@ -1,10 +1,13 @@
import { AlgorithmsByType as ABT } 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<CredentialType, ReadonlyArray<CredentialAlgorithm>> = ABT;
export { Profile, Type } from "./data";
export { GeneratorMetadata } from "./generator-metadata";
export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata";
export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type";
export { isForwarderProfile, isForwarderExtensionId } from "./util";
export { isForwarderProfile, toVendorId, isForwarderExtensionId } from "./util";

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

@ -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,17 @@ export function isForwarderExtensionId(
return algorithm && typeof algorithm === "object" && "forwarder" in algorithm;
}
/** Extract a `VendorId` from a `CredentialAlgorithm`.
* @param algorithm the algorithm containing the vendor id
* @returns the vendor id if the algorithm identifies a forwarder extension.
* Otherwise, undefined.
*/
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 +68,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";
}