diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4775d1f7af..feb5a7706f 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2204,6 +2204,9 @@ "useThisPassword": { "message": "Use this password" }, + "useThisPassphrase": { + "message": "Use this passphrase" + }, "useThisUsername": { "message": "Use this username" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 631f7d571f..9d668d464a 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -351,12 +351,6 @@ "other": { "message": "Other" }, - "generatePassword": { - "message": "Generate password" - }, - "generatePassphrase": { - "message": "Generate passphrase" - }, "type": { "message": "Type" }, @@ -2633,6 +2627,24 @@ "usernameGenerator": { "message": "Username generator" }, + "generatePassword": { + "message": "Generate password" + }, + "generatePassphrase": { + "message": "Generate passphrase" + }, + "passwordGenerated": { + "message": "Password generated" + }, + "passphraseGenerated": { + "message": "Passphrase generated" + }, + "usernameGenerated": { + "message": "Username generated" + }, + "emailGenerated": { + "message": "Email generated" + }, "spinboxBoundariesHint": { "message": "Value must be between $MIN$ and $MAX$.", "description": "Explains spin box minimum and maximum values to the user", @@ -2686,6 +2698,15 @@ "useThisEmail": { "message": "Use this email" }, + "useThisPassword": { + "message": "Use this password" + }, + "useThisPassphrase": { + "message": "Use this passphrase" + }, + "useThisUsername": { + "message": "Use this username" + }, "random": { "message": "Random" }, @@ -3051,12 +3072,6 @@ "weakAndBreachedMasterPasswordDesc": { "message": "Weak password identified and found in a data breach. Use a strong and unique password to protect your account. Are you sure you want to use this password?" }, - "useThisPassword": { - "message": "Use this password" - }, - "useThisUsername": { - "message": "Use this username" - }, "checkForBreaches": { "message": "Check known data breaches for this password" }, diff --git a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts index f11b14aea3..26f87f333e 100644 --- a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts @@ -7,7 +7,7 @@ import { BehaviorSubject, map } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Generators } from "@bitwarden/generator-core"; +import { BuiltIn, Profile } from "@bitwarden/generator-core"; import { BasePolicy, BasePolicyComponent } from "./base-policy.component"; @@ -26,14 +26,22 @@ export class PasswordGeneratorPolicy extends BasePolicy { export class PasswordGeneratorPolicyComponent extends BasePolicyComponent { // these properties forward the application default settings to the UI // for HTML attribute bindings - protected readonly minLengthMin = Generators.password.settings.constraints.length.min; - protected readonly minLengthMax = Generators.password.settings.constraints.length.max; - protected readonly minNumbersMin = Generators.password.settings.constraints.minNumber.min; - protected readonly minNumbersMax = Generators.password.settings.constraints.minNumber.max; - protected readonly minSpecialMin = Generators.password.settings.constraints.minSpecial.min; - protected readonly minSpecialMax = Generators.password.settings.constraints.minSpecial.max; - protected readonly minNumberWordsMin = Generators.passphrase.settings.constraints.numWords.min; - protected readonly minNumberWordsMax = Generators.passphrase.settings.constraints.numWords.max; + protected readonly minLengthMin = + BuiltIn.password.profiles[Profile.account].constraints.default.length.min; + protected readonly minLengthMax = + BuiltIn.password.profiles[Profile.account].constraints.default.length.max; + protected readonly minNumbersMin = + BuiltIn.password.profiles[Profile.account].constraints.default.minNumber.min; + protected readonly minNumbersMax = + BuiltIn.password.profiles[Profile.account].constraints.default.minNumber.max; + protected readonly minSpecialMin = + BuiltIn.password.profiles[Profile.account].constraints.default.minSpecial.min; + protected readonly minSpecialMax = + BuiltIn.password.profiles[Profile.account].constraints.default.minSpecial.max; + protected readonly minNumberWordsMin = + BuiltIn.passphrase.profiles[Profile.account].constraints.default.numWords.min; + protected readonly minNumberWordsMax = + BuiltIn.passphrase.profiles[Profile.account].constraints.default.numWords.max; data = this.formBuilder.group({ overridePasswordType: [null], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3c17baabbb..a170612fab 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -547,12 +547,6 @@ "message": "Toggle collapse", "description": "Toggling an expand/collapse state." }, - "generatePassword": { - "message": "Generate password" - }, - "generatePassphrase": { - "message": "Generate passphrase" - }, "checkPassword": { "message": "Check if password has been exposed." }, @@ -6825,6 +6819,24 @@ "generateEmail": { "message": "Generate email" }, + "generatePassword": { + "message": "Generate password" + }, + "generatePassphrase": { + "message": "Generate passphrase" + }, + "passwordGenerated": { + "message": "Password generated" + }, + "passphraseGenerated": { + "message": "Passphrase generated" + }, + "usernameGenerated": { + "message": "Username generated" + }, + "emailGenerated": { + "message": "Email generated" + }, "spinboxBoundariesHint": { "message": "Value must be between $MIN$ and $MAX$.", "description": "Explains spin box minimum and maximum values to the user", @@ -6888,6 +6900,9 @@ "useThisPassword": { "message": "Use this password" }, + "useThisPassphrase": { + "message": "Use this passphrase" + }, "useThisUsername": { "message": "Use this username" }, diff --git a/libs/common/src/tools/extension/extension.service.spec.ts b/libs/common/src/tools/extension/extension.service.spec.ts index dad5684b52..9959488fec 100644 --- a/libs/common/src/tools/extension/extension.service.spec.ts +++ b/libs/common/src/tools/extension/extension.service.spec.ts @@ -60,6 +60,7 @@ const SomeProvider = { } as LegacyEncryptorProvider, state: SomeStateProvider, log: disabledSemanticLoggerProvider, + now: Date.now, } as UserStateSubjectDependencyProvider; const SomeExtension: ExtensionMetadata = { diff --git a/libs/common/src/tools/log/disabled-logger.ts b/libs/common/src/tools/log/disabled-logger.ts new file mode 100644 index 0000000000..53feb0c8b3 --- /dev/null +++ b/libs/common/src/tools/log/disabled-logger.ts @@ -0,0 +1,24 @@ +import { Jsonify } from "type-fest"; + +import { deepFreeze } from "../util"; + +import { SemanticLogger } from "./semantic-logger.abstraction"; + +/** All disabled loggers emitted by this module are `===` to this logger. */ +export const DISABLED_LOGGER: SemanticLogger = deepFreeze({ + debug(_content: Jsonify, _message?: string): void {}, + + info(_content: Jsonify, _message?: string): void {}, + + warn(_content: Jsonify, _message?: string): void {}, + + error(_content: Jsonify, _message?: string): void {}, + + 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/disabled-semantic-logger.ts b/libs/common/src/tools/log/disabled-semantic-logger.ts deleted file mode 100644 index 21ea48bbe5..0000000000 --- a/libs/common/src/tools/log/disabled-semantic-logger.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Jsonify } from "type-fest"; - -import { SemanticLogger } from "./semantic-logger.abstraction"; - -/** Disables semantic logs. Still panics. */ -export class DisabledSemanticLogger implements SemanticLogger { - debug(_content: Jsonify, _message?: string): void {} - - info(_content: Jsonify, _message?: string): void {} - - warn(_content: Jsonify, _message?: string): void {} - - error(_content: Jsonify, _message?: string): void {} - - 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/factory.ts b/libs/common/src/tools/log/factory.ts index f8abc4d224..5f0f2d74e9 100644 --- a/libs/common/src/tools/log/factory.ts +++ b/libs/common/src/tools/log/factory.ts @@ -3,11 +3,10 @@ import { Jsonify } from "type-fest"; import { LogService } from "../../platform/abstractions/log.service"; import { DefaultSemanticLogger } from "./default-semantic-logger"; -import { DisabledSemanticLogger } from "./disabled-semantic-logger"; +import { DISABLED_LOGGER } from "./disabled-logger"; import { SemanticLogger } from "./semantic-logger.abstraction"; - -/** A type for injection of a log provider */ -export type LogProvider = (context: Jsonify) => SemanticLogger; +import { LogProvider } from "./types"; +import { warnLoggingEnabled } from "./util"; /** Instantiates a semantic logger that emits nothing when a message * is logged. @@ -18,38 +17,72 @@ export type LogProvider = (context: Jsonify) => SemanticLogger export function disabledSemanticLoggerProvider( _context: Jsonify, ): SemanticLogger { - return new DisabledSemanticLogger(); + return DISABLED_LOGGER; } /** Instantiates a semantic logger that emits logs to the console. - * @param context a static payload that is cloned when the logger - * logs a message. The `messages`, `level`, and `content` fields - * are reserved for use by loggers. - * @param settings specializes how the semantic logger functions. - * If this is omitted, the logger suppresses debug messages. + * @param logService writes semantic logs to the console */ -export function consoleSemanticLoggerProvider( - logger: LogService, - context: Jsonify, -): SemanticLogger { - return new DefaultSemanticLogger(logger, context); +export function consoleSemanticLoggerProvider(logService: LogService): LogProvider { + function provider(context: Jsonify) { + const logger = new DefaultSemanticLogger(logService, context); + + warnLoggingEnabled(logService, "consoleSemanticLoggerProvider", context); + return logger; + } + + return provider; } -/** Instantiates a semantic logger that emits logs to the console. +/** Instantiates a semantic logger that emits logs to the console when the + * context's `type` matches its values. + * @param logService writes semantic logs to the console + * @param types the values to match against + */ +export function enableLogForTypes(logService: LogService, types: string[]): LogProvider { + if (types.length) { + warnLoggingEnabled(logService, "enableLogForTypes", { types }); + } + + function provider(context: Jsonify) { + const { type } = context as { type?: unknown }; + if (typeof type === "string" && types.includes(type)) { + const logger = new DefaultSemanticLogger(logService, context); + + warnLoggingEnabled(logService, "enableLogForTypes", { + targetType: type, + available: types, + loggerContext: context, + }); + return logger; + } else { + return DISABLED_LOGGER; + } + } + + return provider; +} + +/** Instantiates a semantic logger that emits logs to the console when its enabled. + * @param enable logs are emitted when this is true + * @param logService writes semantic logs to the console * @param context a static payload that is cloned when the logger - * logs a message. The `messages`, `level`, and `content` fields - * are reserved for use by loggers. - * @param settings specializes how the semantic logger functions. - * If this is omitted, the logger suppresses debug messages. + * logs a message. + * + * @remarks The `message`, `level`, `provider`, and `content` fields + * are reserved for use by the semantic logging system. */ export function ifEnabledSemanticLoggerProvider( enable: boolean, - logger: LogService, + logService: LogService, context: Jsonify, ) { if (enable) { - return consoleSemanticLoggerProvider(logger, context); + const logger = new DefaultSemanticLogger(logService, context); + + warnLoggingEnabled(logService, "ifEnabledSemanticLoggerProvider", context); + return logger; } else { - return disabledSemanticLoggerProvider(context); + return DISABLED_LOGGER; } } diff --git a/libs/common/src/tools/log/index.ts b/libs/common/src/tools/log/index.ts index 22444d23e2..1c93a6ce63 100644 --- a/libs/common/src/tools/log/index.ts +++ b/libs/common/src/tools/log/index.ts @@ -1,2 +1,4 @@ export * from "./factory"; +export * from "./disabled-logger"; +export { LogProvider } from "./types"; export { SemanticLogger } from "./semantic-logger.abstraction"; diff --git a/libs/common/src/tools/log/types.ts b/libs/common/src/tools/log/types.ts new file mode 100644 index 0000000000..ca887af422 --- /dev/null +++ b/libs/common/src/tools/log/types.ts @@ -0,0 +1,11 @@ +import { Jsonify } from "type-fest"; + +import { SemanticLogger } from "./semantic-logger.abstraction"; + +/** Creates a semantic logger. + * @param context all logs emitted by the logger are extended with + * these fields. + * @remarks The `message`, `level`, `provider`, and `content` fields + * are reserved for use by the semantic logging system. + */ +export type LogProvider = (context: Jsonify) => SemanticLogger; diff --git a/libs/common/src/tools/log/util.ts b/libs/common/src/tools/log/util.ts new file mode 100644 index 0000000000..cf1c39230e --- /dev/null +++ b/libs/common/src/tools/log/util.ts @@ -0,0 +1,12 @@ +import { LogService } from "../../platform/abstractions/log.service"; + +// show our GRIT - these functions implement generalized logging +// controls and should return DISABLED_LOGGER in production. +export function warnLoggingEnabled(logService: LogService, method: string, context?: any) { + logService.warning({ + method, + context, + provider: "tools/log", + message: "Semantic logging enabled. 🦟 Please report this bug if you see it 🦟", + }); +} diff --git a/libs/common/src/tools/private-classifier.ts b/libs/common/src/tools/private-classifier.ts index e2406d314c..58244ae990 100644 --- a/libs/common/src/tools/private-classifier.ts +++ b/libs/common/src/tools/private-classifier.ts @@ -17,7 +17,7 @@ export class PrivateClassifier implements Classifier; - return { disclosed: {}, secret }; + return { disclosed: null, secret }; } declassify(_disclosed: Jsonify>, secret: Jsonify) { diff --git a/libs/common/src/tools/public-classifier.ts b/libs/common/src/tools/public-classifier.ts index 136bee555a..e036ebd1c4 100644 --- a/libs/common/src/tools/public-classifier.ts +++ b/libs/common/src/tools/public-classifier.ts @@ -16,7 +16,7 @@ export class PublicClassifier implements Classifier; - return { disclosed, secret: "" }; + return { disclosed, secret: null }; } declassify(disclosed: Jsonify, _secret: Jsonify>) { diff --git a/libs/common/src/tools/rx.rxjs.ts b/libs/common/src/tools/rx.rxjs.ts new file mode 100644 index 0000000000..7c11d658f1 --- /dev/null +++ b/libs/common/src/tools/rx.rxjs.ts @@ -0,0 +1,13 @@ +import { Observable } from "rxjs"; + +/** + * Used to infer types from arguments to functions like {@link withLatestReady}. + * So that you can have `forkJoin([Observable, PromiseLike]): Observable<[A, B]>` + * et al. + * @remarks this type definition is derived from rxjs' {@link ObservableInputTuple}. + * The difference is it *only* works with observables, while the rx version works + * with any thing that can become an observable. + */ +export type ObservableTuple = { + [K in keyof T]: Observable; +}; diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index ee1de1c911..5177dcaa2a 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -3,7 +3,7 @@ * @jest-environment ../../../../shared/test.environment.ts */ // @ts-strict-ignore this file explicitly tests what happens when types are ignored -import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs"; +import { of, firstValueFrom, Subject, tap, EmptyError, BehaviorSubject } from "rxjs"; import { awaitAsync, trackEmissions } from "../../spec"; @@ -16,733 +16,825 @@ import { reduceCollection, withLatestReady, pin, + memoizedMap, } from "./rx"; -describe("errorOnChange", () => { - it("emits a single value when the input emits only once", async () => { - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); +describe("tools rx utilites", () => { + describe("errorOnChange", () => { + it("emits a single value when the input emits only once", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); - source$.next(1); + source$.next(1); - expect(results).toEqual([1]); + expect(results).toEqual([1]); + }); + + it("emits when the input emits", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + + source$.next(1); + source$.next(1); + + expect(results).toEqual([1, 1]); + }); + + it("errors when the input errors", async () => { + const source$ = new Subject(); + const expected = {}; + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.error(expected); + + expect(error).toBe(expected); + }); + + it("completes when the input completes", async () => { + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBe(true); + }); + + it("errors when the input changes", async () => { + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next(1); + source$.next(2); + + expect(error).toEqual({ expectedValue: 1, actualValue: 2 }); + }); + + it("emits when the extracted value remains constant", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + const results: Foo[] = []; + source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v)); + + source$.next({ foo: "bar" }); + source$.next({ foo: "bar" }); + + expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]); + }); + + it("errors when an extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" }); + }); + + it("constructs an error when the extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$ + .pipe( + errorOnChange( + (v) => v.foo, + (expected, actual) => ({ expected, actual }), + ), + ) + .subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expected: "bar", actual: "baz" }); + }); }); - it("emits when the input emits", async () => { - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + describe("reduceCollection", () => { + it.each([[null], [undefined], [[]]])( + "should return the default value when the collection is %p", + async (value: number[]) => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of(value); - source$.next(1); - source$.next(1); + const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result = await firstValueFrom(result$); - expect(results).toEqual([1, 1]); - }); + expect(result).toEqual(100); + }, + ); - it("errors when the input errors", async () => { - const source$ = new Subject(); - const expected = {}; - let error: any = null; - source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); - - source$.error(expected); - - expect(error).toBe(expected); - }); - - it("completes when the input completes", async () => { - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) }); - - source$.complete(); - - expect(complete).toBe(true); - }); - - it("errors when the input changes", async () => { - const source$ = new Subject(); - let error: any = null; - source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); - - source$.next(1); - source$.next(2); - - expect(error).toEqual({ expectedValue: 1, actualValue: 2 }); - }); - - it("emits when the extracted value remains constant", async () => { - type Foo = { foo: string }; - const source$ = new Subject(); - const results: Foo[] = []; - source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v)); - - source$.next({ foo: "bar" }); - source$.next({ foo: "bar" }); - - expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]); - }); - - it("errors when an extracted value changes", async () => { - type Foo = { foo: string }; - const source$ = new Subject(); - let error: any = null; - source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) }); - - source$.next({ foo: "bar" }); - source$.next({ foo: "baz" }); - - expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" }); - }); - - it("constructs an error when the extracted value changes", async () => { - type Foo = { foo: string }; - const source$ = new Subject(); - let error: any = null; - source$ - .pipe( - errorOnChange( - (v) => v.foo, - (expected, actual) => ({ expected, actual }), - ), - ) - .subscribe({ error: (v: unknown) => (error = v) }); - - source$.next({ foo: "bar" }); - source$.next({ foo: "baz" }); - - expect(error).toEqual({ expected: "bar", actual: "baz" }); - }); -}); - -describe("reduceCollection", () => { - it.each([[null], [undefined], [[]]])( - "should return the default value when the collection is %p", - async (value: number[]) => { + it("should reduce the collection to a single value", async () => { const reduce = (acc: number, value: number) => acc + value; - const source$ = of(value); + const source$ = of([1, 2, 3]); - const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result$ = source$.pipe(reduceCollection(reduce, 0)); const result = await firstValueFrom(result$); - expect(result).toEqual(100); - }, - ); - - it("should reduce the collection to a single value", async () => { - const reduce = (acc: number, value: number) => acc + value; - const source$ = of([1, 2, 3]); - - const result$ = source$.pipe(reduceCollection(reduce, 0)); - const result = await firstValueFrom(result$); - - expect(result).toEqual(6); - }); -}); - -describe("distinctIfShallowMatch", () => { - it("emits a single value", async () => { - const source$ = of({ foo: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }]); - }); - - it("emits different values", async () => { - const source$ = of({ foo: true }, { foo: false }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }, { foo: false }]); - }); - - it("emits new keys", async () => { - const source$ = of({ foo: true }, { foo: true, bar: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }, { foo: true, bar: true }]); - }); - - it("suppresses identical values", async () => { - const source$ = of({ foo: true }, { foo: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }]); - }); - - it("suppresses removed keys", async () => { - const source$ = of({ foo: true, bar: true }, { foo: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true, bar: true }]); - }); -}); - -describe("anyComplete", () => { - it("emits true when its input completes", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("completes when its input is already complete", () => { - const input = new Subject(); - input.complete(); - - let completed = false; - anyComplete(input).subscribe({ complete: () => (completed = true) }); - - expect(completed).toBe(true); - }); - - it("completes when any input completes", () => { - const input$ = new Subject(); - const completing$ = new Subject(); - - let completed = false; - anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); - completing$.complete(); - - expect(completed).toBe(true); - }); - - it("ignores emissions", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.next(1); - input$.next(2); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("forwards errors", () => { - const input$ = new Subject(); - const expected = { some: "error" }; - - let error = null; - anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); - input$.error(expected); - - expect(error).toEqual(expected); - }); -}); - -describe("ready", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: ready$ should be cold - const ready$ = source$.pipe(ready(watch$)); - expect(connected).toBe(false); - - ready$.subscribe(); - - expect(connected).toBe(true); - }); - - it("suppresses source emissions until its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("suppresses source emissions until all watches emit", () => { - const watchA$ = new Subject(); - const watchB$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready([watchA$, watchB$])); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // preconditions: no emissions - source$.next(1); - expect(results).toEqual([]); - watchA$.next(); - expect(results).toEqual([]); - - watchB$.next(); - - expect(results).toEqual([1]); - }); - - it("emits the last source emission when its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - source$.next(2); - watch$.next(); - - expect(results).toEqual([2]); - }); - - it("emits all source emissions after its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - source$.next(2); - - expect(results).toEqual([1, 2]); - }); - - it("ignores repeated watch emissions", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let completed = false; - ready$.subscribe({ complete: () => (completed = true) }); - - source$.complete(); - - expect(completed).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch completes before emitting", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.complete(); - - expect(error).toBeInstanceOf(EmptyError); - }); -}); - -describe("withLatestReady", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: ready$ should be cold - const ready$ = source$.pipe(withLatestReady(watch$)); - expect(connected).toBe(false); - - ready$.subscribe(); - - expect(connected).toBe(true); - }); - - it("suppresses source emissions until its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - watch$.next("watch"); - - expect(results).toEqual([[1, "watch"]]); - }); - - it("emits the last source emission when its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - source$.next(2); - watch$.next("watch"); - - expect(results).toEqual([[2, "watch"]]); - }); - - it("emits all source emissions after its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next("watch"); - source$.next(1); - source$.next(2); - - expect(results).toEqual([ - [1, "watch"], - [2, "watch"], - ]); - }); - - it("appends the latest watch emission", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next("ignored"); - watch$.next("watch"); - source$.next(1); - watch$.next("ignored"); - watch$.next("watch"); - source$.next(2); - - expect(results).toEqual([ - [1, "watch"], - [2, "watch"], - ]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - let completed = false; - ready$.subscribe({ complete: () => (completed = true) }); - - source$.complete(); - - expect(completed).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch completes before emitting", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.complete(); - - expect(error).toBeInstanceOf(EmptyError); - }); -}); - -describe("on", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: on$ should be cold - const on$ = source$.pipe(on(watch$)); - expect(connected).toBeFalsy(); - - on$.subscribe(); - - expect(connected).toBeTruthy(); - }); - - it("suppresses source emissions until `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - // precondition: on$ should be cold - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("repeats source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - watch$.next(); - - expect(results).toEqual([1, 1]); - }); - - it("updates source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("emits a value when `on` emits before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("ignores repeated `on` emissions before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("emits only the latest source emission when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - - source$.next(2); - source$.next(3); - watch$.next(); - - expect(results).toEqual([1, 3]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - source$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("completes when its watch completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - watch$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); -}); - -describe("pin", () => { - it("emits the first value", async () => { - const input = new Subject(); - const result: unknown[] = []; - - input.pipe(pin()).subscribe((v) => result.push(v)); - input.next(1); - - expect(result).toEqual([1]); - }); - - it("filters repeated emissions", async () => { - const input = new Subject(); - const result: unknown[] = []; - - input.pipe(pin({ distinct: (p, c) => p == c })).subscribe((v) => result.push(v)); - input.next(1); - input.next(1); - - expect(result).toEqual([1]); - }); - - it("errors if multiple emissions occur", async () => { - const input = new Subject(); - let error: any = null!; - - input.pipe(pin()).subscribe({ - error: (e: unknown) => { - error = e; - }, + expect(result).toEqual(6); }); - input.next(1); - input.next(1); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(/^unknown/); }); - it("names the pinned observables if multiple emissions occur", async () => { - const input = new Subject(); - let error: any = null!; + describe("distinctIfShallowMatch", () => { + it("emits a single value", async () => { + const source$ = of({ foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); - input.pipe(pin({ name: () => "example" })).subscribe({ - error: (e: unknown) => { - error = e; - }, + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); }); - input.next(1); - input.next(1); - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(/^example/); + it("emits different values", async () => { + const source$ = of({ foo: true }, { foo: false }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: false }]); + }); + + it("emits new keys", async () => { + const source$ = of({ foo: true }, { foo: true, bar: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: true, bar: true }]); + }); + + it("suppresses identical values", async () => { + const source$ = of({ foo: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); + }); + + it("suppresses removed keys", async () => { + const source$ = of({ foo: true, bar: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true, bar: true }]); + }); }); - it("errors if indistinct emissions occur", async () => { - const input = new Subject(); - let error: any = null!; + describe("anyComplete", () => { + it("emits true when its input completes", () => { + const input$ = new Subject(); - input - .pipe(pin({ distinct: (p, c) => p == c })) - .subscribe({ error: (e: unknown) => (error = e) }); - input.next(1); - input.next(2); + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.complete(); - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(/^unknown/); + expect(emissions).toEqual([true]); + }); + + it("completes when its input is already complete", () => { + const input = new Subject(); + input.complete(); + + let completed = false; + anyComplete(input).subscribe({ complete: () => (completed = true) }); + + expect(completed).toBe(true); + }); + + it("completes when any input completes", () => { + const input$ = new Subject(); + const completing$ = new Subject(); + + let completed = false; + anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); + completing$.complete(); + + expect(completed).toBe(true); + }); + + it("ignores emissions", () => { + const input$ = new Subject(); + + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.next(1); + input$.next(2); + input$.complete(); + + expect(emissions).toEqual([true]); + }); + + it("forwards errors", () => { + const input$ = new Subject(); + const expected = { some: "error" }; + + let error = null; + anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); + input$.error(expected); + + expect(error).toEqual(expected); + }); + }); + + describe("ready", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(ready(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("suppresses source emissions until all watches emit", () => { + const watchA$ = new Subject(); + const watchB$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready([watchA$, watchB$])); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // preconditions: no emissions + source$.next(1); + expect(results).toEqual([]); + watchA$.next(); + expect(results).toEqual([]); + + watchB$.next(); + + expect(results).toEqual([1]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next(); + + expect(results).toEqual([2]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + source$.next(2); + + expect(results).toEqual([1, 2]); + }); + + it("ignores repeated watch emissions", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); + }); + + describe("withLatestReady", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(withLatestReady(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next("watch"); + + expect(results).toEqual([[1, "watch"]]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next("watch"); + + expect(results).toEqual([[2, "watch"]]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("watch"); + source$.next(1); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("appends the latest watch emission", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("ignored"); + watch$.next("watch"); + source$.next(1); + watch$.next("ignored"); + watch$.next("watch"); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); + }); + + describe("on", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: on$ should be cold + const on$ = source$.pipe(on(watch$)); + expect(connected).toBeFalsy(); + + on$.subscribe(); + + expect(connected).toBeTruthy(); + }); + + it("suppresses source emissions until `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + // precondition: on$ should be cold + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("repeats source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + watch$.next(); + + expect(results).toEqual([1, 1]); + }); + + it("updates source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("emits a value when `on` emits before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("ignores repeated `on` emissions before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("emits only the latest source emission when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + + source$.next(2); + source$.next(3); + watch$.next(); + + expect(results).toEqual([1, 3]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("completes when its watch completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + watch$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + }); + + describe("pin", () => { + it("emits the first value", async () => { + const input = new Subject(); + const result: unknown[] = []; + + input.pipe(pin()).subscribe((v) => result.push(v)); + input.next(1); + + expect(result).toEqual([1]); + }); + + it("filters repeated emissions", async () => { + const input = new Subject(); + const result: unknown[] = []; + + input.pipe(pin({ distinct: (p, c) => p == c })).subscribe((v) => result.push(v)); + input.next(1); + input.next(1); + + expect(result).toEqual([1]); + }); + + it("errors if multiple emissions occur", async () => { + const input = new Subject(); + let error: any = null!; + + input.pipe(pin()).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + input.next(1); + input.next(1); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/^unknown/); + }); + + it("names the pinned observables if multiple emissions occur", async () => { + const input = new Subject(); + let error: any = null!; + + input.pipe(pin({ name: () => "example" })).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + input.next(1); + input.next(1); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/^example/); + }); + + it("errors if indistinct emissions occur", async () => { + const input = new Subject(); + let error: any = null!; + + input + .pipe(pin({ distinct: (p, c) => p == c })) + .subscribe({ error: (e: unknown) => (error = e) }); + input.next(1); + input.next(2); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/^unknown/); + }); + }); + + describe("memoizedMap", () => { + it("maps a value", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const expectedResult = {}; + source$.pipe(memoizedMap(() => expectedResult)).subscribe(result$); + + source$.next("foo"); + + expect(result$.value).toEqual(expectedResult); + }); + + it("caches a mapped result", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map)).subscribe(result$); + + source$.next("foo"); + source$.next("foo"); + + expect(map).toHaveBeenCalledTimes(1); + }); + + it("caches the last mapped result", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map)).subscribe(result$); + + source$.next("foo"); + source$.next("foo"); + source$.next("bar"); + source$.next("foo"); + + expect(map).toHaveBeenCalledTimes(3); + }); + + it("caches multiple mapped results", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map, { size: 2 })).subscribe(result$); + + source$.next("foo"); + source$.next("bar"); + source$.next("foo"); + source$.next("bar"); + + expect(map).toHaveBeenCalledTimes(2); + }); + + it("caches a result by key", () => { + const source$ = new Subject<{ key: string }>(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map, { key: (s) => s.key })).subscribe(result$); + + // the messages are not equal; the keys are + source$.next({ key: "foo" }); + source$.next({ key: "foo" }); + source$.next({ key: "bar" }); + source$.next({ key: "bar" }); + + expect(map).toHaveBeenCalledTimes(2); + }); + }); + + it("errors", () => { + const source$ = new Subject(); + let error: unknown = null; + source$.pipe(memoizedMap(() => {})).subscribe({ error: (e: unknown) => (error = e) }); + const expectedError = {}; + + source$.error(expectedError); + + expect(error).toEqual(expectedError); + }); + + it("completes", () => { + const source$ = new Subject(); + let completed = false; + source$.pipe(memoizedMap(() => {})).subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toEqual(true); }); }); diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts index ea39713558..4c9e547d15 100644 --- a/libs/common/src/tools/rx.ts +++ b/libs/common/src/tools/rx.ts @@ -20,8 +20,13 @@ import { startWith, pairwise, MonoTypeOperatorFunction, + Cons, + scan, + filter, } from "rxjs"; +import { ObservableTuple } from "./rx.rxjs"; + /** Returns its input. */ function identity(value: any): any { return value; @@ -164,26 +169,30 @@ export function ready(watch$: Observable | Observable[]) { ); } -export function withLatestReady( - watch$: Observable, -): OperatorFunction { +export function withLatestReady( + ...watches$: [...ObservableTuple] +): OperatorFunction> { return connect((source$) => { // these subscriptions are safe because `source$` connects only after there // is an external subscriber. const source = new ReplaySubject(1); source$.subscribe(source); - const watch = new ReplaySubject(1); - watch$.subscribe(watch); + + const watches = watches$.map((w) => { + const watch$ = new ReplaySubject(1); + w.subscribe(watch$); + return watch$; + }) as [...ObservableTuple]; // `concat` is subscribed immediately after it's returned, at which point - // `zip` blocks until all items in `watching$` are ready. If that occurs + // `zip` blocks until all items in `watches` are ready. If that occurs // after `source$` is hot, then the replay subject sends the last-captured - // emission through immediately. Otherwise, `ready` waits for the next - // emission - return concat(zip(watch).pipe(first(), ignoreElements()), source).pipe( - withLatestFrom(watch), + // emission through immediately. Otherwise, `withLatestFrom` waits for the + // next emission + return concat(zip(watches).pipe(first(), ignoreElements()), source).pipe( + withLatestFrom(...watches), takeUntil(anyComplete(source)), - ); + ) as Observable>; }); } @@ -238,3 +247,54 @@ export function pin(options?: { }), ); } + +/** maps a value to a result and keeps a cache of the mapping + * @param mapResult - maps the stream to a result; this function must return + * a value. It must not return null or undefined. + * @param options.size - the number of entries in the cache + * @param options.key - maps the source to a cache key + * @remarks this method is useful for optimization of expensive + * `mapResult` calls. It's also useful when an interned reference type + * is needed. + */ +export function memoizedMap>( + mapResult: (source: Source) => Result, + options?: { size?: number; key?: (source: Source) => unknown }, +): OperatorFunction { + return pipe( + // scan's accumulator contains the cache + scan( + ([cache], source) => { + const key: unknown = options?.key?.(source) ?? source; + + // cache hit? + let result = cache?.get(key); + if (result) { + return [cache, result] as const; + } + + // cache miss + result = mapResult(source); + cache?.set(key, result); + + // trim cache + const overage = cache.size - (options?.size ?? 1); + if (overage > 0) { + Array.from(cache?.keys() ?? []) + .slice(0, overage) + .forEach((k) => cache?.delete(k)); + } + + return [cache, result] as const; + }, + // FIXME: upgrade to a least-recently-used cache + [new Map(), null] as [Map, Source | null], + ), + + // encapsulate cache + map(([, result]) => result), + + // preserve `NonNullable` constraint on `Result` + filter((result): result is Result => !!result), + ); +} diff --git a/libs/common/src/tools/state/user-state-subject-dependency-provider.ts b/libs/common/src/tools/state/user-state-subject-dependency-provider.ts index 2763aac483..89b6d7e727 100644 --- a/libs/common/src/tools/state/user-state-subject-dependency-provider.ts +++ b/libs/common/src/tools/state/user-state-subject-dependency-provider.ts @@ -15,4 +15,9 @@ export abstract class UserStateSubjectDependencyProvider { // FIXME: remove `log` and inject the system provider into the USS instead /** Provides semantic logging */ abstract log: (_context: Jsonify) => SemanticLogger; + + /** Get the system time as a number of seconds since the unix epoch + * @remarks this can be turned into a date using `new Date(provider.now())` + */ + abstract now: () => number; } diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index 20acff1787..a6d452d37f 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -90,6 +90,7 @@ const SomeProvider = { } as LegacyEncryptorProvider, state: SomeStateProvider, log: disabledSemanticLoggerProvider, + now: () => 100, }; function fooMaxLength(maxLength: number): StateConstraints { diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index dd88ec2fb2..118b0069c8 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -477,7 +477,12 @@ export class UserStateSubject< * @returns the subscription */ subscribe(observer?: Partial> | ((value: State) => void) | null): Subscription { - return this.output.pipe(map((wc) => wc.state)).subscribe(observer); + return this.output + .pipe( + map((wc) => wc.state), + distinctUntilChanged(), + ) + .subscribe(observer); } // using subjects to ensure the right semantics are followed; diff --git a/libs/common/src/tools/types.ts b/libs/common/src/tools/types.ts index 83f451351c..6123b1f1de 100644 --- a/libs/common/src/tools/types.ts +++ b/libs/common/src/tools/types.ts @@ -2,6 +2,11 @@ import { Simplify } from "type-fest"; import { IntegrationId } from "./integration"; +/** When this is a string, it contains the i18n key. When it is an object, the `literal` member + * contains text that should not be translated. + */ +export type I18nKeyOrLiteral = string | { literal: string }; + /** Constraints that are shared by all primitive field types */ type PrimitiveConstraint = { /** `true` indicates the field is required; otherwise the field is optional */ diff --git a/libs/common/src/tools/util.ts b/libs/common/src/tools/util.ts index 9a3a14c1c8..0fcb5a972a 100644 --- a/libs/common/src/tools/util.ts +++ b/libs/common/src/tools/util.ts @@ -1,3 +1,5 @@ +import { I18nKeyOrLiteral } from "./types"; + /** Recursively freeze an object's own keys * @param value the value to freeze * @returns `value` @@ -10,10 +12,22 @@ export function deepFreeze(value: T): Readonly { for (const key of keys) { const own = value[key]; - if ((own && typeof own === "object") || typeof own === "function") { + if (own && typeof own === "object") { deepFreeze(own); } } return Object.freeze(value); } + +/** Type guard that returns `true` when the value is an i18n key. */ +export function isI18nKey(value: I18nKeyOrLiteral): value is string { + return typeof value === "string"; +} + +/** Type guard that returns `true` when the value requires no translation. + * @remarks the literal value can be accessed using the `.literal` property. + */ +export function isLiteral(value: I18nKeyOrLiteral): value is { literal: string } { + return typeof value === "object" && "literal" in value; +} diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 0512b56e20..956cc611c2 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -57,7 +57,7 @@ import { ToastService, } from "@bitwarden/components"; import { GeneratorServicesModule } from "@bitwarden/generator-components"; -import { CredentialGeneratorService, GenerateRequest, Generators } from "@bitwarden/generator-core"; +import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { ExportedVault, VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; import { EncryptedExportType } from "../enums/encrypted-export-type.enum"; @@ -248,7 +248,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { }), ); this.generatorService - .generate$(Generators.password, { on$: this.onGenerate$, account$ }) + .generate$({ on$: this.onGenerate$, account$ }) .pipe(takeUntil(this.destroy$)) .subscribe((generated) => { this.exportForm.patchValue({ @@ -379,7 +379,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { } generatePassword = async () => { - this.onGenerate$.next({ source: "export" }); + this.onGenerate$.next({ source: "export", type: Type.password }); }; submit = async () => { diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index 3bf586c5a2..a836b26f98 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, @@ -17,7 +15,7 @@ import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { CatchallGenerationOptions, CredentialGeneratorService, - Generators, + BuiltIn, } from "@bitwarden/generator-core"; /** Options group for catchall emails */ @@ -28,7 +26,6 @@ import { }) export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { /** Instantiates the component - * @param accountService queries user availability * @param generatorService settings and policy logic * @param formBuilder reactive form controls */ @@ -37,24 +34,26 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { private generatorService: CredentialGeneratorService, ) {} - /** Binds the component to a specific user's settings. + /** Binds the component to a specific user's settings.\ + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account!: Account; private account$ = new ReplaySubject(1); /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use `CredentialGeneratorService.settings(...)` instead. */ @Output() readonly onUpdated = new EventEmitter(); /** The template's control bindings */ protected settings = this.formBuilder.group({ - catchallDomain: [Generators.catchall.settings.initial.catchallDomain], + catchallDomain: [""], }); async ngOnChanges(changes: SimpleChanges) { @@ -64,7 +63,7 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { } async ngOnInit() { - const settings = await this.generatorService.settings(Generators.catchall, { + const settings = await this.generatorService.settings(BuiltIn.catchall, { account$: this.account$, }); @@ -79,7 +78,7 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { this.saveSettings .pipe( withLatestFrom(this.settings.valueChanges), - map(([, settings]) => settings), + map(([, settings]) => settings as CatchallGenerationOptions), takeUntil(this.destroyed$), ) .subscribe(settings); diff --git a/libs/tools/generator/components/src/credential-generator-history.component.ts b/libs/tools/generator/components/src/credential-generator-history.component.ts index e7dadb3da7..76dfbaea86 100644 --- a/libs/tools/generator/components/src/credential-generator-history.component.ts +++ b/libs/tools/generator/components/src/credential-generator-history.component.ts @@ -6,6 +6,7 @@ import { BehaviorSubject, ReplaySubject, Subject, map, switchMap, takeUntil, tap import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SemanticLogger, @@ -19,10 +20,11 @@ import { ItemModule, NoItemsModule, } from "@bitwarden/components"; -import { CredentialGeneratorService } from "@bitwarden/generator-core"; +import { AlgorithmsByType, CredentialGeneratorService } from "@bitwarden/generator-core"; import { GeneratedCredential, GeneratorHistoryService } from "@bitwarden/generator-history"; import { GeneratorModule } from "./generator.module"; +import { translate } from "./util"; @Component({ standalone: true, @@ -45,6 +47,7 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O constructor( private generatorService: CredentialGeneratorService, private history: GeneratorHistoryService, + private i18nService: I18nService, private logService: LogService, ) {} @@ -94,13 +97,19 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O } protected getCopyText(credential: GeneratedCredential) { - const info = this.generatorService.algorithm(credential.category); - return info.copy; + // there isn't a way way to look up category metadata so + // bodge it by looking up algorithm metadata + const [id] = AlgorithmsByType[credential.category]; + const info = this.generatorService.algorithm(id); + return translate(info.i18nKeys.copyCredential, this.i18nService); } protected getGeneratedValueText(credential: GeneratedCredential) { - const info = this.generatorService.algorithm(credential.category); - return info.credentialType; + // there isn't a way way to look up category metadata so + // bodge it by looking up algorithm metadata + const [id] = AlgorithmsByType[credential.category]; + const info = this.generatorService.algorithm(id); + return translate(info.i18nKeys.credentialType, this.i18nService); } ngOnDestroy() { diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 2414696845..559554b7dd 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -42,13 +42,13 @@ @@ -84,7 +84,7 @@ @@ -94,12 +94,12 @@ [forwarder]="forwarderId$ | async" /> diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 0b48b4cf0f..78b803392d 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { LiveAnnouncer } from "@angular/cdk/a11y"; import { Component, @@ -24,15 +22,15 @@ import { map, ReplaySubject, Subject, - switchMap, takeUntil, + tap, withLatestFrom, } from "rxjs"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { SemanticLogger, disabledSemanticLoggerProvider, @@ -41,23 +39,25 @@ import { import { UserId } from "@bitwarden/common/types/guid"; import { ToastService, Option } from "@bitwarden/components"; import { - AlgorithmInfo, - CredentialAlgorithm, - CredentialCategory, + CredentialType, CredentialGeneratorService, GenerateRequest, GeneratedCredential, - Generators, - getForwarderConfiguration, - isEmailAlgorithm, - isForwarderIntegration, - isPasswordAlgorithm, + isForwarderExtensionId, isSameAlgorithm, + isEmailAlgorithm, isUsernameAlgorithm, - toCredentialGeneratorConfiguration, + isPasswordAlgorithm, + CredentialAlgorithm, + AlgorithmMetadata, + Algorithm, + AlgorithmsByType, + Type, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; +import { translate } from "./util"; + // constants used to identify navigation selections that are not // generator algorithms const IDENTIFIER = "identifier"; @@ -84,11 +84,14 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro private ariaLive: LiveAnnouncer, ) {} + /** exports algorithm symbols to the template */ + protected readonly Algorithm = Algorithm; + /** Binds the component to a specific user's settings. When this input is not provided, * the form binds to the active user */ @Input() - account: Account | null; + account: Account | null = null; /** Send structured debug logs from the credential generator component * to the debugger console. @@ -127,7 +130,7 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro @Output() readonly onGenerated = new EventEmitter(); - protected root$ = new BehaviorSubject<{ nav: string }>({ + protected root$ = new BehaviorSubject<{ nav: string | null }>({ nav: null, }); @@ -141,11 +144,11 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro } protected username = this.formBuilder.group({ - nav: [null as string], + nav: [null as string | null], }); protected forwarder = this.formBuilder.group({ - nav: [null as string], + nav: [null as string | null], }); async ngOnInit() { @@ -154,33 +157,52 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro }); if (!this.account) { - this.account = await firstValueFrom(this.accountService.activeAccount$); - this.log.info( - { userId: this.account.id }, - "account not specified; using active account settings", - ); - this.account$.next(this.account); + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + this.log.panic("active account cannot be `null`."); + } + + this.log.info({ userId: account.id }, "account not specified; using active account settings"); + this.account$.next(account); } - this.generatorService - .algorithms$(["email", "username"], { account$: this.account$ }) + combineLatest([ + this.generatorService.algorithms$("email", { account$: this.account$ }), + this.generatorService.algorithms$("username", { account$: this.account$ }), + ]) .pipe( + map((algorithms) => algorithms.flat()), map((algorithms) => { - const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + // construct options for username and email algorithms; replace forwarder + // entry with a virtual entry for drill-down + const usernames = algorithms.filter((a) => !isForwarderExtensionId(a.id)); + usernames.sort((a, b) => a.weight - b.weight); const usernameOptions = this.toOptions(usernames); - usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") }); + usernameOptions.splice(-1, 0, { + value: FORWARDER, + label: this.i18nService.t("forwardedEmail"), + }); - const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + // construct options for forwarder algorithms; they get their own selection box + const forwarders = algorithms.filter((a) => isForwarderExtensionId(a.id)); + forwarders.sort((a, b) => a.weight - b.weight); const forwarderOptions = this.toOptions(forwarders); forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); return [usernameOptions, forwarderOptions] as const; }), + tap((algorithms) => + this.log.debug({ algorithms: algorithms as object }, "algorithms loaded"), + ), takeUntil(this.destroyed), ) .subscribe(([usernames, forwarders]) => { - this.usernameOptions$.next(usernames); - this.forwarderOptions$.next(forwarders); + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.usernameOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); }); this.generatorService @@ -195,9 +217,15 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro ) .subscribe(this.rootOptions$); - this.algorithm$ + this.maybeAlgorithm$ .pipe( - map((a) => a?.description), + map((a) => { + if (a?.i18nKeys?.description) { + return translate(a.i18nKeys.description, this.i18nService); + } else { + return ""; + } + }), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -208,9 +236,9 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro }); }); - this.algorithm$ + this.maybeAlgorithm$ .pipe( - map((a) => a?.category), + map((a) => a?.type), distinctUntilChanged(), takeUntil(this.destroyed), ) @@ -223,10 +251,12 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro }); // wire up the generator - this.algorithm$ + this.generatorService + .generate$({ + on$: this.generate$, + account$: this.account$, + }) .pipe( - filter((algorithm) => !!algorithm), - switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), catchError((error: unknown, generator) => { if (typeof error === "string") { this.toastService.showToast({ @@ -241,11 +271,14 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro // continue with origin stream return generator; }), - withLatestFrom(this.account$, this.algorithm$), + withLatestFrom(this.account$, this.maybeAlgorithm$), takeUntil(this.destroyed), ) .subscribe(([generated, account, algorithm]) => { - this.log.debug({ source: generated.source }, "credential generated"); + this.log.debug( + { source: generated.source ?? null, algorithm: algorithm?.id ?? null }, + "credential generated", + ); this.generatorHistoryService .track(account.id, generated.credential, generated.category, generated.generationDate) @@ -256,8 +289,8 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - if (generated.source === this.USER_REQUEST) { - this.announce(algorithm.onGeneratedMessage); + if (algorithm && generated.source === this.USER_REQUEST) { + this.announce(translate(algorithm.i18nKeys.credentialGenerated, this.i18nService)); } this.generatedCredential$.next(generated); @@ -274,36 +307,46 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro this.root$ .pipe( - map( - (root): CascadeValue => - root.nav === IDENTIFIER - ? { nav: root.nav } - : { nav: root.nav, algorithm: JSON.parse(root.nav) }, - ), + map((root): CascadeValue => { + if (root.nav === IDENTIFIER) { + return { nav: root.nav }; + } else if (root.nav) { + return { nav: root.nav, algorithm: JSON.parse(root.nav) }; + } else { + return { nav: IDENTIFIER }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeRoot$); this.username.valueChanges .pipe( - map( - (username): CascadeValue => - username.nav === FORWARDER - ? { nav: username.nav } - : { nav: username.nav, algorithm: JSON.parse(username.nav) }, - ), + map((username): CascadeValue => { + if (username.nav === FORWARDER) { + return { nav: username.nav }; + } else if (username.nav) { + return { nav: username.nav, algorithm: JSON.parse(username.nav) }; + } else { + const [algorithm] = AlgorithmsByType[Type.username]; + return { nav: JSON.stringify(algorithm), algorithm }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeIdentifier$); this.forwarder.valueChanges .pipe( - map( - (forwarder): CascadeValue => - forwarder.nav === NONE_SELECTED - ? { nav: forwarder.nav } - : { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }, - ), + map((forwarder): CascadeValue => { + if (forwarder.nav === NONE_SELECTED) { + return { nav: forwarder.nav }; + } else if (forwarder.nav) { + return { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }; + } else { + return { nav: NONE_SELECTED }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeForwarder$); @@ -314,7 +357,7 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro map(([root, username, forwarder]) => { const showForwarder = !root.algorithm && !username.algorithm; const forwarderId = - showForwarder && isForwarderIntegration(forwarder.algorithm) + showForwarder && forwarder.algorithm && isForwarderExtensionId(forwarder.algorithm) ? forwarder.algorithm.forwarder : null; return [showForwarder, forwarderId] as const; @@ -344,47 +387,51 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro return null; } }), - distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + distinctUntilChanged((prev, next) => { + if (prev === null || next === null) { + return false; + } else { + return isSameAlgorithm(prev.id, next.id); + } + }), takeUntil(this.destroyed), ) .subscribe((algorithm) => { - this.log.debug(algorithm, "algorithm selected"); + this.log.debug({ algorithm: algorithm?.id ?? null }, "algorithm selected"); // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - this.algorithm$.next(algorithm); + this.maybeAlgorithm$.next(algorithm); }); }); // assume the last-selected generator algorithm is the user's preferred one const preferences = await this.generatorService.preferences({ account$: this.account$ }); this.algorithm$ - .pipe( - filter((algorithm) => !!algorithm), - withLatestFrom(preferences), - takeUntil(this.destroyed), - ) + .pipe(withLatestFrom(preferences), takeUntil(this.destroyed)) .subscribe(([algorithm, preference]) => { - function setPreference(category: CredentialCategory, log: SemanticLogger) { - const p = preference[category]; + function setPreference(type: CredentialType) { + const p = preference[type]; p.algorithm = algorithm.id; p.updated = new Date(); - - log.info({ algorithm, category }, "algorithm preferences updated"); } // `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference` if (isEmailAlgorithm(algorithm.id)) { - setPreference("email", this.log); + setPreference("email"); } else if (isUsernameAlgorithm(algorithm.id)) { - setPreference("username", this.log); + setPreference("username"); } else if (isPasswordAlgorithm(algorithm.id)) { - setPreference("password", this.log); + setPreference("password"); } else { return; } + this.log.info( + { algorithm: algorithm.id, type: algorithm.type }, + "algorithm preferences updated", + ); preferences.next(preference); }); @@ -392,10 +439,12 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro preferences .pipe( map(({ email, username, password }) => { - const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; const usernamePref = email.updated > username.updated ? email : username; + const forwarderPref = isForwarderExtensionId(usernamePref.algorithm) + ? usernamePref + : null; - // inject drilldown flags + // inject drill-down flags const forwarderNav = !forwarderPref ? NONE_SELECTED : JSON.stringify(forwarderPref.algorithm); @@ -411,14 +460,14 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro selection: { nav: rootNav }, active: { nav: rootNav, - algorithm: rootNav === IDENTIFIER ? null : password.algorithm, + algorithm: rootNav === IDENTIFIER ? undefined : password.algorithm, } as CascadeValue, }, username: { selection: { nav: userNav }, active: { nav: userNav, - algorithm: forwarderPref ? null : usernamePref.algorithm, + algorithm: forwarderPref ? undefined : usernamePref.algorithm, }, }, forwarder: { @@ -435,6 +484,15 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro takeUntil(this.destroyed), ) .subscribe(({ root, username, forwarder }) => { + this.log.debug( + { + root: root.selection, + username: username.selection, + forwarder: forwarder.selection, + }, + "navigation updated", + ); + // update navigation; break subscription loop this.onRootChanged(root.selection); this.username.setValue(username.selection, { emitEvent: false }); @@ -448,16 +506,16 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro // automatically regenerate when the algorithm switches if the algorithm // allows it; otherwise set a placeholder - this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.maybeAlgorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.zone.run(() => { - if (!a || a.onlyOnRequest) { - this.log.debug("autogeneration disabled; clearing generated credential"); - this.generatedCredential$.next(null); - } else { + if (a?.capabilities?.autogenerate) { this.log.debug("autogeneration enabled"); this.generate("autogenerate").catch((e: unknown) => { this.log.error(e as object, "a failure occurred during autogeneration"); }); + } else { + this.log.debug("autogeneration disabled; clearing generated credential"); + this.generatedCredential$.next(undefined); } }); }); @@ -469,41 +527,6 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro this.ariaLive.announce(message).catch((e) => this.logService.error(e)); } - private typeToGenerator$(algorithm: CredentialAlgorithm) { - const dependencies = { - on$: this.generate$, - account$: this.account$, - }; - - this.log.debug({ algorithm }, "constructing generation stream"); - - switch (algorithm) { - case "catchall": - return this.generatorService.generate$(Generators.catchall, dependencies); - - case "subaddress": - return this.generatorService.generate$(Generators.subaddress, dependencies); - - case "username": - return this.generatorService.generate$(Generators.username, dependencies); - - case "password": - return this.generatorService.generate$(Generators.password, dependencies); - - case "passphrase": - return this.generatorService.generate$(Generators.passphrase, dependencies); - } - - if (isForwarderIntegration(algorithm)) { - const forwarder = getForwarderConfiguration(algorithm.forwarder); - const configuration = toCredentialGeneratorConfiguration(forwarder); - const generator = this.generatorService.generate$(configuration, dependencies); - return generator; - } - - this.log.panic({ algorithm }, `Invalid generator type: "${algorithm}"`); - } - /** Lists the top-level credential types supported by the component. * @remarks This is string-typed because angular doesn't support * structural equality for objects, which prevents `CredentialAlgorithm` @@ -519,15 +542,20 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro protected forwarderOptions$ = new BehaviorSubject[]>([]); /** Tracks the currently selected forwarder. */ - protected forwarderId$ = new BehaviorSubject(null); + protected forwarderId$ = new BehaviorSubject(null); /** Tracks forwarder control visibility */ protected showForwarder$ = new BehaviorSubject(false); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected maybeAlgorithm$ = new ReplaySubject(1); - protected showAlgorithm$ = this.algorithm$.pipe( + /** tracks the last valid algorithm selection */ + protected algorithm$ = this.maybeAlgorithm$.pipe( + filter((algorithm): algorithm is AlgorithmMetadata => !!algorithm), + ); + + protected showAlgorithm$ = this.maybeAlgorithm$.pipe( combineLatestWith(this.showForwarder$), map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), ); @@ -536,33 +564,32 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro * Emits the copy button aria-label respective of the selected credential type */ protected credentialTypeCopyLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ i18nKeys: { copyCredential } }) => translate(copyCredential, this.i18nService)), ); /** * Emits the generate button aria-label respective of the selected credential type */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ generate }) => generate), + map(({ i18nKeys: { generateCredential } }) => translate(generateCredential, this.i18nService)), ); /** * Emits the copy credential toast respective of the selected credential type */ protected credentialTypeLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ credentialType }) => credentialType), + map(({ i18nKeys: { credentialType } }) => translate(credentialType, this.i18nService)), ); /** Emits hint key for the currently selected credential type */ - protected credentialTypeHint$ = new ReplaySubject(1); + protected credentialTypeHint$ = new ReplaySubject(1); /** tracks the currently selected credential category */ - protected category$ = new ReplaySubject(1); + protected category$ = new ReplaySubject(1); - private readonly generatedCredential$ = new BehaviorSubject(null); + private readonly generatedCredential$ = new BehaviorSubject( + undefined, + ); /** Emits the last generated value. */ protected readonly value$ = this.generatedCredential$.pipe( @@ -580,15 +607,20 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro * origin in the debugger. */ protected async generate(source: string) { - const request = { source, website: this.website }; + const algorithm = await firstValueFrom(this.algorithm$); + const request: GenerateRequest = { source, algorithm: algorithm.id }; + if (this.website) { + request.website = this.website; + } + this.log.debug(request, "generation requested"); this.generate$.next(request); } - private toOptions(algorithms: AlgorithmInfo[]) { + private toOptions(algorithms: AlgorithmMetadata[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: JSON.stringify(algorithm.id), - label: algorithm.name, + label: translate(algorithm.i18nKeys.name, this.i18nService), })); return options; diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts index 689cc7e258..c961cd5bb7 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.ts +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, @@ -14,13 +12,11 @@ import { FormBuilder } from "@angular/forms"; import { map, ReplaySubject, skip, Subject, switchAll, takeUntil, withLatestFrom } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { - CredentialGeneratorConfiguration, CredentialGeneratorService, - getForwarderConfiguration, - NoPolicy, - toCredentialGeneratorConfiguration, + ForwarderOptions, + GeneratorMetadata, } from "@bitwarden/generator-core"; const Controls = Object.freeze({ @@ -37,7 +33,6 @@ const Controls = Object.freeze({ }) export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component - * @param accountService queries user availability * @param generatorService settings and policy logic * @param formBuilder reactive form controls */ @@ -47,14 +42,16 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy ) {} /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @Input({ required: true }) - forwarder: IntegrationId; + forwarder: VendorId = null!; /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like @@ -71,24 +68,19 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy [Controls.baseUrl]: [""], }); - private forwarderId$ = new ReplaySubject(1); + private vendor = new ReplaySubject(1); async ngOnInit() { - const forwarder$ = new ReplaySubject>(1); - this.forwarderId$ + const forwarder$ = new ReplaySubject>(1); + this.vendor .pipe( - map((id) => getForwarderConfiguration(id)), - // type erasure necessary because the configuration properties are - // determined dynamically at runtime - // FIXME: this can be eliminated by unifying the forwarder settings types; - // see `ForwarderConfiguration<...>` for details. - map((forwarder) => toCredentialGeneratorConfiguration(forwarder)), + map((vendor) => this.generatorService.forwarder(vendor)), takeUntil(this.destroyed$), ) .subscribe((forwarder) => { - this.displayDomain = forwarder.request.includes("domain"); - this.displayToken = forwarder.request.includes("token"); - this.displayBaseUrl = forwarder.request.includes("baseUrl"); + this.displayDomain = forwarder.capabilities.fields.includes("domain"); + this.displayToken = forwarder.capabilities.fields.includes("token"); + this.displayBaseUrl = forwarder.capabilities.fields.includes("baseUrl"); forwarder$.next(forwarder); }); @@ -107,10 +99,10 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy forwarder$.pipe(takeUntil(this.destroyed$)).subscribe((forwarder) => { for (const name in Controls) { const control = this.settings.get(name); - if (forwarder.request.includes(name as any)) { - control.enable({ emitEvent: false }); + if (forwarder.capabilities.fields.includes(name)) { + control?.enable({ emitEvent: false }); } else { - control.disable({ emitEvent: false }); + control?.disable({ emitEvent: false }); } } }); @@ -128,7 +120,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy this.saveSettings .pipe(withLatestFrom(this.settings.valueChanges, settings$), takeUntil(this.destroyed$)) .subscribe(([, value, settings]) => { - settings.next(value); + settings.next(value as ForwarderOptions); }); } @@ -140,7 +132,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy async ngOnChanges(changes: SimpleChanges) { this.refresh$.complete(); if ("forwarder" in changes) { - this.forwarderId$.next(this.forwarder); + this.vendor.next(this.forwarder); } if ("account" in changes) { @@ -148,9 +140,9 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy } } - protected displayDomain: boolean; - protected displayToken: boolean; - protected displayBaseUrl: boolean; + protected displayDomain: boolean = false; + protected displayToken: boolean = false; + protected displayBaseUrl: boolean = false; private readonly refresh$ = new Subject(); diff --git a/libs/tools/generator/components/src/generator-services.module.ts b/libs/tools/generator/components/src/generator-services.module.ts index 214abbd0ac..3a7b771a25 100644 --- a/libs/tools/generator/components/src/generator-services.module.ts +++ b/libs/tools/generator/components/src/generator-services.module.ts @@ -7,19 +7,40 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; -import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; +import { Site } from "@bitwarden/common/tools/extension"; +import { ExtensionRegistry } from "@bitwarden/common/tools/extension/extension-registry.abstraction"; +import { ExtensionService } from "@bitwarden/common/tools/extension/extension.service"; +import { DefaultFields, DefaultSites, Extension } from "@bitwarden/common/tools/extension/metadata"; +import { RuntimeExtensionRegistry } from "@bitwarden/common/tools/extension/runtime-extension-registry"; +import { VendorExtensions, Vendors } from "@bitwarden/common/tools/extension/vendor"; +import { RestClient } from "@bitwarden/common/tools/integration/rpc"; +import { + LogProvider, + disabledSemanticLoggerProvider, + enableLogForTypes, +} from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; import { + BuiltIn, createRandomizer, CredentialGeneratorService, Randomizer, + providers, + DefaultCredentialGeneratorService, } from "@bitwarden/generator-core"; import { KeyService } from "@bitwarden/key-management"; export const RANDOMIZER = new SafeInjectionToken("Randomizer"); +const GENERATOR_SERVICE_PROVIDER = new SafeInjectionToken( + "CredentialGeneratorProviders", +); +const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("SystemServices"); /** Shared module containing generator component dependencies */ @NgModule({ @@ -35,6 +56,116 @@ export const RANDOMIZER = new SafeInjectionToken("Randomizer"); useClass: KeyServiceLegacyEncryptorProvider, deps: [EncryptService, KeyService], }), + safeProvider({ + provide: ExtensionRegistry, + useFactory: () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + + registry.registerSite(Extension[Site.forwarder]); + for (const vendor of Vendors) { + registry.registerVendor(vendor); + } + for (const extension of VendorExtensions) { + registry.registerExtension(extension); + } + registry.setPermission({ all: true }, "default"); + + return registry; + }, + deps: [], + }), + safeProvider({ + provide: SYSTEM_SERVICE_PROVIDER, + useFactory: ( + encryptor: LegacyEncryptorProvider, + state: StateProvider, + policy: PolicyService, + registry: ExtensionRegistry, + logger: LogService, + environment: PlatformUtilsService, + ) => { + let log: LogProvider; + if (environment.isDev()) { + log = enableLogForTypes(logger, []); + } else { + log = disabledSemanticLoggerProvider; + } + + const extension = new ExtensionService(registry, { + encryptor, + state, + log, + now: Date.now, + }); + + return { + policy, + extension, + log, + }; + }, + deps: [ + LegacyEncryptorProvider, + StateProvider, + PolicyService, + ExtensionRegistry, + LogService, + PlatformUtilsService, + ], + }), + safeProvider({ + provide: GENERATOR_SERVICE_PROVIDER, + useFactory: ( + system: SystemServiceProvider, + random: Randomizer, + encryptor: LegacyEncryptorProvider, + state: StateProvider, + i18n: I18nService, + api: ApiService, + ) => { + const userStateDeps = { + encryptor, + state, + log: system.log, + now: Date.now, + } satisfies UserStateSubjectDependencyProvider; + + const metadata = new providers.GeneratorMetadataProvider( + userStateDeps, + system, + Object.values(BuiltIn), + ); + const profile = new providers.GeneratorProfileProvider(userStateDeps, system.policy); + + const generator: providers.GeneratorDependencyProvider = { + randomizer: random, + client: new RestClient(api, i18n), + i18nService: i18n, + }; + + const userState: UserStateSubjectDependencyProvider = { + encryptor, + state, + log: system.log, + now: Date.now, + }; + + return { + userState, + generator, + profile, + metadata, + } satisfies providers.CredentialGeneratorProviders; + }, + deps: [ + SYSTEM_SERVICE_PROVIDER, + RANDOMIZER, + LegacyEncryptorProvider, + StateProvider, + I18nService, + ApiService, + ], + }), safeProvider({ provide: UserStateSubjectDependencyProvider, useFactory: (encryptor: LegacyEncryptorProvider, state: StateProvider) => @@ -42,19 +173,14 @@ export const RANDOMIZER = new SafeInjectionToken("Randomizer"); encryptor, state, log: disabledSemanticLoggerProvider, + now: Date.now, }), deps: [LegacyEncryptorProvider, StateProvider], }), safeProvider({ provide: CredentialGeneratorService, - useClass: CredentialGeneratorService, - deps: [ - RANDOMIZER, - PolicyService, - ApiService, - I18nService, - UserStateSubjectDependencyProvider, - ], + useClass: DefaultCredentialGeneratorService, + deps: [GENERATOR_SERVICE_PROVIDER, SYSTEM_SERVICE_PROVIDER], }), ], }) diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 405914977c..b352525139 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, @@ -12,14 +10,20 @@ import { OnChanges, } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { skip, takeUntil, Subject, map, withLatestFrom, ReplaySubject } from "rxjs"; +import { skip, takeUntil, Subject, map, withLatestFrom, ReplaySubject, tap } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { + SemanticLogger, + disabledSemanticLoggerProvider, + ifEnabledSemanticLoggerProvider, +} from "@bitwarden/common/tools/log"; import { - Generators, CredentialGeneratorService, PassphraseGenerationOptions, + BuiltIn, } from "@bitwarden/generator-core"; const Controls = Object.freeze({ @@ -45,12 +49,26 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy private formBuilder: FormBuilder, private generatorService: CredentialGeneratorService, private i18nService: I18nService, + private logService: LogService, ) {} + /** Send structured debug logs from the credential generator component + * to the debugger console. + * + * @warning this may reveal sensitive information in plaintext. + */ + @Input() + debug: boolean = false; + + // this `log` initializer is overridden in `ngOnInit` + private log: SemanticLogger = disabledSemanticLoggerProvider({}); + /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @@ -70,53 +88,66 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use {@link CredentialGeneratorService.settings} instead. */ @Output() readonly onUpdated = new EventEmitter(); protected settings = this.formBuilder.group({ - [Controls.numWords]: [Generators.passphrase.settings.initial.numWords], - [Controls.wordSeparator]: [Generators.passphrase.settings.initial.wordSeparator], - [Controls.capitalize]: [Generators.passphrase.settings.initial.capitalize], - [Controls.includeNumber]: [Generators.passphrase.settings.initial.includeNumber], + [Controls.numWords]: [0], + [Controls.wordSeparator]: [""], + [Controls.capitalize]: [false], + [Controls.includeNumber]: [false], }); async ngOnInit() { - const settings = await this.generatorService.settings(Generators.passphrase, { + this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, { + type: "PassphraseSettingsComponent", + }); + + const settings = await this.generatorService.settings(BuiltIn.passphrase, { account$: this.account$, }); // skips reactive event emissions to break a subscription cycle settings.withConstraints$ - .pipe(takeUntil(this.destroyed$)) + .pipe( + tap((content) => this.log.debug(content, "passphrase settings loaded with constraints")), + takeUntil(this.destroyed$), + ) .subscribe(({ state, constraints }) => { this.settings.patchValue(state, { emitEvent: false }); let boundariesHint = this.i18nService.t( "spinboxBoundariesHint", - constraints.numWords.min?.toString(), - constraints.numWords.max?.toString(), + constraints.numWords?.min?.toString(), + constraints.numWords?.max?.toString(), ); - if (state.numWords <= (constraints.numWords.recommendation ?? 0)) { + if ((state.numWords ?? 0) <= (constraints.numWords?.recommendation ?? 0)) { boundariesHint += this.i18nService.t( "passphraseNumWordsRecommendationHint", - constraints.numWords.recommendation?.toString(), + constraints.numWords?.recommendation?.toString(), ); } this.numWordsBoundariesHint.next(boundariesHint); }); // the first emission is the current value; subsequent emissions are updates - settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); + settings + .pipe( + skip(1), + tap((settings) => this.log.debug(settings, "passphrase settings onUpdate event")), + takeUntil(this.destroyed$), + ) + .subscribe(this.onUpdated); // explain policy & disable policy-overridden fields this.generatorService - .policy$(Generators.passphrase, { account$: this.account$ }) + .policy$(BuiltIn.passphrase, { account$: this.account$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { - this.wordSeparatorMaxLength = constraints.wordSeparator.maxLength; - this.policyInEffect = constraints.policyInEffect; + this.wordSeparatorMaxLength = constraints.wordSeparator?.maxLength ?? 0; + this.policyInEffect = constraints.policyInEffect ?? false; this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly); this.toggleEnabled(Controls.includeNumber, !constraints.includeNumber?.readonly); @@ -126,22 +157,25 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy this.saveSettings .pipe( withLatestFrom(this.settings.valueChanges), - map(([, settings]) => settings), + tap(([source, form]) => + this.log.debug({ source, form }, "save passphrase settings request"), + ), + map(([, settings]) => settings as PassphraseGenerationOptions), takeUntil(this.destroyed$), ) .subscribe(settings); } /** attribute binding for wordSeparator[maxlength] */ - protected wordSeparatorMaxLength: number; + protected wordSeparatorMaxLength: number = 0; private saveSettings = new Subject(); - save(site: string = "component api call") { - this.saveSettings.next(site); + save(source: string = "component api call") { + this.saveSettings.next(source); } /** display binding for enterprise policy notice */ - protected policyInEffect: boolean; + protected policyInEffect: boolean = false; private numWordsBoundariesHint = new ReplaySubject(1); @@ -150,9 +184,9 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { if (enabled) { - this.settings.get(setting).enable({ emitEvent: false }); + this.settings.get(setting)?.enable({ emitEvent: false }); } else { - this.settings.get(setting).disable({ emitEvent: false }); + this.settings.get(setting)?.disable({ emitEvent: false }); } } diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index 41ed982a49..cc5bdba606 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -3,6 +3,7 @@ class="tw-mb-4" [selected]="credentialType$ | async" (selectedChange)="onCredentialTypeChanged($event)" + *ngIf="showCredentialTypes$ | async" attr.aria-label="{{ 'type' | i18n }}" > @@ -38,14 +39,14 @@ (1); @@ -87,7 +94,11 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy async ngOnChanges(changes: SimpleChanges) { const account = changes?.account; - if (account?.previousValue?.id !== account?.currentValue?.id) { + if ( + account && + account.currentValue.id && + account.previousValue.id !== account.currentValue.id + ) { this.log.debug( { previousUserId: account?.previousValue?.id as UserId, @@ -95,15 +106,19 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy }, "account input change detected", ); - this.account$.next(this.account); + this.account$.next(account.currentValue.id); } } + @Input() + profile: GeneratorProfile = Profile.account; + /** Removes bottom margin, passed to downstream components */ - @Input({ transform: coerceBooleanProperty }) disableMargin = false; + @Input({ transform: coerceBooleanProperty }) + disableMargin = false; /** tracks the currently selected credential type */ - protected credentialType$ = new BehaviorSubject(null); + protected credentialType$ = new BehaviorSubject(null); /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -119,9 +134,11 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy * origin in the debugger. */ protected async generate(source: string) { - this.log.debug({ source }, "generation requested"); + const algorithm = await firstValueFrom(this.algorithm$); + const request: GenerateRequest = { source, algorithm: algorithm.id, profile: this.profile }; - this.generate$.next({ source }); + this.log.debug(request, "generation requested"); + this.generate$.next(request); } /** Tracks changes to the selected credential type @@ -146,16 +163,17 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy async ngOnInit() { this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, { - type: "UsernameGeneratorComponent", + type: "PasswordGeneratorComponent", }); if (!this.account) { - this.account = await firstValueFrom(this.accountService.activeAccount$); - this.log.info( - { userId: this.account.id }, - "account not specified; using active account settings", - ); - this.account$.next(this.account); + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + this.log.panic("active account cannot be `null`."); + } + + this.log.info({ userId: account.id }, "account not specified; using active account settings"); + this.account$.next(account); } this.generatorService @@ -167,10 +185,9 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy .subscribe(this.passwordOptions$); // wire up the generator - this.algorithm$ + this.generatorService + .generate$({ on$: this.generate$, account$: this.account$ }) .pipe( - filter((algorithm) => !!algorithm), - switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), catchError((error: unknown, generator) => { if (typeof error === "string") { this.toastService.showToast({ @@ -189,7 +206,7 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy takeUntil(this.destroyed), ) .subscribe(([generated, account, algorithm]) => { - this.log.debug({ source: generated.source }, "credential generated"); + this.log.debug({ source: generated.source ?? null }, "credential generated"); this.generatorHistoryService .track(account.id, generated.credential, generated.category, generated.generationDate) @@ -201,7 +218,7 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy // template bindings refresh immediately this.zone.run(() => { if (generated.source === this.USER_REQUEST) { - this.announce(algorithm.onGeneratedMessage); + this.announce(translate(algorithm.i18nKeys.credentialGenerated, this.i18nService)); } this.onGenerated.next(generated); @@ -219,10 +236,7 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy ) .subscribe(([algorithm, preference]) => { if (isPasswordAlgorithm(algorithm)) { - this.log.info( - { algorithm, category: CredentialCategories.password }, - "algorithm preferences updated", - ); + this.log.info({ algorithm, type: Type.password }, "algorithm preferences updated"); preference.password.algorithm = algorithm; preference.password.updated = new Date(); } else { @@ -236,11 +250,17 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy preferences .pipe( map(({ password }) => this.generatorService.algorithm(password.algorithm)), - distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + distinctUntilChanged((prev, next) => { + if (prev === null || next === null) { + return false; + } else { + return isSameAlgorithm(prev.id, next.id); + } + }), takeUntil(this.destroyed), ) .subscribe((algorithm) => { - this.log.debug(algorithm, "algorithm selected"); + this.log.debug({ algorithm: algorithm.id }, "algorithm selected"); // update navigation this.onCredentialTypeChanged(algorithm.id); @@ -248,22 +268,22 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - this.algorithm$.next(algorithm); - this.onAlgorithm.next(algorithm); + this.maybeAlgorithm$.next(algorithm); + this.onAlgorithm.next(toAlgorithmInfo(algorithm, this.i18nService)); }); }); // generate on load unless the generator prohibits it - this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.maybeAlgorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.zone.run(() => { - if (!a || a.onlyOnRequest) { - this.log.debug("autogeneration disabled; clearing generated credential"); - this.value$.next("-"); - } else { + if (a?.capabilities?.autogenerate) { this.log.debug("autogeneration enabled"); this.generate("autogenerate").catch((e: unknown) => { this.log.error(e as object, "a failure occurred during autogeneration"); }); + } else { + this.log.debug("autogeneration disabled; clearing generated credential"); + this.value$.next("-"); } }); }); @@ -275,59 +295,45 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy this.ariaLive.announce(message).catch((e) => this.logService.error(e)); } - private typeToGenerator$(algorithm: CredentialAlgorithm) { - const dependencies = { - on$: this.generate$, - account$: this.account$, - }; - - this.log.debug({ algorithm }, "constructing generation stream"); - - switch (algorithm) { - case "password": - return this.generatorService.generate$(Generators.password, dependencies); - - case "passphrase": - return this.generatorService.generate$(Generators.passphrase, dependencies); - default: - this.log.panic({ algorithm }, `Invalid generator type: "${algorithm}"`); - } - } - /** Lists the credential types supported by the component. */ protected passwordOptions$ = new BehaviorSubject[]>([]); + /** Determines when the password/passphrase selector is visible. */ + protected showCredentialTypes$ = this.passwordOptions$.pipe(map((options) => options.length > 1)); + /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected maybeAlgorithm$ = new ReplaySubject(1); + + /** tracks the last valid algorithm selection */ + protected algorithm$ = this.maybeAlgorithm$.pipe( + filter((algorithm): algorithm is AlgorithmMetadata => !!algorithm), + ); /** * Emits the copy button aria-label respective of the selected credential type */ protected credentialTypeCopyLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ i18nKeys: { copyCredential } }) => translate(copyCredential, this.i18nService)), ); /** * Emits the generate button aria-label respective of the selected credential type */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ generate }) => generate), + map(({ i18nKeys: { generateCredential } }) => translate(generateCredential, this.i18nService)), ); /** * Emits the copy credential toast respective of the selected credential type */ protected credentialTypeLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ credentialType }) => credentialType), + map(({ i18nKeys: { credentialType } }) => translate(credentialType, this.i18nService)), ); - private toOptions(algorithms: AlgorithmInfo[]) { + private toOptions(algorithms: AlgorithmMetadata[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, - label: algorithm.name, + label: translate(algorithm.i18nKeys.name, this.i18nService), })); return options; diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index 346e9549cd..965ada3814 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, @@ -17,11 +15,13 @@ import { takeUntil, Subject, map, filter, tap, skip, ReplaySubject, withLatestFr import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { - Generators, CredentialGeneratorService, PasswordGenerationOptions, + BuiltIn, } from "@bitwarden/generator-core"; +import { hasRangeOfValues } from "./util"; + const Controls = Object.freeze({ length: "length", uppercase: "uppercase", @@ -52,9 +52,11 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { ) {} /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @@ -78,40 +80,40 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use `CredentialGeneratorService.settings(...)` instead. */ @Output() readonly onUpdated = new EventEmitter(); protected settings = this.formBuilder.group({ - [Controls.length]: [Generators.password.settings.initial.length], - [Controls.uppercase]: [Generators.password.settings.initial.uppercase], - [Controls.lowercase]: [Generators.password.settings.initial.lowercase], - [Controls.number]: [Generators.password.settings.initial.number], - [Controls.special]: [Generators.password.settings.initial.special], - [Controls.minNumber]: [Generators.password.settings.initial.minNumber], - [Controls.minSpecial]: [Generators.password.settings.initial.minSpecial], - [Controls.avoidAmbiguous]: [!Generators.password.settings.initial.ambiguous], + [Controls.length]: [0], + [Controls.uppercase]: [false], + [Controls.lowercase]: [false], + [Controls.number]: [false], + [Controls.special]: [false], + [Controls.minNumber]: [0], + [Controls.minSpecial]: [0], + [Controls.avoidAmbiguous]: [false], }); private get numbers() { - return this.settings.get(Controls.number); + return this.settings.get(Controls.number)!; } private get special() { - return this.settings.get(Controls.special); + return this.settings.get(Controls.special)!; } private get minNumber() { - return this.settings.get(Controls.minNumber); + return this.settings.get(Controls.minNumber)!; } private get minSpecial() { - return this.settings.get(Controls.minSpecial); + return this.settings.get(Controls.minSpecial)!; } async ngOnInit() { - const settings = await this.generatorService.settings(Generators.password, { + const settings = await this.generatorService.settings(BuiltIn.password, { account$: this.account$, }); @@ -130,13 +132,13 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { .subscribe(([state, constraints]) => { let boundariesHint = this.i18nService.t( "spinboxBoundariesHint", - constraints.length.min?.toString(), - constraints.length.max?.toString(), + constraints.length?.min?.toString(), + constraints.length?.max?.toString(), ); - if (state.length <= (constraints.length.recommendation ?? 0)) { + if (state.length <= (constraints.length?.recommendation ?? 0)) { boundariesHint += this.i18nService.t( "passwordLengthRecommendationHint", - constraints.length.recommendation?.toString(), + constraints.length?.recommendation?.toString(), ); } this.lengthBoundariesHint.next(boundariesHint); @@ -147,19 +149,25 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { // explain policy & disable policy-overridden fields this.generatorService - .policy$(Generators.password, { account$: this.account$ }) + .policy$(BuiltIn.password, { account$: this.account$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { - this.policyInEffect = constraints.policyInEffect; + this.policyInEffect = constraints.policyInEffect ?? false; const toggles = [ - [Controls.length, constraints.length.min < constraints.length.max], + [Controls.length, hasRangeOfValues(constraints.length?.min, constraints.length?.max)], [Controls.uppercase, !constraints.uppercase?.readonly], [Controls.lowercase, !constraints.lowercase?.readonly], [Controls.number, !constraints.number?.readonly], [Controls.special, !constraints.special?.readonly], - [Controls.minNumber, constraints.minNumber.min < constraints.minNumber.max], - [Controls.minSpecial, constraints.minSpecial.min < constraints.minSpecial.max], + [ + Controls.minNumber, + hasRangeOfValues(constraints.minNumber?.min, constraints.minNumber?.max), + ], + [ + Controls.minSpecial, + hasRangeOfValues(constraints.minSpecial?.min, constraints.minSpecial?.max), + ], ] as [keyof typeof Controls, boolean][]; for (const [control, enabled] of toggles) { @@ -172,7 +180,7 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { let lastMinNumber = 1; this.numbers.valueChanges .pipe( - filter((checked) => !(checked && this.minNumber.value > 0)), + filter((checked) => !(checked && (this.minNumber.value ?? 0) > 0)), map((checked) => (checked ? lastMinNumber : 0)), takeUntil(this.destroyed$), ) @@ -180,8 +188,11 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { this.minNumber.valueChanges .pipe( - map((value) => [value, value > 0] as const), - tap(([value, checkNumbers]) => (lastMinNumber = checkNumbers ? value : lastMinNumber)), + map((value) => [value, (value ?? 0) > 0] as const), + tap( + ([value, checkNumbers]) => + (lastMinNumber = checkNumbers && value ? value : lastMinNumber), + ), takeUntil(this.destroyed$), ) .subscribe(([, checkNumbers]) => this.numbers.setValue(checkNumbers, { emitEvent: false })); @@ -189,7 +200,7 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { let lastMinSpecial = 1; this.special.valueChanges .pipe( - filter((checked) => !(checked && this.minSpecial.value > 0)), + filter((checked) => !(checked && (this.minSpecial.value ?? 0) > 0)), map((checked) => (checked ? lastMinSpecial : 0)), takeUntil(this.destroyed$), ) @@ -197,8 +208,11 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { this.minSpecial.valueChanges .pipe( - map((value) => [value, value > 0] as const), - tap(([value, checkSpecial]) => (lastMinSpecial = checkSpecial ? value : lastMinSpecial)), + map((value) => [value, (value ?? 0) > 0] as const), + tap( + ([value, checkSpecial]) => + (lastMinSpecial = checkSpecial && value ? value : lastMinSpecial), + ), takeUntil(this.destroyed$), ) .subscribe(([, checkSpecial]) => this.special.setValue(checkSpecial, { emitEvent: false })); @@ -230,7 +244,7 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { } /** display binding for enterprise policy notice */ - protected policyInEffect: boolean; + protected policyInEffect: boolean = false; private lengthBoundariesHint = new ReplaySubject(1); @@ -239,9 +253,9 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { if (enabled) { - this.settings.get(setting).enable({ emitEvent: false }); + this.settings.get(setting)?.enable({ emitEvent: false }); } else { - this.settings.get(setting).disable({ emitEvent: false }); + this.settings.get(setting)?.disable({ emitEvent: false }); } } diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index b09ecc86f9..27ed6d5f9f 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, @@ -13,10 +11,10 @@ import { import { FormBuilder } from "@angular/forms"; import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; -import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { CredentialGeneratorService, - Generators, + BuiltIn, SubaddressGenerationOptions, } from "@bitwarden/generator-core"; @@ -28,20 +26,20 @@ import { }) export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component - * @param accountService queries user availability * @param generatorService settings and policy logic * @param formBuilder reactive form controls */ constructor( private formBuilder: FormBuilder, private generatorService: CredentialGeneratorService, - private accountService: AccountService, ) {} /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @@ -54,18 +52,18 @@ export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use `CredentialGeneratorService.settings(...)` instead. */ @Output() readonly onUpdated = new EventEmitter(); /** The template's control bindings */ protected settings = this.formBuilder.group({ - subaddressEmail: [Generators.subaddress.settings.initial.subaddressEmail], + subaddressEmail: [""], }); async ngOnInit() { - const settings = await this.generatorService.settings(Generators.subaddress, { + const settings = await this.generatorService.settings(BuiltIn.plusAddress, { account$: this.account$, }); @@ -79,7 +77,7 @@ export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy this.saveSettings .pipe( withLatestFrom(this.settings.valueChanges), - map(([, settings]) => settings), + map(([, settings]) => settings as SubaddressGenerationOptions), takeUntil(this.destroyed$), ) .subscribe(settings); diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 533a7a0e54..51b998f1d5 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -59,7 +59,7 @@ @@ -69,12 +69,12 @@ [account]="account$ | async" /> diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index de48a9bd6b..6227bcd3f7 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { LiveAnnouncer } from "@angular/cdk/a11y"; import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { @@ -25,15 +23,15 @@ import { map, ReplaySubject, Subject, - switchMap, takeUntil, + tap, withLatestFrom, } from "rxjs"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { SemanticLogger, disabledSemanticLoggerProvider, @@ -43,21 +41,23 @@ import { UserId } from "@bitwarden/common/types/guid"; import { ToastService, Option } from "@bitwarden/components"; import { AlgorithmInfo, - CredentialAlgorithm, - CredentialCategories, CredentialGeneratorService, GenerateRequest, GeneratedCredential, - Generators, - getForwarderConfiguration, + isForwarderExtensionId, isEmailAlgorithm, - isForwarderIntegration, - isSameAlgorithm, isUsernameAlgorithm, - toCredentialGeneratorConfiguration, + isSameAlgorithm, + CredentialAlgorithm, + AlgorithmMetadata, + AlgorithmsByType, + Type, + Algorithm, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; +import { toAlgorithmInfo, translate } from "./util"; + // constants used to identify navigation selections that are not // generator algorithms const FORWARDER = "forwarder"; @@ -89,11 +89,14 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy private ariaLive: LiveAnnouncer, ) {} + /** exports algorithm symbols to the template */ + protected readonly Algorithm = Algorithm; + /** Binds the component to a specific user's settings. When this input is not provided, * the form binds to the active user */ @Input() - account: Account | null; + account: Account | null = null; protected account$ = new ReplaySubject(1); @@ -110,7 +113,11 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy async ngOnChanges(changes: SimpleChanges) { const account = changes?.account; - if (account?.previousValue?.id !== account?.currentValue?.id) { + if ( + account && + account.currentValue.id && + account.previousValue.id !== account.currentValue.id + ) { this.log.debug( { previousUserId: account?.previousValue?.id as UserId, @@ -118,7 +125,7 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy }, "account input change detected", ); - this.account$.next(this.account); + this.account$.next(account.currentValue.id); } } @@ -134,18 +141,18 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy /** emits algorithm info when the selected algorithm changes */ @Output() - readonly onAlgorithm = new EventEmitter(); + readonly onAlgorithm = new EventEmitter(); /** Removes bottom margin from internal elements */ @Input({ transform: coerceBooleanProperty }) disableMargin = false; /** Tracks the selected generation algorithm */ protected username = this.formBuilder.group({ - nav: [null as string], + nav: [null as string | null], }); protected forwarder = this.formBuilder.group({ - nav: [null as string], + nav: [null as string | null], }); async ngOnInit() { @@ -154,38 +161,63 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy }); if (!this.account) { - this.account = await firstValueFrom(this.accountService.activeAccount$); - this.log.info( - { userId: this.account.id }, - "account not specified; using active account settings", - ); - this.account$.next(this.account); + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + this.log.panic("active account cannot be `null`."); + } + + this.log.info({ userId: account.id }, "account not specified; using active account settings"); + this.account$.next(account); } - this.generatorService - .algorithms$(["email", "username"], { account$: this.account$ }) + combineLatest([ + this.generatorService.algorithms$("email", { account$: this.account$ }), + this.generatorService.algorithms$("username", { account$: this.account$ }), + ]) .pipe( + map((algorithms) => algorithms.flat()), map((algorithms) => { - const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + // construct options for username and email algorithms; replace forwarder + // entry with a virtual entry for drill-down + const usernames = algorithms.filter((a) => !isForwarderExtensionId(a.id)); + usernames.sort((a, b) => a.weight - b.weight); const usernameOptions = this.toOptions(usernames); - usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") }); + usernameOptions.splice(-1, 0, { + value: FORWARDER, + label: this.i18nService.t("forwardedEmail"), + }); - const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + // construct options for forwarder algorithms; they get their own selection box + const forwarders = algorithms.filter((a) => isForwarderExtensionId(a.id)); + forwarders.sort((a, b) => a.weight - b.weight); const forwarderOptions = this.toOptions(forwarders); forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); return [usernameOptions, forwarderOptions] as const; }), + tap((algorithms) => + this.log.debug({ algorithms: algorithms as object }, "algorithms loaded"), + ), takeUntil(this.destroyed), ) .subscribe(([usernames, forwarders]) => { - this.typeOptions$.next(usernames); - this.forwarderOptions$.next(forwarders); + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.typeOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); }); - this.algorithm$ + this.maybeAlgorithm$ .pipe( - map((a) => a?.description), + map((a) => { + if (a?.i18nKeys?.description) { + return translate(a.i18nKeys.description, this.i18nService); + } else { + return ""; + } + }), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -197,10 +229,12 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy }); // wire up the generator - this.algorithm$ + this.generatorService + .generate$({ + on$: this.generate$, + account$: this.account$, + }) .pipe( - filter((algorithm) => !!algorithm), - switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), catchError((error: unknown, generator) => { if (typeof error === "string") { this.toastService.showToast({ @@ -215,11 +249,14 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy // continue with origin stream return generator; }), - withLatestFrom(this.account$, this.algorithm$), + withLatestFrom(this.account$, this.maybeAlgorithm$), takeUntil(this.destroyed), ) .subscribe(([generated, account, algorithm]) => { - this.log.debug({ source: generated.source }, "credential generated"); + this.log.debug( + { source: generated.source ?? null, algorithm: algorithm?.id ?? null }, + "credential generated", + ); this.generatorHistoryService .track(account.id, generated.credential, generated.category, generated.generationDate) @@ -230,12 +267,12 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - if (generated.source === this.USER_REQUEST) { - this.announce(algorithm.onGeneratedMessage); + if (algorithm && generated.source === this.USER_REQUEST) { + this.announce(translate(algorithm.i18nKeys.credentialGenerated, this.i18nService)); } + this.generatedCredential$.next(generated); this.onGenerated.next(generated); - this.value$.next(generated.credential); }); }); @@ -248,24 +285,31 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy this.username.valueChanges .pipe( - map( - (username): CascadeValue => - username.nav === FORWARDER - ? { nav: username.nav } - : { nav: username.nav, algorithm: JSON.parse(username.nav) }, - ), + map((username): CascadeValue => { + if (username.nav === FORWARDER) { + return { nav: username.nav }; + } else if (username.nav) { + return { nav: username.nav, algorithm: JSON.parse(username.nav) }; + } else { + const [algorithm] = AlgorithmsByType[Type.username]; + return { nav: JSON.stringify(algorithm), algorithm }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeIdentifier$); this.forwarder.valueChanges .pipe( - map( - (forwarder): CascadeValue => - forwarder.nav === NONE_SELECTED - ? { nav: forwarder.nav } - : { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }, - ), + map((forwarder): CascadeValue => { + if (forwarder.nav === NONE_SELECTED) { + return { nav: forwarder.nav }; + } else if (forwarder.nav) { + return { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }; + } else { + return { nav: NONE_SELECTED }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeForwarder$); @@ -276,7 +320,7 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy map(([username, forwarder]) => { const showForwarder = !username.algorithm; const forwarderId = - showForwarder && isForwarderIntegration(forwarder.algorithm) + showForwarder && forwarder.algorithm && isForwarderExtensionId(forwarder.algorithm) ? forwarder.algorithm.forwarder : null; return [showForwarder, forwarderId] as const; @@ -306,57 +350,61 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy return null; } }), - distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + distinctUntilChanged((prev, next) => { + if (prev === null || next === null) { + return false; + } else { + return isSameAlgorithm(prev.id, next.id); + } + }), takeUntil(this.destroyed), ) .subscribe((algorithm) => { - this.log.debug(algorithm, "algorithm selected"); + this.log.debug({ algorithm: algorithm?.id ?? null }, "algorithm selected"); // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - this.algorithm$.next(algorithm); - this.onAlgorithm.next(algorithm); + this.maybeAlgorithm$.next(algorithm); + if (algorithm) { + this.onAlgorithm.next(toAlgorithmInfo(algorithm, this.i18nService)); + } else { + this.onAlgorithm.next(null); + } }); }); // assume the last-visible generator algorithm is the user's preferred one const preferences = await this.generatorService.preferences({ account$: this.account$ }); this.algorithm$ - .pipe( - filter((algorithm) => !!algorithm), - withLatestFrom(preferences), - takeUntil(this.destroyed), - ) + .pipe(withLatestFrom(preferences), takeUntil(this.destroyed)) .subscribe(([algorithm, preference]) => { if (isEmailAlgorithm(algorithm.id)) { - this.log.info( - { algorithm, category: CredentialCategories.email }, - "algorithm preferences updated", - ); preference.email.algorithm = algorithm.id; preference.email.updated = new Date(); } else if (isUsernameAlgorithm(algorithm.id)) { - this.log.info( - { algorithm, category: CredentialCategories.username }, - "algorithm preferences updated", - ); preference.username.algorithm = algorithm.id; preference.username.updated = new Date(); } else { return; } + this.log.info( + { algorithm: algorithm.id, type: algorithm.type }, + "algorithm preferences updated", + ); preferences.next(preference); }); preferences .pipe( map(({ email, username }) => { - const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; const usernamePref = email.updated > username.updated ? email : username; + const forwarderPref = isForwarderExtensionId(usernamePref.algorithm) + ? usernamePref + : null; - // inject drilldown flags + // inject drill-down flags const forwarderNav = !forwarderPref ? NONE_SELECTED : JSON.stringify(forwarderPref.algorithm); @@ -368,7 +416,7 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy selection: { nav: userNav }, active: { nav: userNav, - algorithm: forwarderPref ? null : usernamePref.algorithm, + algorithm: forwarderPref ? undefined : usernamePref.algorithm, }, }, forwarder: { @@ -385,6 +433,14 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy takeUntil(this.destroyed), ) .subscribe(({ username, forwarder }) => { + this.log.debug( + { + username: username.selection, + forwarder: forwarder.selection, + }, + "navigation updated", + ); + // update navigation; break subscription loop this.username.setValue(username.selection, { emitEvent: false }); this.forwarder.setValue(forwarder.selection, { emitEvent: false }); @@ -396,17 +452,16 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy // automatically regenerate when the algorithm switches if the algorithm // allows it; otherwise set a placeholder - this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.maybeAlgorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.zone.run(() => { - if (!a || a.onlyOnRequest) { - this.log.debug("autogeneration disabled; clearing generated credential"); - this.value$.next("-"); - } else { + if (a?.capabilities?.autogenerate) { this.log.debug("autogeneration enabled"); - this.generate("autogenerate").catch((e: unknown) => { this.log.error(e as object, "a failure occurred during autogeneration"); }); + } else { + this.log.debug("autogeneration disabled; clearing generated credential"); + this.generatedCredential$.next(undefined); } }); }); @@ -414,34 +469,6 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy this.log.debug("component initialized"); } - private typeToGenerator$(algorithm: CredentialAlgorithm) { - const dependencies = { - on$: this.generate$, - account$: this.account$, - }; - - this.log.debug({ algorithm }, "constructing generation stream"); - - switch (algorithm) { - case "catchall": - return this.generatorService.generate$(Generators.catchall, dependencies); - - case "subaddress": - return this.generatorService.generate$(Generators.subaddress, dependencies); - - case "username": - return this.generatorService.generate$(Generators.username, dependencies); - } - - if (isForwarderIntegration(algorithm)) { - const forwarder = getForwarderConfiguration(algorithm.forwarder); - const configuration = toCredentialGeneratorConfiguration(forwarder); - return this.generatorService.generate$(configuration, dependencies); - } - - this.log.panic({ algorithm }, `Invalid generator type: "${algorithm}"`); - } - private announce(message: string) { this.ariaLive.announce(message).catch((e) => this.logService.error(e)); } @@ -450,7 +477,7 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy protected typeOptions$ = new BehaviorSubject[]>([]); /** Tracks the currently selected forwarder. */ - protected forwarderId$ = new BehaviorSubject(null); + protected forwarderId$ = new BehaviorSubject(null); /** Lists the credential types supported by the component. */ protected forwarderOptions$ = new BehaviorSubject[]>([]); @@ -458,19 +485,30 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy /** Tracks forwarder control visibility */ protected showForwarder$ = new BehaviorSubject(false); - /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + /** tracks the currently selected algorithm; emits `null` when no algorithm selected */ + protected maybeAlgorithm$ = new ReplaySubject(1); + + /** tracks the last valid algorithm selection */ + protected algorithm$ = this.maybeAlgorithm$.pipe( + filter((algorithm): algorithm is AlgorithmMetadata => !!algorithm), + ); /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); + private readonly generatedCredential$ = new BehaviorSubject( + undefined, + ); + /** Emits the last generated value. */ - protected readonly value$ = new BehaviorSubject(""); + protected readonly value$ = this.generatedCredential$.pipe( + map((generated) => generated?.credential ?? "-"), + ); /** Emits when a new credential is requested */ private readonly generate$ = new Subject(); - protected showAlgorithm$ = this.algorithm$.pipe( + protected showAlgorithm$ = this.maybeAlgorithm$.pipe( combineLatestWith(this.showForwarder$), map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), ); @@ -479,24 +517,21 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy * Emits the copy button aria-label respective of the selected credential type */ protected credentialTypeCopyLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ i18nKeys: { copyCredential } }) => translate(copyCredential, this.i18nService)), ); /** * Emits the generate button aria-label respective of the selected credential type */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ generate }) => generate), + map(({ i18nKeys: { generateCredential } }) => translate(generateCredential, this.i18nService)), ); /** * Emits the copy credential toast respective of the selected credential type */ protected credentialTypeLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ credentialType }) => credentialType), + map(({ i18nKeys: { credentialType } }) => translate(credentialType, this.i18nService)), ); /** Identifies generator requests that were requested by the user */ @@ -507,15 +542,20 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy * origin in the debugger. */ protected async generate(source: string) { - const request = { source, website: this.website }; + const algorithm = await firstValueFrom(this.algorithm$); + const request: GenerateRequest = { source, algorithm: algorithm.id }; + if (this.website) { + request.website = this.website; + } + this.log.debug(request, "generation requested"); this.generate$.next(request); } - private toOptions(algorithms: AlgorithmInfo[]) { + private toOptions(algorithms: AlgorithmMetadata[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: JSON.stringify(algorithm.id), - label: algorithm.name, + label: translate(algorithm.i18nKeys.name, this.i18nService), })); return options; @@ -528,9 +568,11 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy // finalize subjects this.generate$.complete(); - this.value$.complete(); + this.generatedCredential$.complete(); // finalize component bindings this.onGenerated.complete(); + + this.log.debug("component destroyed"); } } diff --git a/libs/tools/generator/components/src/username-settings.component.ts b/libs/tools/generator/components/src/username-settings.component.ts index ea3cfbd35f..7a12957f90 100644 --- a/libs/tools/generator/components/src/username-settings.component.ts +++ b/libs/tools/generator/components/src/username-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, @@ -17,7 +15,7 @@ import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { CredentialGeneratorService, EffUsernameGenerationOptions, - Generators, + BuiltIn, } from "@bitwarden/generator-core"; /** Options group for usernames */ @@ -28,7 +26,6 @@ import { }) export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component - * @param accountService queries user availability * @param generatorService settings and policy logic * @param formBuilder reactive form controls */ @@ -38,9 +35,11 @@ export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { ) {} /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @@ -53,19 +52,19 @@ export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use `CredentialGeneratorService.settings(...)` instead. */ @Output() readonly onUpdated = new EventEmitter(); /** The template's control bindings */ protected settings = this.formBuilder.group({ - wordCapitalize: [Generators.username.settings.initial.wordCapitalize], - wordIncludeNumber: [Generators.username.settings.initial.wordIncludeNumber], + wordCapitalize: [false], + wordIncludeNumber: [false], }); async ngOnInit() { - const settings = await this.generatorService.settings(Generators.username, { + const settings = await this.generatorService.settings(BuiltIn.effWordList, { account$: this.account$, }); @@ -79,7 +78,7 @@ export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { this.saveSettings .pipe( withLatestFrom(this.settings.valueChanges), - map(([, settings]) => settings), + map(([, settings]) => settings as EffUsernameGenerationOptions), takeUntil(this.destroyed$), ) .subscribe(settings); diff --git a/libs/tools/generator/components/src/util.ts b/libs/tools/generator/components/src/util.ts index 95e55d816c..4b0ce4383d 100644 --- a/libs/tools/generator/components/src/util.ts +++ b/libs/tools/generator/components/src/util.ts @@ -1,71 +1,46 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { ValidatorFn, Validators } from "@angular/forms"; -import { distinctUntilChanged, map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { I18nKeyOrLiteral } from "@bitwarden/common/tools/types"; +import { isI18nKey } from "@bitwarden/common/tools/util"; +import { AlgorithmInfo, AlgorithmMetadata } from "@bitwarden/generator-core"; -import { AnyConstraint, Constraints } from "@bitwarden/common/tools/types"; -import { UserId } from "@bitwarden/common/types/guid"; -import { CredentialGeneratorConfiguration } from "@bitwarden/generator-core"; +/** Adapts {@link AlgorithmMetadata} to legacy {@link AlgorithmInfo} structure. */ +export function toAlgorithmInfo(metadata: AlgorithmMetadata, i18n: I18nService) { + const info: AlgorithmInfo = { + id: metadata.id, + type: metadata.type, + name: translate(metadata.i18nKeys.name, i18n), + generate: translate(metadata.i18nKeys.generateCredential, i18n), + onGeneratedMessage: translate(metadata.i18nKeys.credentialGenerated, i18n), + credentialType: translate(metadata.i18nKeys.credentialType, i18n), + copy: translate(metadata.i18nKeys.copyCredential, i18n), + useGeneratedValue: translate(metadata.i18nKeys.useCredential, i18n), + onlyOnRequest: !metadata.capabilities.autogenerate, + request: metadata.capabilities.fields, + }; -export function completeOnAccountSwitch() { - return pipe( - map(({ id }: { id: UserId | null }) => id), - skipWhile((id) => !id), - startWith(null as UserId), - pairwise(), - takeWhile(([prev, next]) => (prev ?? next) === next), - map(([_, id]) => id), - distinctUntilChanged(), - ); + if (metadata.i18nKeys.description) { + info.description = translate(metadata.i18nKeys.description, i18n); + } + + return info; } -export function toValidators( - target: keyof Settings, - configuration: CredentialGeneratorConfiguration, - policy?: Constraints, -) { - const validators: Array = []; - - // widen the types to avoid typecheck issues - const config: AnyConstraint = configuration.settings.constraints[target]; - const runtime: AnyConstraint = policy[target]; - - const required = getConstraint("required", config, runtime) ?? false; - if (required) { - validators.push(Validators.required); - } - - const maxLength = getConstraint("maxLength", config, runtime); - if (maxLength !== undefined) { - validators.push(Validators.maxLength(maxLength)); - } - - const minLength = getConstraint("minLength", config, runtime); - if (minLength !== undefined) { - validators.push(Validators.minLength(config.minLength)); - } - - const min = getConstraint("min", config, runtime); - if (min !== undefined) { - validators.push(Validators.min(min)); - } - - const max = getConstraint("max", config, runtime); - if (max !== undefined) { - validators.push(Validators.max(max)); - } - - return validators; +/** Translates an internationalization key + * @param key the key to translate + * @param i18n the service providing translations + * @returns the translated key; if the key is a literal the literal + * is returned instead. + */ +export function translate(key: I18nKeyOrLiteral, i18n: I18nService) { + return isI18nKey(key) ? i18n.t(key) : key.literal; } -function getConstraint( - key: Key, - config: AnyConstraint, - policy?: AnyConstraint, -) { - if (policy && key in policy) { - return policy[key] ?? config[key]; - } else if (config && key in config) { - return config[key]; - } +/** Returns true when min < max + * @param min the minimum value to check; when this is nullish it becomes 0. + * @param max the maximum value to check; when this is nullish it becomes +Infinity. + */ +export function hasRangeOfValues(min?: number, max?: number) { + const minimum = min ?? 0; + const maximum = max ?? Number.POSITIVE_INFINITY; + return minimum < maximum; } diff --git a/libs/tools/generator/core/src/abstractions/credential-generator-service.abstraction.ts b/libs/tools/generator/core/src/abstractions/credential-generator-service.abstraction.ts new file mode 100644 index 0000000000..74f7851459 --- /dev/null +++ b/libs/tools/generator/core/src/abstractions/credential-generator-service.abstraction.ts @@ -0,0 +1,104 @@ +import { Observable } from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BoundDependency, OnDependency } from "@bitwarden/common/tools/dependencies"; +import { VendorId } from "@bitwarden/common/tools/extension"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; + +import { + CredentialAlgorithm, + GeneratorMetadata, + GeneratorProfile, + CredentialType, +} from "../metadata"; +import { AlgorithmMetadata } from "../metadata/algorithm-metadata"; +import { + CredentialPreference, + ForwarderOptions, + GeneratedCredential, + GenerateRequest, +} from "../types"; +import { GeneratorConstraints } from "../types/generator-constraints"; + +/** Generates credentials used in identity and/or authentication flows. + */ +export abstract class CredentialGeneratorService { + /** Generates a stream of credentials + * @param dependencies.on$ Required. A new credential is emitted when this emits. + */ + abstract generate$: ( + dependencies: OnDependency & BoundDependency<"account", Account>, + ) => Observable; + + /** Emits metadata for the set of algorithms available to a user. + * @param type the set of algorithms + * @param dependencies.account$ algorithms are filtered to only + * those matching the provided account's policy. + * @returns An observable that emits algorithm metadata. + */ + abstract algorithms$: ( + type: CredentialType, + dependencies: BoundDependency<"account", Account>, + ) => Observable; + + /** Lists metadata for a set of algorithms. + * @param type the type or types of algorithms + * @returns A list containing the requested metadata. + * @remarks this is a raw data interface. To apply rules such as algorithm availability, + * use {@link algorithms$} instead. + */ + abstract algorithms: (type: CredentialType | CredentialType[]) => AlgorithmMetadata[]; + + /** Look up the metadata for a specific generator algorithm + * @param id identifies the algorithm + * @returns the requested metadata, or `null` if the metadata wasn't found. + */ + abstract algorithm: (id: CredentialAlgorithm) => AlgorithmMetadata; + + /** Look up the forwarder metadata for a vendor. + * @param id identifies the vendor proving the forwarder + */ + abstract forwarder: (id: VendorId) => GeneratorMetadata; + + /** 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 {@link @bitwarden/generator-navigation} + * instead. + */ + abstract preferences: ( + dependencies: BoundDependency<"account", Account>, + ) => UserStateSubject; + + /** Get a subject bound to a specific user's settings. the subject enforces policy for the + * settings by automatically updating incorrect values to those allowed by policy. + * @param metadata determines which generator's settings are loaded + * @param dependencies.account$ identifies the account to which the settings are bound + * @param profile identifies the generator profile to load; when this is not specified + * the user's account profile is loaded. + * @returns a subject bound to the requested user's generator settings + * @remarks Generator metadata can be looked up using {@link BuiltIn} and {@link forwarder}. + */ + abstract settings: ( + metadata: Readonly>, + dependencies: BoundDependency<"account", Account>, + profile?: GeneratorProfile, + ) => UserStateSubject; + + /** Get the policy constraints for the provided configuration + * @param metadata determines which generator's policy is loaded + * @param dependencies.account$ determines which user's policy is loaded + * @param profile identifies the generator profile to load; when this is not specified + * the user's account profile is loaded. + * @returns an observable that emits the policy once `dependencies.account$` + * and the policy become available. + * @remarks Generator metadata can be looked up using {@link BuiltIn} and {@link forwarder}. + */ + abstract policy$: ( + metadata: Readonly>, + dependencies: BoundDependency<"account", Account>, + profile?: GeneratorProfile, + ) => Observable>; +} diff --git a/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts b/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts index 221c0b8b00..9c12dba44c 100644 --- a/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts +++ b/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts @@ -9,6 +9,7 @@ import { PolicyEvaluator } from "./policy-evaluator.abstraction"; /** Generates credentials used for user authentication * @typeParam Options the credential generation configuration * @typeParam Policy the policy enforced by the generator + * @deprecated Use {@link CredentialGeneratorService} instead. */ export abstract class GeneratorService { /** An observable monitoring the options saved to disk. diff --git a/libs/tools/generator/core/src/abstractions/index.ts b/libs/tools/generator/core/src/abstractions/index.ts index 471ec89ea3..4f45f985ef 100644 --- a/libs/tools/generator/core/src/abstractions/index.ts +++ b/libs/tools/generator/core/src/abstractions/index.ts @@ -1,3 +1,4 @@ +export { CredentialGeneratorService } from "./credential-generator-service.abstraction"; export { GeneratorService } from "./generator.service.abstraction"; export { GeneratorStrategy } from "./generator-strategy.abstraction"; export { PolicyEvaluator } from "./policy-evaluator.abstraction"; diff --git a/libs/tools/generator/core/src/data/default-addy-io-options.ts b/libs/tools/generator/core/src/data/default-addy-io-options.ts deleted file mode 100644 index 2ebeefff6a..0000000000 --- a/libs/tools/generator/core/src/data/default-addy-io-options.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { EmailDomainOptions, SelfHostedApiOptions } from "../types"; - -export const DefaultAddyIoOptions: SelfHostedApiOptions & EmailDomainOptions = Object.freeze({ - website: null, - baseUrl: "https://app.addy.io", - token: "", - domain: "", -}); diff --git a/libs/tools/generator/core/src/data/default-credential-preferences.ts b/libs/tools/generator/core/src/data/default-credential-preferences.ts deleted file mode 100644 index c26d44b3b7..0000000000 --- a/libs/tools/generator/core/src/data/default-credential-preferences.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CredentialPreference } from "../types"; - -import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "./generator-types"; - -export const DefaultCredentialPreferences: CredentialPreference = Object.freeze({ - email: Object.freeze({ - algorithm: EmailAlgorithms[0], - updated: new Date(0), - }), - password: Object.freeze({ - algorithm: PasswordAlgorithms[0], - updated: new Date(0), - }), - username: Object.freeze({ - algorithm: UsernameAlgorithms[0], - updated: new Date(0), - }), -}); diff --git a/libs/tools/generator/core/src/data/default-duck-duck-go-options.ts b/libs/tools/generator/core/src/data/default-duck-duck-go-options.ts deleted file mode 100644 index c600e6e512..0000000000 --- a/libs/tools/generator/core/src/data/default-duck-duck-go-options.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ApiOptions } from "../types"; - -export const DefaultDuckDuckGoOptions: ApiOptions = Object.freeze({ - website: null, - token: "", -}); diff --git a/libs/tools/generator/core/src/data/default-fastmail-options.ts b/libs/tools/generator/core/src/data/default-fastmail-options.ts deleted file mode 100644 index 18faefc464..0000000000 --- a/libs/tools/generator/core/src/data/default-fastmail-options.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApiOptions, EmailPrefixOptions } from "../types"; - -export const DefaultFastmailOptions: ApiOptions & EmailPrefixOptions = Object.freeze({ - website: "", - domain: "", - prefix: "", - token: "", -}); diff --git a/libs/tools/generator/core/src/data/default-firefox-relay-options.ts b/libs/tools/generator/core/src/data/default-firefox-relay-options.ts deleted file mode 100644 index 20433a3e12..0000000000 --- a/libs/tools/generator/core/src/data/default-firefox-relay-options.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ApiOptions } from "../types"; - -export const DefaultFirefoxRelayOptions: ApiOptions = Object.freeze({ - website: null, - token: "", -}); diff --git a/libs/tools/generator/core/src/data/default-forward-email-options.ts b/libs/tools/generator/core/src/data/default-forward-email-options.ts deleted file mode 100644 index d5175534a0..0000000000 --- a/libs/tools/generator/core/src/data/default-forward-email-options.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ApiOptions, EmailDomainOptions } from "../types"; - -export const DefaultForwardEmailOptions: ApiOptions & EmailDomainOptions = Object.freeze({ - website: null, - token: "", - domain: "", -}); diff --git a/libs/tools/generator/core/src/data/default-simple-login-options.ts b/libs/tools/generator/core/src/data/default-simple-login-options.ts deleted file mode 100644 index 965b1222cd..0000000000 --- a/libs/tools/generator/core/src/data/default-simple-login-options.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SelfHostedApiOptions } from "../types"; - -export const DefaultSimpleLoginOptions: SelfHostedApiOptions = Object.freeze({ - website: null, - baseUrl: "https://app.simplelogin.io", - token: "", -}); diff --git a/libs/tools/generator/core/src/data/generator-types.ts b/libs/tools/generator/core/src/data/generator-types.ts deleted file mode 100644 index e54ec34e49..0000000000 --- a/libs/tools/generator/core/src/data/generator-types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Types of passwords that may be generated by the credential generator */ -export const PasswordAlgorithms = Object.freeze(["password", "passphrase"] as const); - -/** Types of usernames that may be generated by the credential generator */ -export const UsernameAlgorithms = Object.freeze(["username"] as const); - -/** Types of email addresses that may be generated by the credential generator */ -export const EmailAlgorithms = Object.freeze(["catchall", "subaddress"] as const); - -/** All types of credentials that may be generated by the credential generator */ -export const CredentialAlgorithms = Object.freeze([ - ...PasswordAlgorithms, - ...UsernameAlgorithms, - ...EmailAlgorithms, -] as const); diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts deleted file mode 100644 index da87c60f1f..0000000000 --- a/libs/tools/generator/core/src/data/generators.ts +++ /dev/null @@ -1,422 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; -import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; -import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; -import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; -import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; - -import { - EmailRandomizer, - ForwarderConfiguration, - PasswordRandomizer, - UsernameRandomizer, -} from "../engine"; -import { Forwarder } from "../engine/forwarder"; -import { - DefaultPolicyEvaluator, - DynamicPasswordPolicyConstraints, - PassphraseGeneratorOptionsEvaluator, - passphraseLeastPrivilege, - PassphrasePolicyConstraints, - PasswordGeneratorOptionsEvaluator, - passwordLeastPrivilege, -} from "../policies"; -import { CatchallConstraints } from "../policies/catchall-constraints"; -import { SubaddressConstraints } from "../policies/subaddress-constraints"; -import { - CatchallGenerationOptions, - CredentialGenerator, - CredentialGeneratorConfiguration, - EffUsernameGenerationOptions, - GeneratorDependencyProvider, - NoPolicy, - PassphraseGenerationOptions, - PassphraseGeneratorPolicy, - PasswordGenerationOptions, - PasswordGeneratorPolicy, - SubaddressGenerationOptions, -} from "../types"; - -import { DefaultCatchallOptions } from "./default-catchall-options"; -import { DefaultEffUsernameOptions } from "./default-eff-username-options"; -import { DefaultPassphraseBoundaries } from "./default-passphrase-boundaries"; -import { DefaultPassphraseGenerationOptions } from "./default-passphrase-generation-options"; -import { DefaultPasswordBoundaries } from "./default-password-boundaries"; -import { DefaultPasswordGenerationOptions } from "./default-password-generation-options"; -import { DefaultSubaddressOptions } from "./default-subaddress-generator-options"; - -const PASSPHRASE: CredentialGeneratorConfiguration< - PassphraseGenerationOptions, - PassphraseGeneratorPolicy -> = Object.freeze({ - id: "passphrase", - category: "password", - nameKey: "passphrase", - generateKey: "generatePassphrase", - onGeneratedMessageKey: "passphraseGenerated", - credentialTypeKey: "passphrase", - copyKey: "copyPassphrase", - useGeneratedValueKey: "useThisPassword", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new PasswordRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultPassphraseGenerationOptions, - constraints: { - numWords: { - min: DefaultPassphraseBoundaries.numWords.min, - max: DefaultPassphraseBoundaries.numWords.max, - recommendation: DefaultPassphraseGenerationOptions.numWords, - }, - wordSeparator: { maxLength: 1 }, - }, - account: { - key: "passphraseGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "numWords", - "wordSeparator", - "capitalize", - "includeNumber", - ]), - state: GENERATOR_DISK, - initial: DefaultPassphraseGenerationOptions, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: Object.freeze({ - minNumberWords: 0, - capitalize: false, - includeNumber: false, - }), - combine: passphraseLeastPrivilege, - createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), - toConstraints: (policy) => - new PassphrasePolicyConstraints(policy, PASSPHRASE.settings.constraints), - }, -}); - -const PASSWORD: CredentialGeneratorConfiguration< - PasswordGenerationOptions, - PasswordGeneratorPolicy -> = Object.freeze({ - id: "password", - category: "password", - nameKey: "password", - generateKey: "generatePassword", - onGeneratedMessageKey: "passwordGenerated", - credentialTypeKey: "password", - copyKey: "copyPassword", - useGeneratedValueKey: "useThisPassword", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new PasswordRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultPasswordGenerationOptions, - constraints: { - length: { - min: DefaultPasswordBoundaries.length.min, - max: DefaultPasswordBoundaries.length.max, - recommendation: DefaultPasswordGenerationOptions.length, - }, - minNumber: { - min: DefaultPasswordBoundaries.minDigits.min, - max: DefaultPasswordBoundaries.minDigits.max, - }, - minSpecial: { - min: DefaultPasswordBoundaries.minSpecialCharacters.min, - max: DefaultPasswordBoundaries.minSpecialCharacters.max, - }, - }, - account: { - key: "passwordGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "length", - "ambiguous", - "uppercase", - "minUppercase", - "lowercase", - "minLowercase", - "number", - "minNumber", - "special", - "minSpecial", - ]), - state: GENERATOR_DISK, - initial: DefaultPasswordGenerationOptions, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: Object.freeze({ - minLength: 0, - useUppercase: false, - useLowercase: false, - useNumbers: false, - numberCount: 0, - useSpecial: false, - specialCount: 0, - }), - combine: passwordLeastPrivilege, - createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), - toConstraints: (policy) => - new DynamicPasswordPolicyConstraints(policy, PASSWORD.settings.constraints), - }, -}); - -const USERNAME: CredentialGeneratorConfiguration = - Object.freeze({ - id: "username", - category: "username", - nameKey: "randomWord", - generateKey: "generateUsername", - onGeneratedMessageKey: "usernameGenerated", - credentialTypeKey: "username", - copyKey: "copyUsername", - useGeneratedValueKey: "useThisUsername", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new UsernameRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultEffUsernameOptions, - constraints: {}, - account: { - key: "effUsernameGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "wordCapitalize", - "wordIncludeNumber", - ]), - state: GENERATOR_DISK, - initial: DefaultEffUsernameOptions, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; - }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); - }, - toConstraints(_policy: NoPolicy) { - return new IdentityConstraint(); - }, - }, - }); - -const CATCHALL: CredentialGeneratorConfiguration = - Object.freeze({ - id: "catchall", - category: "email", - nameKey: "catchallEmail", - descriptionKey: "catchallEmailDesc", - generateKey: "generateEmail", - onGeneratedMessageKey: "emailGenerated", - credentialTypeKey: "email", - copyKey: "copyEmail", - useGeneratedValueKey: "useThisEmail", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new EmailRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultCatchallOptions, - constraints: { catchallDomain: { minLength: 1 } }, - account: { - key: "catchallGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "catchallType", - "catchallDomain", - ]), - state: GENERATOR_DISK, - initial: { - catchallType: "random", - catchallDomain: "", - }, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; - }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); - }, - toConstraints(_policy: NoPolicy, email: string) { - return new CatchallConstraints(email); - }, - }, - }); - -const SUBADDRESS: CredentialGeneratorConfiguration = - Object.freeze({ - id: "subaddress", - category: "email", - nameKey: "plusAddressedEmail", - descriptionKey: "plusAddressedEmailDesc", - generateKey: "generateEmail", - onGeneratedMessageKey: "emailGenerated", - credentialTypeKey: "email", - copyKey: "copyEmail", - useGeneratedValueKey: "useThisEmail", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new EmailRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultSubaddressOptions, - constraints: {}, - account: { - key: "subaddressGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "subaddressType", - "subaddressEmail", - ]), - state: GENERATOR_DISK, - initial: { - subaddressType: "random", - subaddressEmail: "", - }, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; - }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); - }, - toConstraints(_policy: NoPolicy, email: string) { - return new SubaddressConstraints(email); - }, - }, - }); - -export function toCredentialGeneratorConfiguration( - configuration: ForwarderConfiguration, -) { - const forwarder = Object.freeze({ - id: { forwarder: configuration.id }, - category: "email", - nameKey: configuration.name, - descriptionKey: "forwardedEmailDesc", - generateKey: "generateEmail", - onGeneratedMessageKey: "emailGenerated", - credentialTypeKey: "email", - copyKey: "copyEmail", - useGeneratedValueKey: "useThisEmail", - onlyOnRequest: true, - request: configuration.forwarder.request, - engine: { - create(dependencies: GeneratorDependencyProvider) { - // FIXME: figure out why `configuration` fails to typecheck - const config: any = configuration; - return new Forwarder(config, dependencies.client, dependencies.i18nService); - }, - }, - settings: { - initial: configuration.forwarder.defaultSettings, - constraints: configuration.forwarder.settingsConstraints, - account: configuration.forwarder.local.settings, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; - }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); - }, - toConstraints(_policy: NoPolicy) { - return new IdentityConstraint(); - }, - }, - } satisfies CredentialGeneratorConfiguration); - - return forwarder; -} - -/** Generator configurations */ -export const Generators = Object.freeze({ - /** Passphrase generator configuration */ - passphrase: PASSPHRASE, - - /** Password generator configuration */ - password: PASSWORD, - - /** Username generator configuration */ - username: USERNAME, - - /** Catchall email generator configuration */ - catchall: CATCHALL, - - /** Email subaddress generator configuration */ - subaddress: SUBADDRESS, -}); diff --git a/libs/tools/generator/core/src/data/index.ts b/libs/tools/generator/core/src/data/index.ts index 482703fd3c..bcf57e98c9 100644 --- a/libs/tools/generator/core/src/data/index.ts +++ b/libs/tools/generator/core/src/data/index.ts @@ -1,20 +1,8 @@ -export * from "./generators"; -export * from "./default-addy-io-options"; export * from "./default-catchall-options"; -export * from "./default-duck-duck-go-options"; -export * from "./default-fastmail-options"; -export * from "./default-forward-email-options"; export * from "./default-passphrase-boundaries"; export * from "./default-password-boundaries"; export * from "./default-eff-username-options"; -export * from "./default-firefox-relay-options"; export * from "./default-passphrase-generation-options"; export * from "./default-password-generation-options"; -export * from "./default-credential-preferences"; export * from "./default-subaddress-generator-options"; -export * from "./default-simple-login-options"; -export * from "./forwarders"; export * from "./integrations"; -export * from "./policies"; -export * from "./username-digits"; -export * from "./generator-types"; diff --git a/libs/tools/generator/core/src/data/policies.ts b/libs/tools/generator/core/src/data/policies.ts deleted file mode 100644 index 4e46718a39..0000000000 --- a/libs/tools/generator/core/src/data/policies.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - PassphraseGenerationOptions, - PassphraseGeneratorPolicy, - PasswordGenerationOptions, - PasswordGeneratorPolicy, - PolicyConfiguration, -} from "../types"; - -import { Generators } from "./generators"; - -/** Policy configurations - * @deprecated use Generator.*.policy instead - */ -export const Policies = Object.freeze({ - Passphrase: Generators.passphrase.policy, - Password: Generators.password.policy, -} satisfies { - /** Passphrase policy configuration */ - Passphrase: PolicyConfiguration; - - /** Password policy configuration */ - Password: PolicyConfiguration; -}); diff --git a/libs/tools/generator/core/src/data/username-digits.ts b/libs/tools/generator/core/src/data/username-digits.ts deleted file mode 100644 index 99ef15cf1c..0000000000 --- a/libs/tools/generator/core/src/data/username-digits.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const UsernameDigits = Object.freeze({ - enabled: 4, - disabled: 0, -}); diff --git a/libs/tools/generator/core/src/engine/email-randomizer.spec.ts b/libs/tools/generator/core/src/engine/email-randomizer.spec.ts index fb953af165..2ebe50d12d 100644 --- a/libs/tools/generator/core/src/engine/email-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/email-randomizer.spec.ts @@ -2,6 +2,8 @@ import { mock } from "jest-mock-extended"; import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { Algorithm, Type } from "../metadata"; + import { Randomizer } from "./abstractions"; import { EmailRandomizer } from "./email-randomizer"; @@ -41,7 +43,8 @@ describe("EmailRandomizer", () => { async (email) => { const emailRandomizer = new EmailRandomizer(randomizer); - const result = await emailRandomizer.randomAsciiSubaddress(email); + // this tests what happens when the type system is subverted + const result = await emailRandomizer.randomAsciiSubaddress(email!); expect(result).toEqual(""); }, @@ -100,7 +103,8 @@ describe("EmailRandomizer", () => { it.each([[null], [undefined], [""]])("returns null if the domain is %p", async (domain) => { const emailRandomizer = new EmailRandomizer(randomizer); - const result = await emailRandomizer.randomAsciiCatchall(domain); + // this tests what happens when the type system is subverted + const result = await emailRandomizer.randomAsciiCatchall(domain!); expect(result).toBeNull(); }); @@ -150,7 +154,8 @@ describe("EmailRandomizer", () => { it.each([[null], [undefined], [""]])("returns null if the domain is %p", async (domain) => { const emailRandomizer = new EmailRandomizer(randomizer); - const result = await emailRandomizer.randomWordsCatchall(domain); + // this tests what happens when the type system is subverted + const result = await emailRandomizer.randomWordsCatchall(domain!); expect(result).toBeNull(); }); @@ -214,32 +219,32 @@ describe("EmailRandomizer", () => { const email = new EmailRandomizer(randomizer); const result = await email.generate( - {}, + { algorithm: Algorithm.catchall }, { catchallDomain: "example.com", }, ); - expect(result.category).toEqual("catchall"); + expect(result.category).toEqual(Type.email); }); it("processes subaddress generation options", async () => { const email = new EmailRandomizer(randomizer); const result = await email.generate( - {}, + { algorithm: Algorithm.plusAddress }, { subaddressEmail: "foo@example.com", }, ); - expect(result.category).toEqual("subaddress"); + expect(result.category).toEqual(Type.email); }); it("throws when it cannot recognize the options type", async () => { const email = new EmailRandomizer(randomizer); - const result = email.generate({}, {}); + const result = email.generate({ algorithm: Algorithm.password }, {}); await expect(result).rejects.toBeInstanceOf(Error); }); diff --git a/libs/tools/generator/core/src/engine/email-randomizer.ts b/libs/tools/generator/core/src/engine/email-randomizer.ts index 0be95a975a..f673ba05fc 100644 --- a/libs/tools/generator/core/src/engine/email-randomizer.ts +++ b/libs/tools/generator/core/src/engine/email-randomizer.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { Type } from "../metadata"; import { CatchallGenerationOptions, CredentialGenerator, @@ -128,7 +129,7 @@ export class EmailRandomizer return new GeneratedCredential( email, - "catchall", + Type.email, Date.now(), request.source, request.website, @@ -138,7 +139,7 @@ export class EmailRandomizer return new GeneratedCredential( email, - "subaddress", + Type.email, Date.now(), request.source, request.website, diff --git a/libs/tools/generator/core/src/engine/forwarder.ts b/libs/tools/generator/core/src/engine/forwarder.ts index 6c6e574e87..5f41e35d21 100644 --- a/libs/tools/generator/core/src/engine/forwarder.ts +++ b/libs/tools/generator/core/src/engine/forwarder.ts @@ -8,6 +8,7 @@ import { } from "@bitwarden/common/tools/integration/rpc"; import { GenerationRequest } from "@bitwarden/common/tools/types"; +import { Type } from "../metadata"; import { CredentialGenerator, GeneratedCredential } from "../types"; import { AccountRequest, ForwarderConfiguration } from "./forwarder-configuration"; @@ -40,9 +41,8 @@ export class Forwarder implements CredentialGenerator { const create = this.createForwardingAddress(this.configuration, settings); const result = await this.client.fetchJson(create, requestOptions); - const id = { forwarder: this.configuration.id }; - return new GeneratedCredential(result, id, Date.now()); + return new GeneratedCredential(result, Type.email, Date.now()); } private createContext( diff --git a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts index fca98855fd..a36c4bb535 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts @@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended"; import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; import { Randomizer } from "../abstractions"; +import { Algorithm, Type } from "../metadata"; import { Ascii } from "./data"; import { PasswordRandomizer } from "./password-randomizer"; @@ -341,32 +342,32 @@ describe("PasswordRandomizer", () => { const password = new PasswordRandomizer(randomizer); const result = await password.generate( - {}, + { algorithm: Algorithm.password }, { length: 10, }, ); - expect(result.category).toEqual("password"); + expect(result.category).toEqual(Type.password); }); it("processes passphrase generation options", async () => { const password = new PasswordRandomizer(randomizer); const result = await password.generate( - {}, + { algorithm: Algorithm.passphrase }, { numWords: 10, }, ); - expect(result.category).toEqual("passphrase"); + expect(result.category).toEqual(Type.password); }); it("throws when it cannot recognize the options type", async () => { const password = new PasswordRandomizer(randomizer); - const result = password.generate({}, {}); + const result = password.generate({ algorithm: Algorithm.username }, {}); await expect(result).rejects.toBeInstanceOf(Error); }); diff --git a/libs/tools/generator/core/src/engine/password-randomizer.ts b/libs/tools/generator/core/src/engine/password-randomizer.ts index a9612d2fb4..dc61ee064e 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { Type } from "../metadata"; import { CredentialGenerator, GenerateRequest, @@ -86,7 +87,7 @@ export class PasswordRandomizer return new GeneratedCredential( password, - "password", + Type.password, Date.now(), request.source, request.website, @@ -97,7 +98,7 @@ export class PasswordRandomizer return new GeneratedCredential( passphrase, - "passphrase", + Type.password, Date.now(), request.source, request.website, diff --git a/libs/tools/generator/core/src/engine/username-randomizer.spec.ts b/libs/tools/generator/core/src/engine/username-randomizer.spec.ts index 54d140e446..be0650fe16 100644 --- a/libs/tools/generator/core/src/engine/username-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/username-randomizer.spec.ts @@ -2,6 +2,8 @@ import { mock } from "jest-mock-extended"; import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { Algorithm, Type } from "../metadata"; + import { Randomizer } from "./abstractions"; import { UsernameRandomizer } from "./username-randomizer"; @@ -108,19 +110,19 @@ describe("UsernameRandomizer", () => { const username = new UsernameRandomizer(randomizer); const result = await username.generate( - {}, + { algorithm: Algorithm.username }, { wordIncludeNumber: true, }, ); - expect(result.category).toEqual("username"); + expect(result.category).toEqual(Type.username); }); it("throws when it cannot recognize the options type", async () => { const username = new UsernameRandomizer(randomizer); - const result = username.generate({}, {}); + const result = username.generate({ algorithm: Algorithm.passphrase }, {}); await expect(result).rejects.toBeInstanceOf(Error); }); diff --git a/libs/tools/generator/core/src/index.ts b/libs/tools/generator/core/src/index.ts index 494d034b67..928e6786ff 100644 --- a/libs/tools/generator/core/src/index.ts +++ b/libs/tools/generator/core/src/index.ts @@ -3,13 +3,34 @@ export * from "./abstractions"; export * from "./data"; export { createRandomizer } from "./factories"; export * from "./types"; -export { CredentialGeneratorService } from "./services"; +export { DefaultCredentialGeneratorService } from "./services"; +export { + CredentialType, + CredentialAlgorithm, + PasswordAlgorithm, + Algorithm, + BuiltIn, + Type, + Profile, + GeneratorMetadata, + GeneratorProfile, + AlgorithmMetadata, + AlgorithmsByType, +} from "./metadata"; +export { + isForwarderExtensionId, + isEmailAlgorithm, + isUsernameAlgorithm, + isPasswordAlgorithm, + isSameAlgorithm, +} from "./metadata/util"; // These internal interfacess are exposed for use by other generator modules // They are unstable and may change arbitrarily export * as engine from "./engine"; export * as integration from "./integration"; export * as policies from "./policies"; +export * as providers from "./providers"; export * as rx from "./rx"; export * as services from "./services"; export * as strategies from "./strategies"; diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts index 93ffed3392..bd1be0ee6a 100644 --- a/libs/tools/generator/core/src/integration/addy-io.ts +++ b/libs/tools/generator/core/src/integration/addy-io.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -101,7 +102,7 @@ const forwarder = Object.freeze({ export const AddyIo = Object.freeze({ // integration - id: "anonaddy" as IntegrationId & VendorId, + id: Vendor.addyio 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 d2bd6173a1..b88a7f77a3 100644 --- a/libs/tools/generator/core/src/integration/duck-duck-go.ts +++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; 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"; @@ -90,7 +91,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const DuckDuckGo = Object.freeze({ - id: "duckduckgo" as IntegrationId & VendorId, + id: Vendor.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 bfde1aa70f..a540807666 100644 --- a/libs/tools/generator/core/src/integration/fastmail.ts +++ b/libs/tools/generator/core/src/integration/fastmail.ts @@ -6,6 +6,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; 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"; @@ -160,7 +161,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const Fastmail = Object.freeze({ - id: "fastmail" as IntegrationId & VendorId, + id: Vendor.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 f80de0c95d..9fbd56aa6e 100644 --- a/libs/tools/generator/core/src/integration/firefox-relay.ts +++ b/libs/tools/generator/core/src/integration/firefox-relay.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; 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"; @@ -98,7 +99,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const FirefoxRelay = Object.freeze({ - id: "firefoxrelay" as IntegrationId & VendorId, + id: Vendor.mozilla 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 34b4602b94..b53fc4ffab 100644 --- a/libs/tools/generator/core/src/integration/forward-email.ts +++ b/libs/tools/generator/core/src/integration/forward-email.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; 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"; @@ -102,7 +103,7 @@ const forwarder = Object.freeze({ export const ForwardEmail = Object.freeze({ // integration metadata - id: "forwardemail" as IntegrationId & VendorId, + id: Vendor.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 efbac69cec..f3cc776d40 100644 --- a/libs/tools/generator/core/src/integration/simple-login.ts +++ b/libs/tools/generator/core/src/integration/simple-login.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -104,7 +105,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const SimpleLogin = Object.freeze({ - id: "simplelogin" as IntegrationId & VendorId, + id: Vendor.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 c07deef553..8bffa630dd 100644 --- a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts +++ b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts @@ -1,6 +1,6 @@ -import { CredentialAlgorithm, CredentialType } from "./type"; +import { I18nKeyOrLiteral } from "@bitwarden/common/tools/types"; -type I18nKeyOrLiteral = string | { literal: string }; +import { CredentialAlgorithm, CredentialType } from "./type"; /** Credential generator metadata common across credential generators */ export type AlgorithmMetadata = { @@ -14,7 +14,7 @@ export type AlgorithmMetadata = { id: CredentialAlgorithm; /** The kind of credential generated by this configuration */ - category: CredentialType; + type: CredentialType; /** Used to order credential algorithms for display purposes. * Items with lesser weights appear before entries with greater @@ -23,6 +23,10 @@ export type AlgorithmMetadata = { weight: number; /** Localization keys */ + // FIXME: in practice, keys like `credentialGenerated` all align + // with credential types and contain duplicate keys. Extract + // them into a "credential type metadata" type and integrate + // that type with the algorithm metadata instead. i18nKeys: { /** descriptive name of the algorithm */ name: I18nKeyOrLiteral; 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 d6cc1795e0..1099a6d59e 100644 --- a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts +++ b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts @@ -2,7 +2,8 @@ import { mock } from "jest-mock-extended"; import { EmailRandomizer } from "../../engine"; import { CatchallConstraints } from "../../policies/catchall-constraints"; -import { CatchallGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CatchallGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/email/catchall.ts b/libs/tools/generator/core/src/metadata/email/catchall.ts index 0711e5c371..991f6b18b7 100644 --- a/libs/tools/generator/core/src/metadata/email/catchall.ts +++ b/libs/tools/generator/core/src/metadata/email/catchall.ts @@ -4,17 +4,14 @@ import { deepFreeze } from "@bitwarden/common/tools/util"; import { EmailRandomizer } from "../../engine"; import { CatchallConstraints } from "../../policies/catchall-constraints"; -import { - CatchallGenerationOptions, - CredentialGenerator, - GeneratorDependencyProvider, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CatchallGenerationOptions, CredentialGenerator } from "../../types"; import { Algorithm, Type, Profile } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const catchall: GeneratorMetadata = deepFreeze({ id: Algorithm.catchall, - category: Type.email, + type: Type.email, weight: 210, i18nKeys: { name: "catchallEmail", diff --git a/libs/tools/generator/core/src/metadata/email/forwarder.ts b/libs/tools/generator/core/src/metadata/email/forwarder.ts index f4f150f33f..1066f890ef 100644 --- a/libs/tools/generator/core/src/metadata/email/forwarder.ts +++ b/libs/tools/generator/core/src/metadata/email/forwarder.ts @@ -1,19 +1,14 @@ 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 { GeneratorDependencyProvider } from "../../providers"; +import { ForwarderOptions } 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, @@ -28,7 +23,7 @@ export function toForwarderMetadata( const generator: GeneratorMetadata = { id: { forwarder: extension.product.vendor.id }, - category: Type.email, + type: Type.email, weight: 300, i18nKeys: { name, @@ -56,6 +51,12 @@ export function toForwarderMetadata( storage: { key: "forwarder", frame: 512, + initial: { + token: "", + baseUrl: "", + domain: "", + prefix: "", + }, options: { deserializer: (value) => value, clearOn: ["logout"], 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 063cb71c23..befc900cea 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 @@ -2,7 +2,8 @@ import { mock } from "jest-mock-extended"; import { EmailRandomizer } from "../../engine"; import { SubaddressConstraints } from "../../policies/subaddress-constraints"; -import { SubaddressGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { SubaddressGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.ts b/libs/tools/generator/core/src/metadata/email/plus-address.ts index 0db0acd415..940d659944 100644 --- a/libs/tools/generator/core/src/metadata/email/plus-address.ts +++ b/libs/tools/generator/core/src/metadata/email/plus-address.ts @@ -4,17 +4,14 @@ import { deepFreeze } from "@bitwarden/common/tools/util"; import { EmailRandomizer } from "../../engine"; import { SubaddressConstraints } from "../../policies/subaddress-constraints"; -import { - CredentialGenerator, - GeneratorDependencyProvider, - SubaddressGenerationOptions, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, SubaddressGenerationOptions } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const plusAddress: GeneratorMetadata = deepFreeze({ id: Algorithm.plusAddress, - category: Type.email, + type: Type.email, weight: 200, i18nKeys: { name: "plusAddressedEmail", diff --git a/libs/tools/generator/core/src/metadata/generator-metadata.ts b/libs/tools/generator/core/src/metadata/generator-metadata.ts index 9296d30430..704ce88b21 100644 --- a/libs/tools/generator/core/src/metadata/generator-metadata.ts +++ b/libs/tools/generator/core/src/metadata/generator-metadata.ts @@ -1,4 +1,5 @@ -import { CredentialGenerator, GeneratorDependencyProvider } from "../types"; +import { GeneratorDependencyProvider } from "../providers"; +import { CredentialGenerator } from "../types"; import { AlgorithmMetadata } from "./algorithm-metadata"; import { Profile } from "./data"; diff --git a/libs/tools/generator/core/src/metadata/index.ts b/libs/tools/generator/core/src/metadata/index.ts index d943782227..17c0291870 100644 --- a/libs/tools/generator/core/src/metadata/index.ts +++ b/libs/tools/generator/core/src/metadata/index.ts @@ -3,7 +3,32 @@ import { AlgorithmsByType as AlgorithmsByTypeData, Type as TypeData, } from "./data"; +import catchall from "./email/catchall"; +import plusAddress from "./email/plus-address"; +import passphrase from "./password/eff-word-list"; +import password from "./password/random-password"; import { CredentialType, CredentialAlgorithm } from "./type"; +import effWordList from "./username/eff-word-list"; + +/** Credential generators hosted natively by the credential generator system. + * These are supplemented by generators from the {@link ExtensionService}. + */ +export const BuiltIn = Object.freeze({ + /** Catchall email address generator */ + catchall, + + /** plus-addressed email address generator */ + plusAddress, + + /** passphrase generator using the EFF word list */ + passphrase, + + /** password generator */ + password, + + /** username generator using the EFF word list */ + effWordList, +}); // `CredentialAlgorithm` is defined in terms of `ABT`; supplying // type information in the barrel file breaks a circular dependency. @@ -12,14 +37,29 @@ export const AlgorithmsByType: Record< CredentialType, ReadonlyArray > = AlgorithmsByTypeData; + +/** A list of all built-in algorithm identifiers + * @remarks this is useful when you need to filter invalid values + */ export const Algorithms: ReadonlyArray = Object.freeze( Object.values(AlgorithmData), ); + +/** A list of all built-in algorithm types + * @remarks this is useful when you need to filter invalid values + */ export const Types: ReadonlyArray = Object.freeze(Object.values(TypeData)); export { Profile, Type, Algorithm } from "./data"; export { toForwarderMetadata } from "./email/forwarder"; +export { AlgorithmMetadata } from "./algorithm-metadata"; export { GeneratorMetadata } from "./generator-metadata"; export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata"; -export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type"; +export { + GeneratorProfile, + CredentialAlgorithm, + PasswordAlgorithm, + CredentialType, + ForwarderExtensionId, +} from "./type"; export { isForwarderProfile, toVendorId, isForwarderExtensionId } from "./util"; 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 e02d63d3d5..0c0693af27 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 @@ -5,7 +5,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PasswordRandomizer } from "../../engine"; import { PassphrasePolicyConstraints } from "../../policies"; -import { PassphraseGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { PassphraseGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts index fc86032bf6..021112f7c8 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts @@ -5,17 +5,14 @@ import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { PasswordRandomizer } from "../../engine"; import { passphraseLeastPrivilege, PassphrasePolicyConstraints } from "../../policies"; -import { - CredentialGenerator, - GeneratorDependencyProvider, - PassphraseGenerationOptions, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, PassphraseGenerationOptions } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const passphrase: GeneratorMetadata = { id: Algorithm.passphrase, - category: Type.password, + type: Type.password, weight: 110, i18nKeys: { name: "passphrase", @@ -26,7 +23,7 @@ const passphrase: GeneratorMetadata = { useCredential: "useThisPassphrase", }, capabilities: { - autogenerate: false, + autogenerate: true, fields: [], }, engine: { 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 9e38c50ee2..b22f3e9356 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 @@ -5,7 +5,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints } from "../../policies"; -import { PasswordGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { PasswordGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/password/random-password.ts b/libs/tools/generator/core/src/metadata/password/random-password.ts index 693236b096..e446f1962a 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.ts @@ -5,17 +5,14 @@ import { deepFreeze } from "@bitwarden/common/tools/util"; import { PasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints, passwordLeastPrivilege } from "../../policies"; -import { - CredentialGenerator, - GeneratorDependencyProvider, - PasswordGeneratorSettings, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, PasswordGeneratorSettings } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const password: GeneratorMetadata = deepFreeze({ id: Algorithm.password, - category: Type.password, + type: Type.password, weight: 100, i18nKeys: { name: "password", 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 d47d5ec9fc..beebb01650 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 @@ -3,7 +3,8 @@ import { mock } from "jest-mock-extended"; import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; import { UsernameRandomizer } from "../../engine"; -import { EffUsernameGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { EffUsernameGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.ts index 6373daf8ed..2802eea2c0 100644 --- a/libs/tools/generator/core/src/metadata/username/eff-word-list.ts +++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.ts @@ -4,17 +4,14 @@ import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state import { deepFreeze } from "@bitwarden/common/tools/util"; import { UsernameRandomizer } from "../../engine"; -import { - CredentialGenerator, - EffUsernameGenerationOptions, - GeneratorDependencyProvider, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, EffUsernameGenerationOptions } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const effWordList: GeneratorMetadata = deepFreeze({ id: Algorithm.username, - category: Type.username, + type: Type.username, weight: 400, i18nKeys: { name: "randomWord", diff --git a/libs/tools/generator/core/src/metadata/util.ts b/libs/tools/generator/core/src/metadata/util.ts index 86b2742e86..a8e8879b57 100644 --- a/libs/tools/generator/core/src/metadata/util.ts +++ b/libs/tools/generator/core/src/metadata/util.ts @@ -12,23 +12,23 @@ import { /** Returns true when the input algorithm is a password algorithm. */ export function isPasswordAlgorithm( - algorithm: CredentialAlgorithm, + algorithm: CredentialAlgorithm | null, ): algorithm is PasswordAlgorithm { return AlgorithmsByType.password.includes(algorithm as any); } /** Returns true when the input algorithm is a username algorithm. */ export function isUsernameAlgorithm( - algorithm: CredentialAlgorithm, + algorithm: CredentialAlgorithm | null, ): algorithm is UsernameAlgorithm { return AlgorithmsByType.username.includes(algorithm as any); } /** Returns true when the input algorithm is a forwarder integration. */ export function isForwarderExtensionId( - algorithm: CredentialAlgorithm, + algorithm: CredentialAlgorithm | null, ): algorithm is ForwarderExtensionId { - return algorithm && typeof algorithm === "object" && "forwarder" in algorithm; + return !!(algorithm && typeof algorithm === "object" && "forwarder" in algorithm); } /** Extract a `VendorId` from a `CredentialAlgorithm`. diff --git a/libs/tools/generator/core/src/policies/available-algorithms-constraint.ts b/libs/tools/generator/core/src/policies/available-algorithms-constraint.ts new file mode 100644 index 0000000000..1824581664 --- /dev/null +++ b/libs/tools/generator/core/src/policies/available-algorithms-constraint.ts @@ -0,0 +1,76 @@ +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; +import { Constraints, StateConstraints } from "@bitwarden/common/tools/types"; + +import { CredentialAlgorithm, CredentialType } from "../metadata"; +import { CredentialPreference } from "../types"; +import { TypeRequest } from "../types/metadata-request"; + +export class AvailableAlgorithmsConstraint implements StateConstraints { + /** Well-known constraints of `State` */ + readonly constraints: Readonly> = {}; + + /** Creates a password policy constraints + * @param algorithms loads the algorithms for an algorithm type + * @param isAvailable returns `true` when `algorithm` is enabled by policy + * @param system provides logging facilities + */ + constructor( + readonly algorithms: (request: TypeRequest) => CredentialAlgorithm[], + readonly isAvailable: (algorithm: CredentialAlgorithm) => boolean, + readonly system: UserStateSubjectDependencyProvider, + ) { + this.log = system.log({ type: "AvailableAlgorithmsConstraint" }); + } + private readonly log: SemanticLogger; + + adjust(preferences: CredentialPreference): CredentialPreference { + const result: any = {}; + + const types = Object.keys(preferences) as CredentialType[]; + for (const t of types) { + result[t] = this.adjustPreference(t, preferences[t]); + } + + return result; + } + + private adjustPreference(type: CredentialType, preference: { algorithm: CredentialAlgorithm }) { + if (this.isAvailable(preference.algorithm)) { + this.log.debug({ preference, type }, "using preferred algorithm"); + + return preference; + } + + // choose a default - this algorithm is arbitrary, but stable. + const algorithms = type ? this.algorithms({ type: type }) : []; + const defaultAlgorithm = algorithms.find(this.isAvailable) ?? null; + + // adjust the preference + let adjustedPreference; + if (defaultAlgorithm) { + adjustedPreference = { + ...preference, + algorithm: defaultAlgorithm, + updated: this.system.now(), + }; + this.log.debug( + { preference, defaultAlgorithm, type }, + "preference not available; defaulting the algorithm", + ); + } else { + // FIXME: hard-code a fallback in category metadata + this.log.warn( + { preference, type }, + "preference not available and default algorithm not found; continuing with preference", + ); + adjustedPreference = preference; + } + + return adjustedPreference; + } + + fix(preferences: CredentialPreference): CredentialPreference { + return preferences; + } +} diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts index 1ef0adc1af..5f699974fb 100644 --- a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts @@ -2,15 +2,15 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyId } from "@bitwarden/common/types/guid"; -import { CredentialAlgorithms, PasswordAlgorithms } from "../data"; +import { Algorithm, Algorithms, AlgorithmsByType } from "../metadata"; import { availableAlgorithms } from "./available-algorithms-policy"; -describe("availableAlgorithmsPolicy", () => { +describe("availableAlgorithms_vNextPolicy", () => { it("returns all algorithms", () => { const result = availableAlgorithms([]); - for (const expected of CredentialAlgorithms) { + for (const expected of Algorithms) { expect(result).toContain(expected); } }); @@ -30,7 +30,7 @@ describe("availableAlgorithmsPolicy", () => { expect(result).toContain(override); - for (const expected of PasswordAlgorithms.filter((a) => a !== override)) { + for (const expected of AlgorithmsByType[Algorithm.password].filter((a) => a !== override)) { expect(result).not.toContain(expected); } }); @@ -50,7 +50,7 @@ describe("availableAlgorithmsPolicy", () => { expect(result).toContain(override); - for (const expected of PasswordAlgorithms.filter((a) => a !== override)) { + for (const expected of AlgorithmsByType[Algorithm.password].filter((a) => a !== override)) { expect(result).not.toContain(expected); } }); @@ -79,7 +79,7 @@ describe("availableAlgorithmsPolicy", () => { expect(result).toContain("password"); - for (const expected of PasswordAlgorithms.filter((a) => a !== "password")) { + for (const expected of AlgorithmsByType[Algorithm.password].filter((a) => a !== "password")) { expect(result).not.toContain(expected); } }); @@ -97,7 +97,7 @@ describe("availableAlgorithmsPolicy", () => { const result = availableAlgorithms([policy]); - for (const expected of CredentialAlgorithms) { + for (const expected of Algorithms) { expect(result).toContain(expected); } }); @@ -115,7 +115,7 @@ describe("availableAlgorithmsPolicy", () => { const result = availableAlgorithms([policy]); - for (const expected of CredentialAlgorithms) { + for (const expected of Algorithms) { expect(result).toContain(expected); } }); @@ -133,7 +133,7 @@ describe("availableAlgorithmsPolicy", () => { const result = availableAlgorithms([policy]); - for (const expected of CredentialAlgorithms) { + for (const expected of Algorithms) { expect(result).toContain(expected); } }); 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 0c44a1a040..e63b648cf4 100644 --- a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts @@ -1,57 +1,30 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { PolicyType } from "@bitwarden/common/admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models // implement ADR-0002 import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { - CredentialAlgorithm as LegacyAlgorithm, - EmailAlgorithms, - PasswordAlgorithms, - UsernameAlgorithms, -} from ".."; -import { CredentialAlgorithm } from "../metadata"; +import { AlgorithmsByType, CredentialAlgorithm, Type } 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[]): LegacyAlgorithm[] { +export function availableAlgorithms(policies: Policy[]): CredentialAlgorithm[] { const overridePassword = policies .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) .reduce( (type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)), - null as LegacyAlgorithm, + null as CredentialAlgorithm | null, ); - const policy: LegacyAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms]; + const policy: CredentialAlgorithm[] = [ + ...AlgorithmsByType[Type.email], + ...AlgorithmsByType[Type.username], + ]; 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( - (type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)), - null as CredentialAlgorithm, - ); - - const policy: CredentialAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms]; - if (overridePassword) { - policy.push(overridePassword); - } else { - policy.push(...PasswordAlgorithms); + policy.push(...AlgorithmsByType[Type.password]); } return policy; diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts index c8ae02ef72..0bebb0825b 100644 --- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts @@ -1,15 +1,25 @@ import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; -import { Generators } from "../data"; +import { BuiltIn, Profile } from "../metadata"; import { PasswordGeneratorSettings } from "../types"; import { AtLeastOne, Zero } from "./constraints"; import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints"; -const accoutSettings = Generators.password.settings.account as ObjectKey; -const defaultOptions = accoutSettings.initial; -const disabledPolicy = Generators.password.policy.disabledValue; -const someConstraints = Generators.password.settings.constraints; +// non-null assertions used because these are always-defined constants +const accoutSettings = BuiltIn.password.profiles[Profile.account]! + .storage as ObjectKey; +const defaultOptions = accoutSettings.initial!; +const disabledPolicy = { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, +}; +const someConstraints = BuiltIn.password.profiles[Profile.account]!.constraints.default; describe("DynamicPasswordPolicyConstraints", () => { describe("constructor", () => { @@ -33,8 +43,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.lowercase.readonly).toEqual(true); - expect(constraints.lowercase.requiredValue).toEqual(true); + expect(constraints.lowercase?.readonly).toEqual(true); + expect(constraints.lowercase?.requiredValue).toEqual(true); expect(constraints.minLowercase).toEqual({ min: 1 }); }); @@ -43,8 +53,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.uppercase.readonly).toEqual(true); - expect(constraints.uppercase.requiredValue).toEqual(true); + expect(constraints.uppercase?.readonly).toEqual(true); + expect(constraints.uppercase?.requiredValue).toEqual(true); expect(constraints.minUppercase).toEqual({ min: 1 }); }); @@ -53,8 +63,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.number.readonly).toEqual(true); - expect(constraints.number.requiredValue).toEqual(true); + expect(constraints.number?.readonly).toEqual(true); + expect(constraints.number?.requiredValue).toEqual(true); expect(constraints.minNumber).toEqual({ min: 1, max: 9 }); }); @@ -63,8 +73,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.special.readonly).toEqual(true); - expect(constraints.special.requiredValue).toEqual(true); + expect(constraints.special?.readonly).toEqual(true); + expect(constraints.special?.requiredValue).toEqual(true); expect(constraints.minSpecial).toEqual({ min: 1, max: 9 }); }); @@ -73,8 +83,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.number.readonly).toEqual(true); - expect(constraints.number.requiredValue).toEqual(true); + expect(constraints.number?.readonly).toEqual(true); + expect(constraints.number?.requiredValue).toEqual(true); expect(constraints.minNumber).toEqual({ min: 2, max: 9 }); }); @@ -83,8 +93,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.special.readonly).toEqual(true); - expect(constraints.special.requiredValue).toEqual(true); + expect(constraints.special?.readonly).toEqual(true); + expect(constraints.special?.requiredValue).toEqual(true); expect(constraints.minSpecial).toEqual({ min: 2, max: 9 }); }); @@ -140,7 +150,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const dynamic = new DynamicPasswordPolicyConstraints( { ...disabledPolicy, - useLowercase, + // the `undefined` case is testing behavior when the type system is bypassed + useLowercase: useLowercase!, }, someConstraints, ); @@ -185,7 +196,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const dynamic = new DynamicPasswordPolicyConstraints( { ...disabledPolicy, - useUppercase, + // the `undefined` case is testing behavior when the type system is bypassed + useUppercase: useUppercase!, }, someConstraints, ); diff --git a/libs/tools/generator/core/src/policies/index.ts b/libs/tools/generator/core/src/policies/index.ts index 0d05e70230..893f0402d3 100644 --- a/libs/tools/generator/core/src/policies/index.ts +++ b/libs/tools/generator/core/src/policies/index.ts @@ -5,3 +5,5 @@ export { PassphrasePolicyConstraints } from "./passphrase-policy-constraints"; export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; export { passphraseLeastPrivilege } from "./passphrase-least-privilege"; export { passwordLeastPrivilege } from "./password-least-privilege"; +export { AvailableAlgorithmsConstraint } from "./available-algorithms-constraint"; +export { availableAlgorithms } from "./available-algorithms-policy"; diff --git a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts index 3b1eb79939..5fcf847c50 100644 --- a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts @@ -1,12 +1,32 @@ -import { Policies, DefaultPassphraseBoundaries } from "../data"; -import { PassphraseGenerationOptions } from "../types"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { DefaultPassphraseBoundaries } from "../data"; +import { + PassphraseGenerationOptions, + PassphraseGeneratorPolicy, + PolicyConfiguration, +} from "../types"; import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; +import { passphraseLeastPrivilege } from "./passphrase-least-privilege"; -describe("Password generator options builder", () => { +const Passphrase: PolicyConfiguration = + deepFreeze({ + type: PolicyType.PasswordGenerator, + disabledValue: { + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }, + combine: passphraseLeastPrivilege, + createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), + }); + +describe("Passphrase generator options builder", () => { describe("constructor()", () => { it("should set the policy object to a copy of the input policy", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = 10; // arbitrary change for deep equality check const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -16,7 +36,7 @@ describe("Password generator options builder", () => { }); it("should set default boundaries when a default policy is used", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); expect(builder.numWords).toEqual(DefaultPassphraseBoundaries.numWords); @@ -25,7 +45,7 @@ describe("Password generator options builder", () => { it.each([1, 2])( "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)", (minNumberWords) => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -37,7 +57,7 @@ describe("Password generator options builder", () => { it.each([8, 12, 18])( "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words", (minNumberWords) => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -50,7 +70,7 @@ describe("Password generator options builder", () => { it.each([150, 300, 9000])( "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries", (minNumberWords) => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -63,14 +83,14 @@ describe("Password generator options builder", () => { describe("policyInEffect", () => { it("should return false when the policy has no effect", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); expect(builder.policyInEffect).toEqual(false); }); it("should return true when the policy has a numWords greater than the default boundary", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = DefaultPassphraseBoundaries.numWords.min + 1; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -78,7 +98,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has capitalize enabled", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.capitalize = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -86,7 +106,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has includeNumber enabled", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.includeNumber = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -98,7 +118,7 @@ describe("Password generator options builder", () => { // All tests should freeze the options to ensure they are not modified it("should set `capitalize` to `false` when the policy does not override it", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -108,7 +128,7 @@ describe("Password generator options builder", () => { }); it("should set `capitalize` to `true` when the policy overrides it", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.capitalize = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ capitalize: false }); @@ -119,7 +139,7 @@ describe("Password generator options builder", () => { }); it("should set `includeNumber` to false when the policy does not override it", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -129,7 +149,7 @@ describe("Password generator options builder", () => { }); it("should set `includeNumber` to true when the policy overrides it", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.includeNumber = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ includeNumber: false }); @@ -140,7 +160,7 @@ describe("Password generator options builder", () => { }); it("should set `numWords` to the minimum value when it isn't supplied", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -154,7 +174,7 @@ describe("Password generator options builder", () => { (numWords) => { expect(numWords).toBeLessThan(DefaultPassphraseBoundaries.numWords.min); - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -170,7 +190,7 @@ describe("Password generator options builder", () => { expect(numWords).toBeGreaterThanOrEqual(DefaultPassphraseBoundaries.numWords.min); expect(numWords).toBeLessThanOrEqual(DefaultPassphraseBoundaries.numWords.max); - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -185,7 +205,7 @@ describe("Password generator options builder", () => { (numWords) => { expect(numWords).toBeGreaterThan(DefaultPassphraseBoundaries.numWords.max); - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -196,7 +216,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", @@ -214,7 +234,7 @@ describe("Password generator options builder", () => { // All tests should freeze the options to ensure they are not modified it("should return the input options without altering them", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ wordSeparator: "%" }); @@ -224,7 +244,7 @@ describe("Password generator options builder", () => { }); it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -234,7 +254,7 @@ describe("Password generator options builder", () => { }); it("should leave `wordSeparator` as the empty string '' when it is the empty string", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ wordSeparator: "" }); @@ -244,7 +264,7 @@ describe("Password generator options builder", () => { }); it("should preserve unknown properties", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", diff --git a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts index ecac385598..0fbc1796e9 100644 --- a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts @@ -4,8 +4,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyId } from "@bitwarden/common/types/guid"; -import { Policies } from "../data"; - import { passphraseLeastPrivilege } from "./passphrase-least-privilege"; function createPolicy( @@ -22,21 +20,27 @@ function createPolicy( }); } +const disabledValue = Object.freeze({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, +}); + describe("passphraseLeastPrivilege", () => { it("should return the accumulator when the policy type does not apply", () => { const policy = createPolicy({}, PolicyType.RequireSso); - const result = passphraseLeastPrivilege(Policies.Passphrase.disabledValue, policy); + const result = passphraseLeastPrivilege(disabledValue, policy); - expect(result).toEqual(Policies.Passphrase.disabledValue); + expect(result).toEqual(disabledValue); }); it("should return the accumulator when the policy is not enabled", () => { const policy = createPolicy({}, PolicyType.PasswordGenerator, false); - const result = passphraseLeastPrivilege(Policies.Passphrase.disabledValue, policy); + const result = passphraseLeastPrivilege(disabledValue, policy); - expect(result).toEqual(Policies.Passphrase.disabledValue); + expect(result).toEqual(disabledValue); }); it.each([ @@ -46,8 +50,8 @@ describe("passphraseLeastPrivilege", () => { ])("should take the %p from the policy", (input, value) => { const policy = createPolicy({ [input]: value }); - const result = passphraseLeastPrivilege(Policies.Passphrase.disabledValue, policy); + const result = passphraseLeastPrivilege(disabledValue, policy); - expect(result).toEqual({ ...Policies.Passphrase.disabledValue, [input]: value }); + expect(result).toEqual({ ...disabledValue, [input]: value }); }); }); diff --git a/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts index d6e0a5615d..6306382c84 100644 --- a/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts @@ -1,4 +1,4 @@ -import { Generators } from "../data"; +import { BuiltIn, Profile } from "../metadata"; import { PassphrasePolicyConstraints } from "./passphrase-policy-constraints"; @@ -9,8 +9,12 @@ const SomeSettings = { wordSeparator: "-", }; -const disabledPolicy = Generators.passphrase.policy.disabledValue; -const someConstraints = Generators.passphrase.settings.constraints; +const disabledPolicy = { + minNumberWords: 0, + capitalize: false, + includeNumber: false, +}; +const someConstraints = BuiltIn.passphrase.profiles[Profile.account]!.constraints.default; describe("PassphrasePolicyConstraints", () => { describe("constructor", () => { @@ -61,7 +65,7 @@ describe("PassphrasePolicyConstraints", () => { expect(constraints.policyInEffect).toBeTruthy(); expect(constraints.numWords).toMatchObject({ min: 10, - max: someConstraints.numWords.max, + max: someConstraints.numWords?.max, }); }); }); @@ -84,8 +88,8 @@ describe("PassphrasePolicyConstraints", () => { }); it.each([ - [1, someConstraints.numWords.min, 3, someConstraints.numWords.max], - [21, someConstraints.numWords.min, 20, someConstraints.numWords.max], + [1, someConstraints.numWords?.min, 3, someConstraints.numWords?.max], + [21, someConstraints.numWords?.min, 20, someConstraints.numWords?.max], ])( `fits numWords (=%p) within the default bounds (%p <= %p <= %p)`, (value, _, expected, __) => { @@ -98,8 +102,8 @@ describe("PassphrasePolicyConstraints", () => { ); it.each([ - [1, 6, 6, someConstraints.numWords.max], - [21, 20, 20, someConstraints.numWords.max], + [1, 6, 6, someConstraints.numWords?.max], + [21, 20, 20, someConstraints.numWords?.max], ])( "fits numWords (=%p) within the policy bounds (%p <= %p <= %p)", (value, minNumberWords, expected, _) => { diff --git a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts index 91334f91f8..a088f93d3f 100644 --- a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts +++ b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts @@ -1,14 +1,34 @@ -import { DefaultPasswordBoundaries, Policies } from "../data"; -import { PasswordGenerationOptions } from "../types"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { DefaultPasswordBoundaries } from "../data"; +import { PasswordGenerationOptions, PasswordGeneratorPolicy, PolicyConfiguration } from "../types"; import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; +import { passwordLeastPrivilege } from "./password-least-privilege"; + +const Password: PolicyConfiguration = + deepFreeze({ + type: PolicyType.PasswordGenerator, + disabledValue: { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }, + combine: passwordLeastPrivilege, + createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), + }); describe("Password generator options builder", () => { const defaultOptions = Object.freeze({ minLength: 0 }); describe("constructor()", () => { it("should set the policy object to a copy of the input policy", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = 10; // arbitrary change for deep equality check const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -18,7 +38,7 @@ describe("Password generator options builder", () => { }); it("should set default boundaries when a default policy is used", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -32,7 +52,7 @@ describe("Password generator options builder", () => { (minLength) => { expect(minLength).toBeLessThan(DefaultPasswordBoundaries.length.min); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = minLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -47,7 +67,7 @@ describe("Password generator options builder", () => { expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.min); expect(expectedLength).toBeLessThanOrEqual(DefaultPasswordBoundaries.length.max); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -62,7 +82,7 @@ describe("Password generator options builder", () => { (expectedLength) => { expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.max); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -78,7 +98,7 @@ describe("Password generator options builder", () => { expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.min); expect(expectedMinDigits).toBeLessThanOrEqual(DefaultPasswordBoundaries.minDigits.max); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -93,7 +113,7 @@ describe("Password generator options builder", () => { (expectedMinDigits) => { expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.max); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -113,7 +133,7 @@ describe("Password generator options builder", () => { DefaultPasswordBoundaries.minSpecialCharacters.max, ); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -132,7 +152,7 @@ describe("Password generator options builder", () => { DefaultPasswordBoundaries.minSpecialCharacters.max, ); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -151,7 +171,7 @@ describe("Password generator options builder", () => { (expectedLength, numberCount, specialCount) => { expect(expectedLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = numberCount; policy.specialCount = specialCount; @@ -164,14 +184,14 @@ describe("Password generator options builder", () => { describe("policyInEffect", () => { it("should return false when the policy has no effect", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(builder.policyInEffect).toEqual(false); }); it("should return true when the policy has a minlength greater than the default boundary", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = DefaultPasswordBoundaries.length.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -179,7 +199,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a number count greater than the default boundary", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = DefaultPasswordBoundaries.minDigits.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -187,7 +207,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a special character count greater than the default boundary", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.specialCount = DefaultPasswordBoundaries.minSpecialCharacters.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -195,7 +215,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has uppercase enabled", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useUppercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -203,7 +223,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has lowercase enabled", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useLowercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -211,7 +231,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has numbers enabled", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useNumbers = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -219,7 +239,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has special characters enabled", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useSpecial = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -237,7 +257,7 @@ describe("Password generator options builder", () => { ])( "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'", (expectedUppercase, uppercase) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useUppercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -251,7 +271,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true", (uppercase) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useUppercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -269,7 +289,7 @@ describe("Password generator options builder", () => { ])( "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'", (expectedLowercase, lowercase) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useLowercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -283,7 +303,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true", (lowercase) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useLowercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -301,7 +321,7 @@ describe("Password generator options builder", () => { ])( "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'", (expectedNumber, number) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useNumbers = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -315,7 +335,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.number` (= %s) to true when `policy.useNumbers` is true", (number) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useNumbers = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -333,7 +353,7 @@ describe("Password generator options builder", () => { ])( "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'", (expectedSpecial, special) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useSpecial = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -347,7 +367,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.special` (= %s) to true when `policy.useSpecial` is true", (special) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useSpecial = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -361,7 +381,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.length` (= %i) to the minimum it is less than the minimum length", (length) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeLessThan(builder.length.min); @@ -376,7 +396,7 @@ describe("Password generator options builder", () => { it.each([5, 10, 50, 100, 128])( "should not change `options.length` (= %i) when it is within the boundaries", (length) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeGreaterThanOrEqual(builder.length.min); expect(length).toBeLessThanOrEqual(builder.length.max); @@ -392,7 +412,7 @@ describe("Password generator options builder", () => { it.each([129, 500, 9000])( "should set `options.length` (= %i) to the maximum length when it is exceeded", (length) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeGreaterThan(builder.length.max); @@ -414,7 +434,7 @@ describe("Password generator options builder", () => { ])( "should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0", (expectedNumber, minNumber) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, minNumber }); @@ -425,7 +445,7 @@ describe("Password generator options builder", () => { ); it("should set `options.minNumber` to the minimum value when `options.number` is true", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number: true }); @@ -435,7 +455,7 @@ describe("Password generator options builder", () => { }); it("should set `options.minNumber` to 0 when `options.number` is false", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number: false }); @@ -447,7 +467,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.minNumber` (= %i) to the minimum it is less than the minimum number", (minNumber) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = 5; // arbitrary value greater than minNumber expect(minNumber).toBeLessThan(policy.numberCount); @@ -463,7 +483,7 @@ describe("Password generator options builder", () => { it.each([1, 3, 5, 7, 9])( "should not change `options.minNumber` (= %i) when it is within the boundaries", (minNumber) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min); expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max); @@ -479,7 +499,7 @@ describe("Password generator options builder", () => { it.each([10, 20, 400])( "should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded", (minNumber) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minNumber).toBeGreaterThan(builder.minDigits.max); @@ -501,7 +521,7 @@ describe("Password generator options builder", () => { ])( "should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0", (expectedSpecial, minSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, minSpecial }); @@ -512,7 +532,7 @@ describe("Password generator options builder", () => { ); it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special: true }); @@ -522,7 +542,7 @@ describe("Password generator options builder", () => { }); it("should set `options.minSpecial` to 0 when `options.special` is false", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special: false }); @@ -534,7 +554,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters", (minSpecial) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.specialCount = 5; // arbitrary value greater than minSpecial expect(minSpecial).toBeLessThan(policy.specialCount); @@ -550,7 +570,7 @@ describe("Password generator options builder", () => { it.each([1, 3, 5, 7, 9])( "should not change `options.minSpecial` (= %i) when it is within the boundaries", (minSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min); expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max); @@ -566,7 +586,7 @@ describe("Password generator options builder", () => { it.each([10, 20, 400])( "should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded", (minSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max); @@ -579,7 +599,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", @@ -602,7 +622,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minLowercase === %i` when `options.lowercase` is %s", (expectedMinLowercase, lowercase) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ lowercase, ...defaultOptions }); @@ -618,7 +638,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minUppercase === %i` when `options.uppercase` is %s", (expectedMinUppercase, uppercase) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ uppercase, ...defaultOptions }); @@ -634,7 +654,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set", (expectedMinNumber, number) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ number, ...defaultOptions }); @@ -652,7 +672,7 @@ describe("Password generator options builder", () => { ])( "should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set", (expectedNumber, minNumber) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minNumber, ...defaultOptions }); @@ -668,7 +688,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set", (special, expectedMinSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ special, ...defaultOptions }); @@ -686,7 +706,7 @@ describe("Password generator options builder", () => { ])( "should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set", (minSpecial, expectedSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minSpecial, ...defaultOptions }); @@ -707,7 +727,7 @@ describe("Password generator options builder", () => { const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial; expect(sumOfMinimums).toBeLessThan(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minLowercase, @@ -732,7 +752,7 @@ describe("Password generator options builder", () => { (expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => { expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minLowercase, @@ -749,7 +769,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", diff --git a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts index 5d5430b8ca..7f8dce19b1 100644 --- a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts @@ -4,8 +4,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyId } from "@bitwarden/common/types/guid"; -import { Policies } from "../data"; - import { passwordLeastPrivilege } from "./password-least-privilege"; function createPolicy( @@ -22,21 +20,31 @@ function createPolicy( }); } +const disabledValue = Object.freeze({ + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, +}); + describe("passwordLeastPrivilege", () => { it("should return the accumulator when the policy type does not apply", () => { const policy = createPolicy({}, PolicyType.RequireSso); - const result = passwordLeastPrivilege(Policies.Password.disabledValue, policy); + const result = passwordLeastPrivilege(disabledValue, policy); - expect(result).toEqual(Policies.Password.disabledValue); + expect(result).toEqual(disabledValue); }); it("should return the accumulator when the policy is not enabled", () => { const policy = createPolicy({}, PolicyType.PasswordGenerator, false); - const result = passwordLeastPrivilege(Policies.Password.disabledValue, policy); + const result = passwordLeastPrivilege(disabledValue, policy); - expect(result).toEqual(Policies.Password.disabledValue); + expect(result).toEqual(disabledValue); }); it.each([ @@ -50,8 +58,8 @@ describe("passwordLeastPrivilege", () => { ])("should take the %p from the policy", (input, value, expected) => { const policy = createPolicy({ [input]: value }); - const result = passwordLeastPrivilege(Policies.Password.disabledValue, policy); + const result = passwordLeastPrivilege(disabledValue, policy); - expect(result).toEqual({ ...Policies.Password.disabledValue, [expected]: value }); + expect(result).toEqual({ ...disabledValue, [expected]: value }); }); }); diff --git a/libs/tools/generator/core/src/providers/credential-generator-providers.ts b/libs/tools/generator/core/src/providers/credential-generator-providers.ts new file mode 100644 index 0000000000..1e1a8345f3 --- /dev/null +++ b/libs/tools/generator/core/src/providers/credential-generator-providers.ts @@ -0,0 +1,14 @@ +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; + +import { GeneratorDependencyProvider } from "./generator-dependency-provider"; +import { GeneratorMetadataProvider } from "./generator-metadata-provider"; +import { GeneratorProfileProvider } from "./generator-profile-provider"; + +// FIXME: find a better way to manage common dependencies than smashing them all +// together into a mega-type. +export type CredentialGeneratorProviders = { + readonly userState: UserStateSubjectDependencyProvider; + readonly generator: GeneratorDependencyProvider; + readonly profile: GeneratorProfileProvider; + readonly metadata: GeneratorMetadataProvider; +}; diff --git a/libs/tools/generator/core/src/providers/credential-preferences.spec.ts b/libs/tools/generator/core/src/providers/credential-preferences.spec.ts new file mode 100644 index 0000000000..6fd747f382 --- /dev/null +++ b/libs/tools/generator/core/src/providers/credential-preferences.spec.ts @@ -0,0 +1,105 @@ +import { AlgorithmsByType, Type } from "../metadata"; +import { CredentialPreference } from "../types"; + +import { PREFERENCES } from "./credential-preferences"; + +const SomeCredentialPreferences: CredentialPreference = Object.freeze({ + email: Object.freeze({ + algorithm: AlgorithmsByType[Type.email][0], + updated: new Date(0), + }), + password: Object.freeze({ + algorithm: AlgorithmsByType[Type.password][0], + updated: new Date(0), + }), + username: Object.freeze({ + algorithm: AlgorithmsByType[Type.username][0], + updated: new Date(0), + }), +}); + +describe("PREFERENCES", () => { + describe("deserializer", () => { + it.each([[null], [undefined]])("creates new preferences (= %p)", (value) => { + // this case tests what happens when the type system is bypassed + const result = PREFERENCES.deserializer(value!); + + expect(result).toMatchObject({ + email: { + algorithm: AlgorithmsByType[Type.email][0], + }, + password: { + algorithm: AlgorithmsByType[Type.password][0], + }, + username: { + algorithm: AlgorithmsByType[Type.username][0], + }, + }); + }); + + it("fills missing password preferences", () => { + const input: any = structuredClone(SomeCredentialPreferences); + delete input.password; + + const result = PREFERENCES.deserializer(input); + + expect(result).toMatchObject({ + password: { + algorithm: AlgorithmsByType[Type.password][0], + }, + }); + }); + + it("fills missing email preferences", () => { + const input: any = structuredClone(SomeCredentialPreferences); + delete input.email; + + const result = PREFERENCES.deserializer(input); + + expect(result).toMatchObject({ + email: { + algorithm: AlgorithmsByType[Type.email][0], + }, + }); + }); + + it("fills missing username preferences", () => { + const input: any = structuredClone(SomeCredentialPreferences); + delete input.username; + + const result = PREFERENCES.deserializer(input); + + expect(result).toMatchObject({ + username: { + algorithm: AlgorithmsByType[Type.username][0], + }, + }); + }); + + it("converts string fields to Dates", () => { + const input: any = structuredClone(SomeCredentialPreferences); + input.email.updated = "1970-01-01T00:00:00.100Z"; + input.password.updated = "1970-01-01T00:00:00.200Z"; + input.username.updated = "1970-01-01T00:00:00.300Z"; + + const result = PREFERENCES.deserializer(input); + + expect(result?.email.updated).toEqual(new Date(100)); + expect(result?.password.updated).toEqual(new Date(200)); + expect(result?.username.updated).toEqual(new Date(300)); + }); + + it("converts number fields to Dates", () => { + const input: any = structuredClone(SomeCredentialPreferences); + input.email.updated = 100; + input.password.updated = 200; + input.username.updated = 300; + + const result = PREFERENCES.deserializer(input); + + expect(result?.email.updated).toEqual(new Date(100)); + expect(result?.password.updated).toEqual(new Date(200)); + expect(result?.username.updated).toEqual(new Date(300)); + }); + }); +}); diff --git a/libs/tools/generator/core/src/providers/credential-preferences.ts b/libs/tools/generator/core/src/providers/credential-preferences.ts new file mode 100644 index 0000000000..5c6efd6008 --- /dev/null +++ b/libs/tools/generator/core/src/providers/credential-preferences.ts @@ -0,0 +1,28 @@ +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { AlgorithmsByType, CredentialType } from "../metadata"; +import { CredentialPreference } from "../types"; + +/** plaintext password generation options */ +export const PREFERENCES = new UserKeyDefinition( + GENERATOR_DISK, + "credentialPreferences", + { + deserializer: (value) => { + const result = (value as any) ?? {}; + + for (const key in AlgorithmsByType) { + const type = key as CredentialType; + if (result[type]) { + result[type].updated = new Date(result[type].updated); + } else { + const [algorithm] = AlgorithmsByType[type]; + result[type] = { algorithm, updated: new Date() }; + } + } + + return result; + }, + clearOn: ["logout"], + }, +); diff --git a/libs/tools/generator/core/src/providers/generator-dependency-provider.ts b/libs/tools/generator/core/src/providers/generator-dependency-provider.ts new file mode 100644 index 0000000000..14942698cd --- /dev/null +++ b/libs/tools/generator/core/src/providers/generator-dependency-provider.ts @@ -0,0 +1,12 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { RestClient } from "@bitwarden/common/tools/integration/rpc"; + +import { Randomizer } from "../abstractions"; + +export type GeneratorDependencyProvider = { + randomizer: Randomizer; + client: RestClient; + // FIXME: introduce `I18nKeyOrLiteral` into forwarder + // structures and remove this dependency + i18nService: I18nService; +}; diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts similarity index 98% rename from libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts rename to libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts index 37a987f88b..71fced46fa 100644 --- a/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts @@ -75,6 +75,7 @@ const SystemProvider = { } as LegacyEncryptorProvider, state: SomeStateProvider, log: disabledSemanticLoggerProvider, + now: Date.now, } as UserStateSubjectDependencyProvider; const SomeSiteId: SiteId = Site.forwarder; @@ -415,14 +416,14 @@ describe("GeneratorMetadataProvider", () => { await expect(firstValueFrom(result)).resolves.toEqual(plusAddress.id); }); - it("emits undefined when the user's preference is unavailable and there is no metadata", async () => { + it("emits the original preference when the user's preference is unavailable and there is no metadata", async () => { SomePolicyService.policiesByType$.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(); + await expect(firstValueFrom(result)).resolves.toEqual(preferences[Type.email].algorithm); }); }); diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.ts b/libs/tools/generator/core/src/providers/generator-metadata-provider.ts similarity index 87% rename from libs/tools/generator/core/src/services/generator-metadata-provider.ts rename to libs/tools/generator/core/src/providers/generator-metadata-provider.ts index 161f7192c3..5290154502 100644 --- a/libs/tools/generator/core/src/services/generator-metadata-provider.ts +++ b/libs/tools/generator/core/src/providers/generator-metadata-provider.ts @@ -1,12 +1,4 @@ -import { - Observable, - combineLatestWith, - distinctUntilChanged, - map, - shareReplay, - switchMap, - takeUntil, -} from "rxjs"; +import { Observable, distinctUntilChanged, map, shareReplay, switchMap, takeUntil } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; @@ -14,7 +6,7 @@ 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 { anyComplete, memoizedMap, 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"; @@ -29,7 +21,7 @@ import { Algorithms, Types, } from "../metadata"; -import { availableAlgorithms_vNext } from "../policies/available-algorithms-policy"; +import { AvailableAlgorithmsConstraint, availableAlgorithms } from "../policies"; import { CredentialPreference } from "../types"; import { AlgorithmRequest, @@ -148,8 +140,15 @@ export class GeneratorMetadataProvider { const policies$ = this.application.policy .policiesByType$(PolicyType.PasswordGenerator, id) .pipe( - map((p) => availableAlgorithms_vNext(p).filter((a) => this._metadata.has(a))), - map((p) => new Set(p)), + map((p) => + availableAlgorithms(p) + .filter((a) => this._metadata.has(a)) + .sort(), + ), + // interning the set transformation lets `distinctUntilChanged()` eliminate + // repeating policy emissions using reference equality + memoizedMap((a) => new Set(a), { key: (a) => a.join(":") }), + distinctUntilChanged(), // complete policy emissions otherwise `switchMap` holds `available$` open indefinitely takeUntil(anyComplete(id$)), ); @@ -211,24 +210,7 @@ export class GeneratorMetadataProvider { 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; - }), + map((preferences) => preferences[type].algorithm), distinctUntilChanged(), ); @@ -246,8 +228,16 @@ export class GeneratorMetadataProvider { preferences( dependencies: BoundDependency<"account", Account>, ): UserStateSubject { - // FIXME: enforce policy - const subject = new UserStateSubject(PREFERENCES, this.system, dependencies); + const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); + + const constraints$ = this.isAvailable$({ account$ }).pipe( + map( + (isAvailable) => + new AvailableAlgorithmsConstraint(this.algorithms.bind(this), isAvailable, this.system), + ), + ); + + const subject = new UserStateSubject(PREFERENCES, this.system, { account$, constraints$ }); return subject; } diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts similarity index 99% rename from libs/tools/generator/core/src/services/generator-profile-provider.spec.ts rename to libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts index aeb1a648a1..1053834eca 100644 --- a/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts @@ -67,6 +67,7 @@ const dependencyProvider: UserStateSubjectDependencyProvider = { encryptor: encryptorProvider, state: stateProvider, log: disabledSemanticLoggerProvider, + now: Date.now, }; // settings storage location diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.ts b/libs/tools/generator/core/src/providers/generator-profile-provider.ts similarity index 94% rename from libs/tools/generator/core/src/services/generator-profile-provider.ts rename to libs/tools/generator/core/src/providers/generator-profile-provider.ts index 7088e23d3f..4117d1f2a7 100644 --- a/libs/tools/generator/core/src/services/generator-profile-provider.ts +++ b/libs/tools/generator/core/src/providers/generator-profile-provider.ts @@ -19,6 +19,7 @@ import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/stat import { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "../metadata"; import { GeneratorConstraints } from "../types/generator-constraints"; +import { equivalent } from "../util"; /** Surfaces contextual information to credential generators */ export class GeneratorProfileProvider { @@ -99,7 +100,10 @@ export class GeneratorProfileProvider { const constraints$ = policies$.pipe( map((policies) => profile.constraints.create(policies, context)), - tap(() => this.log.debug("constraints created")), + distinctUntilChanged((previous, next) => { + return equivalent(previous, next); + }), + tap((constraints) => this.log.debug(constraints as object, "constraints updated")), ); return constraints$; diff --git a/libs/tools/generator/core/src/providers/index.ts b/libs/tools/generator/core/src/providers/index.ts new file mode 100644 index 0000000000..bad56b746b --- /dev/null +++ b/libs/tools/generator/core/src/providers/index.ts @@ -0,0 +1,4 @@ +export { CredentialGeneratorProviders } from "./credential-generator-providers"; +export { GeneratorMetadataProvider } from "./generator-metadata-provider"; +export { GeneratorProfileProvider } from "./generator-profile-provider"; +export { GeneratorDependencyProvider } from "./generator-dependency-provider"; diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts index 44d23ef1c5..ab907b6455 100644 --- a/libs/tools/generator/core/src/rx.ts +++ b/libs/tools/generator/core/src/rx.ts @@ -18,20 +18,6 @@ export function mapPolicyToEvaluator( ); } -/** Maps an administrative console policy to constraints using the provided configuration. - * @param configuration the configuration that constructs the constraints. - */ -export function mapPolicyToConstraints( - configuration: PolicyConfiguration, - email: string, -) { - return pipe( - reduceCollection(configuration.combine, configuration.disabledValue), - distinctIfShallowMatch(), - map((policy) => configuration.toConstraints(policy, email)), - ); -} - /** Constructs a method that maps a policy to the default (no-op) policy. */ export function newDefaultEvaluator() { return () => { diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts deleted file mode 100644 index 2bc8d51487..0000000000 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ /dev/null @@ -1,1050 +0,0 @@ -// FIXME: remove ts-strict-ignore once `FakeAccountService` implements ts strict support -// @ts-strict-ignore -import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, map, Subject } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; -import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; -import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction"; -import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; -import { StateConstraints } from "@bitwarden/common/tools/types"; -import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; - -import { - FakeStateProvider, - FakeAccountService, - awaitAsync, - ObservableTracker, -} from "../../../../../common/spec"; -import { Randomizer } from "../abstractions"; -import { Generators } from "../data"; -import { - CredentialGeneratorConfiguration, - GeneratedCredential, - GenerateRequest, - GeneratorConstraints, -} from "../types"; - -import { CredentialGeneratorService } from "./credential-generator.service"; - -// arbitrary settings types -type SomeSettings = { foo: string }; -type SomePolicy = { fooPolicy: boolean }; - -// settings storage location -const SettingsKey = new UserKeyDefinition(GENERATOR_DISK, "SomeSettings", { - deserializer: (value) => value, - clearOn: [], -}); - -// fake policies -const policyService = mock(); -const somePolicy = new Policy({ - data: { fooPolicy: true }, - type: PolicyType.PasswordGenerator, - id: "" as PolicyId, - organizationId: "" as OrganizationId, - enabled: true, -}); -const passwordOverridePolicy = new Policy({ - id: "" as PolicyId, - organizationId: "", - type: PolicyType.PasswordGenerator, - data: { - overridePasswordType: "password", - }, - enabled: true, -}); - -const passphraseOverridePolicy = new Policy({ - id: "" as PolicyId, - organizationId: "", - type: PolicyType.PasswordGenerator, - data: { - overridePasswordType: "passphrase", - }, - enabled: true, -}); - -const SomeTime = new Date(1); -const SomeAlgorithm = "passphrase"; -const SomeCategory = "password"; -const SomeNameKey = "passphraseKey"; -const SomeGenerateKey = "generateKey"; -const SomeCredentialTypeKey = "credentialTypeKey"; -const SomeOnGeneratedMessageKey = "onGeneratedMessageKey"; -const SomeCopyKey = "copyKey"; -const SomeUseGeneratedValueKey = "useGeneratedValueKey"; - -// fake the configuration -const SomeConfiguration: CredentialGeneratorConfiguration = { - id: SomeAlgorithm, - category: SomeCategory, - nameKey: SomeNameKey, - generateKey: SomeGenerateKey, - onGeneratedMessageKey: SomeOnGeneratedMessageKey, - credentialTypeKey: SomeCredentialTypeKey, - copyKey: SomeCopyKey, - useGeneratedValueKey: SomeUseGeneratedValueKey, - onlyOnRequest: false, - request: [], - engine: { - create: (_randomizer) => { - return { - generate: (request, settings) => { - const result = new GeneratedCredential( - settings.foo, - SomeAlgorithm, - SomeTime, - request.source, - request.website, - ); - return Promise.resolve(result); - }, - }; - }, - }, - settings: { - initial: { foo: "initial" }, - constraints: { foo: {} }, - account: SettingsKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: { - fooPolicy: false, - }, - combine: (acc, policy) => { - return { fooPolicy: acc.fooPolicy || policy.data.fooPolicy }; - }, - createEvaluator: () => { - throw new Error("this should never be called"); - }, - toConstraints: (policy) => { - if (policy.fooPolicy) { - return { - constraints: { - policyInEffect: true, - }, - calibrate(state: SomeSettings) { - return { - constraints: {}, - adjust(state: SomeSettings) { - return { foo: `adjusted(${state.foo})` }; - }, - fix(state: SomeSettings) { - return { foo: `fixed(${state.foo})` }; - }, - } satisfies StateConstraints; - }, - } satisfies GeneratorConstraints; - } else { - return { - constraints: { - policyInEffect: false, - }, - adjust(state: SomeSettings) { - return state; - }, - fix(state: SomeSettings) { - return state; - }, - } satisfies GeneratorConstraints; - } - }, - }, -}; - -// fake user information -const SomeUser = "SomeUser" as UserId; -const AnotherUser = "SomeOtherUser" as UserId; -const accounts = { - [SomeUser]: { - id: SomeUser, - name: "some user", - email: "some.user@example.com", - emailVerified: true, - }, - [AnotherUser]: { - id: AnotherUser, - name: "some other user", - email: "some.other.user@example.com", - emailVerified: true, - }, -}; -const accountService = new FakeAccountService(accounts); - -// fake state -const stateProvider = new FakeStateProvider(accountService); - -// fake randomizer -const randomizer = mock(); - -const i18nService = mock(); - -const apiService = mock(); - -const encryptor = mock(); -const encryptorProvider = mock({ - userEncryptor$(_, dependencies) { - return dependencies.singleUserId$.pipe(map((userId) => ({ userId, encryptor }))); - }, -}); - -const account$ = new BehaviorSubject(accounts[SomeUser]); - -const providers = { - encryptor: encryptorProvider, - state: stateProvider, - log: disabledSemanticLoggerProvider, -}; - -describe("CredentialGeneratorService", () => { - beforeEach(async () => { - await accountService.switchAccount(SomeUser); - policyService.policiesByType$.mockImplementation(() => new BehaviorSubject([]).asObservable()); - i18nService.t.mockImplementation((key: string) => key); - apiService.fetch.mockImplementation(() => Promise.resolve(mock())); - jest.clearAllMocks(); - }); - - describe("generate$", () => { - it("completes when `on$` completes", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - let complete = false; - - // confirm no emission during subscription - generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({ - complete: () => { - complete = true; - }, - }); - on$.complete(); - await awaitAsync(); - - expect(complete).toBeTruthy(); - }); - - it("includes request.source in the generated credential", async () => { - const settings = { foo: "value" }; - await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new BehaviorSubject({ source: "some source" }); - const generated = new ObservableTracker( - generator.generate$(SomeConfiguration, { on$, account$ }), - ); - - const result = await generated.expectEmission(); - - expect(result.source).toEqual("some source"); - }); - - it("includes request.website in the generated credential", async () => { - const settings = { foo: "value" }; - await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new BehaviorSubject({ website: "some website" }); - const generated = new ObservableTracker( - generator.generate$(SomeConfiguration, { on$, account$ }), - ); - - const result = await generated.expectEmission(); - - expect(result.website).toEqual("some website"); - }); - - // FIXME: test these when the fake state provider can create the required emissions - it.todo("errors when the settings error"); - it.todo("completes when the settings complete"); - - it("emits a generation for a specific user when `user$` supplied", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable(); - const on$ = new Subject(); - const generated = new ObservableTracker( - generator.generate$(SomeConfiguration, { on$, account$ }), - ); - on$.next({}); - - const result = await generated.expectEmission(); - - expect(result).toEqual(new GeneratedCredential("another", SomeAlgorithm, SomeTime)); - }); - - it("errors when `user$` errors", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - const account$ = new BehaviorSubject(accounts[SomeUser]); - let error = null; - - generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({ - error: (e: unknown) => { - error = e; - }, - }); - account$.error({ some: "error" }); - await awaitAsync(); - - expect(error).toEqual({ some: "error" }); - }); - - it("completes when `user$` completes", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - const account$ = new BehaviorSubject(accounts[SomeUser]); - let completed = false; - - generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({ - complete: () => { - completed = true; - }, - }); - account$.complete(); - await awaitAsync(); - - expect(completed).toBeTruthy(); - }); - - it("emits a generation only when `on$` emits", async () => { - // This test breaks from arrange/act/assert because it is testing causality - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - const results: any[] = []; - - // confirm no emission during subscription - const sub = generator - .generate$(SomeConfiguration, { on$, account$ }) - .subscribe((result) => results.push(result)); - await awaitAsync(); - expect(results.length).toEqual(0); - - // confirm forwarded emission - on$.next({}); - await awaitAsync(); - expect(results).toEqual([new GeneratedCredential("value", SomeAlgorithm, SomeTime)]); - - // confirm updating settings does not cause an emission - await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser); - await awaitAsync(); - expect(results.length).toBe(1); - - // confirm forwarded emission takes latest value - on$.next({}); - await awaitAsync(); - sub.unsubscribe(); - - expect(results).toEqual([ - new GeneratedCredential("value", SomeAlgorithm, SomeTime), - new GeneratedCredential("next", SomeAlgorithm, SomeTime), - ]); - }); - - it("errors when `on$` errors", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - let error: any = null; - - // confirm no emission during subscription - generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({ - error: (e: unknown) => { - error = e; - }, - }); - on$.error({ some: "error" }); - await awaitAsync(); - - expect(error).toEqual({ some: "error" }); - }); - - // FIXME: test these when the fake state provider can delay its first emission - it.todo("emits when settings$ become available if on$ is called before they're ready."); - }); - - describe("algorithms", () => { - it("outputs password generation metadata", () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = generator.algorithms("password"); - - expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy(); - - // this test shouldn't contain entries outside of the current category - expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy(); - expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy(); - }); - - it("outputs username generation metadata", () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = generator.algorithms("username"); - - expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); - - // this test shouldn't contain entries outside of the current category - expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy(); - expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); - }); - - it("outputs email generation metadata", () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = generator.algorithms("email"); - - expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); - - // this test shouldn't contain entries outside of the current category - expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy(); - expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); - }); - - it("combines metadata across categories", () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = generator.algorithms(["username", "email"]); - - expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); - - // this test shouldn't contain entries outside of the current categories - expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); - }); - }); - - describe("algorithms$", () => { - // these tests cannot use the observable tracker because they return - // data that cannot be cloned - it("returns password metadata", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.algorithms$("password", { account$ })); - - expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy(); - }); - - it("returns username metadata", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.algorithms$("username", { account$ })); - - expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); - }); - - it("returns email metadata", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.algorithms$("email", { account$ })); - - expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); - }); - - it("returns username and email metadata", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom( - generator.algorithms$(["username", "email"], { account$ }), - ); - - expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); - }); - - // Subsequent tests focus on passwords and passphrases as an example of policy - // awareness; they exercise the logic without being comprehensive - it("enforces the active user's policy", async () => { - const policy$ = new BehaviorSubject([passwordOverridePolicy]); - policyService.policiesByType$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.algorithms$(["password"], { account$ })); - - expect(policyService.policiesByType$).toHaveBeenCalledWith( - PolicyType.PasswordGenerator, - SomeUser, - ); - expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.passphrase.id)).toBeFalsy(); - }); - - it("follows changes to the active user", async () => { - const account$ = new BehaviorSubject(accounts[SomeUser]); - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passphraseOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const results: any = []; - const sub = generator.algorithms$("password", { account$ }).subscribe((r) => results.push(r)); - - account$.next(accounts[AnotherUser]); - await awaitAsync(); - sub.unsubscribe(); - - const [someResult, anotherResult] = results; - - expect(policyService.policiesByType$).toHaveBeenNthCalledWith( - 1, - PolicyType.PasswordGenerator, - SomeUser, - ); - expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); - expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); - - expect(policyService.policiesByType$).toHaveBeenNthCalledWith( - 2, - PolicyType.PasswordGenerator, - AnotherUser, - ); - expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy(); - expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy(); - }); - - it("reads an arbitrary user's settings", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable(); - - const result = await firstValueFrom(generator.algorithms$("password", { account$ })); - - expect(policyService.policiesByType$).toHaveBeenCalledWith( - PolicyType.PasswordGenerator, - AnotherUser, - ); - expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); - expect(result.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); - }); - - it("follows changes to the arbitrary user", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passphraseOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - const results: any = []; - const sub = generator.algorithms$("password", { account$ }).subscribe((r) => results.push(r)); - - account.next(accounts[AnotherUser]); - await awaitAsync(); - sub.unsubscribe(); - - const [someResult, anotherResult] = results; - expect(policyService.policiesByType$).toHaveBeenCalledWith( - PolicyType.PasswordGenerator, - SomeUser, - ); - expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); - expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); - - expect(policyService.policiesByType$).toHaveBeenCalledWith( - PolicyType.PasswordGenerator, - AnotherUser, - ); - expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy(); - expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy(); - }); - - it("errors when the arbitrary user's stream errors", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let error = null; - - generator.algorithms$("password", { account$ }).subscribe({ - error: (e: unknown) => { - error = e; - }, - }); - account.error({ some: "error" }); - await awaitAsync(); - - expect(error).toEqual({ some: "error" }); - }); - - it("completes when the arbitrary user's stream completes", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let completed = false; - - generator.algorithms$("password", { account$ }).subscribe({ - complete: () => { - completed = true; - }, - }); - account.complete(); - await awaitAsync(); - - expect(completed).toBeTruthy(); - }); - - it("ignores repeated arbitrary user emissions", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let count = 0; - - const sub = generator.algorithms$("password", { account$ }).subscribe({ - next: () => { - count++; - }, - }); - await awaitAsync(); - account.next(accounts[SomeUser]); - await awaitAsync(); - account.next(accounts[SomeUser]); - await awaitAsync(); - sub.unsubscribe(); - - expect(count).toEqual(1); - }); - }); - - describe("settings$", () => { - it("defaults to the configuration's initial settings if settings aren't found", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ })); - - expect(result).toEqual(SomeConfiguration.settings.initial); - }); - - it("reads from the active user's configuration-defined storage", async () => { - const settings = { foo: "value" }; - await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ })); - - expect(result).toEqual(settings); - }); - - it("applies policy to the loaded settings", async () => { - const settings = { foo: "value" }; - await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const policy$ = new BehaviorSubject([somePolicy]); - policyService.policiesByType$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ })); - - expect(result).toEqual({ foo: "adjusted(value)" }); - }); - - it("reads an arbitrary user's settings", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const anotherSettings = { foo: "another" }; - await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable(); - - const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ })); - - expect(result).toEqual(anotherSettings); - }); - - it("errors when the arbitrary user's stream errors", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let error = null; - - generator.settings$(SomeConfiguration, { account$ }).subscribe({ - error: (e: unknown) => { - error = e; - }, - }); - account.error({ some: "error" }); - await awaitAsync(); - - expect(error).toEqual({ some: "error" }); - }); - - it("completes when the arbitrary user's stream completes", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let completed = false; - - generator.settings$(SomeConfiguration, { account$ }).subscribe({ - complete: () => { - completed = true; - }, - }); - account.complete(); - await awaitAsync(); - - expect(completed).toBeTruthy(); - }); - }); - - describe("settings", () => { - it("writes to the user's state", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const subject = generator.settings(SomeConfiguration, { account$ }); - - subject.next({ foo: "next value" }); - await awaitAsync(); - const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser)); - - expect(result).toEqual({ - foo: "next value", - }); - }); - }); - - describe("policy$", () => { - it("creates constraints without policy in effect when there is no policy", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); - - const result = await firstValueFrom(generator.policy$(SomeConfiguration, { account$ })); - - expect(result.constraints.policyInEffect).toBeFalsy(); - }); - - it("creates constraints with policy in effect when there is a policy", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); - const policy$ = new BehaviorSubject([somePolicy]); - policyService.policiesByType$.mockReturnValue(policy$); - - const result = await firstValueFrom(generator.policy$(SomeConfiguration, { account$ })); - - expect(result.constraints.policyInEffect).toBeTruthy(); - }); - - it("follows policy emissions", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - const somePolicySubject = new BehaviorSubject([somePolicy]); - policyService.policiesByType$.mockReturnValueOnce(somePolicySubject.asObservable()); - const emissions: GeneratorConstraints[] = []; - const sub = generator - .policy$(SomeConfiguration, { account$ }) - .subscribe((policy) => emissions.push(policy)); - - // swap the active policy for an inactive policy - somePolicySubject.next([]); - await awaitAsync(); - sub.unsubscribe(); - const [someResult, anotherResult] = emissions; - - expect(someResult.constraints.policyInEffect).toBeTruthy(); - expect(anotherResult.constraints.policyInEffect).toBeFalsy(); - }); - - it("follows user emissions", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); - const anotherPolicy$ = new BehaviorSubject([]).asObservable(); - policyService.policiesByType$ - .mockReturnValueOnce(somePolicy$) - .mockReturnValueOnce(anotherPolicy$); - const emissions: GeneratorConstraints[] = []; - const sub = generator - .policy$(SomeConfiguration, { account$ }) - .subscribe((policy) => emissions.push(policy)); - - // swapping the user invokes the return for `anotherPolicy$` - account.next(accounts[AnotherUser]); - await awaitAsync(); - sub.unsubscribe(); - const [someResult, anotherResult] = emissions; - - expect(someResult.constraints.policyInEffect).toBeTruthy(); - expect(anotherResult.constraints.policyInEffect).toBeFalsy(); - }); - - it("errors when the user errors", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - const expectedError = { some: "error" }; - - let actualError: any = null; - generator.policy$(SomeConfiguration, { account$ }).subscribe({ - error: (e: unknown) => { - actualError = e; - }, - }); - account.error(expectedError); - await awaitAsync(); - - expect(actualError).toEqual(expectedError); - }); - - it("completes when the user completes", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - - let completed = false; - generator.policy$(SomeConfiguration, { account$ }).subscribe({ - complete: () => { - completed = true; - }, - }); - account.complete(); - await awaitAsync(); - - expect(completed).toBeTruthy(); - }); - }); -}); diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts deleted file mode 100644 index eacc2ca6fc..0000000000 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ /dev/null @@ -1,296 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { concatMap, distinctUntilChanged, map, Observable, switchMap, takeUntil } from "rxjs"; -import { Simplify } from "type-fest"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { BoundDependency, OnDependency } from "@bitwarden/common/tools/dependencies"; -import { IntegrationMetadata } from "@bitwarden/common/tools/integration"; -import { RestClient } from "@bitwarden/common/tools/integration/rpc"; -import { anyComplete, withLatestReady } 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 { Randomizer } from "../abstractions"; -import { - Generators, - getForwarderConfiguration, - Integrations, - toCredentialGeneratorConfiguration, -} from "../data"; -import { availableAlgorithms } from "../policies/available-algorithms-policy"; -import { mapPolicyToConstraints } from "../rx"; -import { - CredentialAlgorithm, - CredentialCategories, - CredentialCategory, - AlgorithmInfo, - CredentialPreference, - isForwarderIntegration, - ForwarderIntegration, - GenerateRequest, -} from "../types"; -import { - CredentialGeneratorConfiguration as Configuration, - CredentialGeneratorInfo, - GeneratorDependencyProvider, -} from "../types/credential-generator-configuration"; -import { GeneratorConstraints } from "../types/generator-constraints"; - -import { PREFERENCES } from "./credential-preferences"; - -type Generate$Dependencies = Simplify< - OnDependency & BoundDependency<"account", Account> ->; - -export class CredentialGeneratorService { - constructor( - private readonly randomizer: Randomizer, - private readonly policyService: PolicyService, - private readonly apiService: ApiService, - private readonly i18nService: I18nService, - private readonly providers: UserStateSubjectDependencyProvider, - ) {} - - private getDependencyProvider(): GeneratorDependencyProvider { - return { - client: new RestClient(this.apiService, this.i18nService), - i18nService: this.i18nService, - randomizer: this.randomizer, - }; - } - - // FIXME: the rxjs methods of this service can be a lot more resilient if - // `Subjects` are introduced where sharing occurs - - /** Generates a stream of credentials - * @param configuration determines which generator's settings are loaded - * @param dependencies.on$ Required. A new credential is emitted when this emits. - */ - generate$( - configuration: Readonly>, - dependencies: Generate$Dependencies, - ) { - const engine = configuration.engine.create(this.getDependencyProvider()); - const settings$ = this.settings$(configuration, dependencies); - - // generation proper - const generate$ = dependencies.on$.pipe( - withLatestReady(settings$), - concatMap(([request, settings]) => engine.generate(request, settings)), - takeUntil(anyComplete([settings$])), - ); - - return generate$; - } - - /** Emits metadata concerning the provided generation algorithms - * @param category the category or categories of interest - * @param dependences.account$ algorithms are filtered to only - * those matching the provided account's policy. - * @returns An observable that emits algorithm metadata. - */ - algorithms$( - category: CredentialCategory, - dependencies: BoundDependency<"account", Account>, - ): Observable; - algorithms$( - category: CredentialCategory[], - dependencies: BoundDependency<"account", Account>, - ): Observable; - algorithms$( - category: CredentialCategory | CredentialCategory[], - dependencies: BoundDependency<"account", Account>, - ) { - // any cast required here because TypeScript fails to bind `category` - // to the union-typed overload of `algorithms`. - const algorithms = this.algorithms(category as any); - - // apply policy - const algorithms$ = dependencies.account$.pipe( - distinctUntilChanged(), - switchMap((account) => { - const policies$ = this.policyService - .policiesByType$(PolicyType.PasswordGenerator, account.id) - .pipe( - map((p) => new Set(availableAlgorithms(p))), - // complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely - takeUntil(anyComplete(dependencies.account$)), - ); - return policies$; - }), - map((available) => { - const filtered = algorithms.filter( - (c) => isForwarderIntegration(c.id) || available.has(c.id), - ); - return filtered; - }), - ); - - return algorithms$; - } - - /** Lists metadata for the algorithms in a credential category - * @param category the category or categories of interest - * @returns A list containing the requested metadata. - */ - algorithms(category: CredentialCategory): AlgorithmInfo[]; - algorithms(category: CredentialCategory[]): AlgorithmInfo[]; - algorithms(category: CredentialCategory | CredentialCategory[]): AlgorithmInfo[] { - const categories: CredentialCategory[] = Array.isArray(category) ? category : [category]; - - const algorithms = categories - .flatMap((c) => CredentialCategories[c] as CredentialAlgorithm[]) - .map((id) => this.algorithm(id)) - .filter((info) => info !== null); - - const forwarders = Object.keys(Integrations) - .map((key: keyof typeof Integrations) => { - const forwarder: ForwarderIntegration = { forwarder: Integrations[key].id }; - return this.algorithm(forwarder); - }) - .filter((forwarder) => categories.includes(forwarder.category)); - - return algorithms.concat(forwarders); - } - - /** Look up the metadata for a specific generator algorithm - * @param id identifies the algorithm - * @returns the requested metadata, or `null` if the metadata wasn't found. - */ - algorithm(id: CredentialAlgorithm): AlgorithmInfo { - let generator: CredentialGeneratorInfo = null; - let integration: IntegrationMetadata = null; - - if (isForwarderIntegration(id)) { - const forwarderConfig = getForwarderConfiguration(id.forwarder); - integration = forwarderConfig; - - if (forwarderConfig) { - generator = toCredentialGeneratorConfiguration(forwarderConfig); - } - } else { - generator = Generators[id]; - } - - if (!generator) { - throw new Error(`Invalid credential algorithm: ${JSON.stringify(id)}`); - } - - const info: AlgorithmInfo = { - id: generator.id, - category: generator.category, - name: integration ? integration.name : this.i18nService.t(generator.nameKey), - generate: this.i18nService.t(generator.generateKey), - onGeneratedMessage: this.i18nService.t(generator.onGeneratedMessageKey), - credentialType: this.i18nService.t(generator.credentialTypeKey), - copy: this.i18nService.t(generator.copyKey), - useGeneratedValue: this.i18nService.t(generator.useGeneratedValueKey), - onlyOnRequest: generator.onlyOnRequest, - request: generator.request, - }; - - if (generator.descriptionKey) { - info.description = this.i18nService.t(generator.descriptionKey); - } - - return info; - } - - /** Get the settings for the provided configuration - * @param configuration determines which generator's settings are loaded - * @param dependencies.account$ identifies the account to which the settings are bound. - * @returns an observable that emits settings - * @remarks the observable enforces policies on the settings - */ - settings$( - configuration: Configuration, - dependencies: BoundDependency<"account", Account>, - ) { - const constraints$ = this.policy$(configuration, dependencies); - - const settings = new UserStateSubject(configuration.settings.account, this.providers, { - constraints$, - account$: dependencies.account$, - }); - - const settings$ = settings.pipe( - map((settings) => settings ?? structuredClone(configuration.settings.initial)), - ); - - return settings$; - } - - /** 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 { - // FIXME: enforce policy - const subject = new UserStateSubject(PREFERENCES, this.providers, dependencies); - - return subject; - } - - /** Get a subject bound to a specific user's settings - * @param configuration determines which generator's settings are loaded - * @param dependencies.account$ identifies the account to which the settings are bound - * @returns a subject bound to the requested user's generator settings - * @remarks the subject enforces policy for the settings - */ - settings( - configuration: Readonly>, - dependencies: BoundDependency<"account", Account>, - ) { - const constraints$ = this.policy$(configuration, dependencies); - - const subject = new UserStateSubject(configuration.settings.account, this.providers, { - constraints$, - account$: dependencies.account$, - }); - - return subject; - } - - /** Get the policy constraints for the provided configuration - * @param dependencies.account$ determines which user's policy is loaded - * @returns an observable that emits the policy once `dependencies.account$` - * and the policy become available. - */ - policy$( - configuration: Configuration, - dependencies: BoundDependency<"account", Account>, - ): Observable> { - const constraints$ = dependencies.account$.pipe( - map((account) => { - if (account.emailVerified) { - return { userId: account.id, email: account.email }; - } - - return { userId: account.id, email: null }; - }), - switchMap(({ userId, email }) => { - // complete policy emissions otherwise `switchMap` holds `policies$` open indefinitely - const policies$ = this.policyService - .policiesByType$(configuration.policy.type, userId) - .pipe( - mapPolicyToConstraints(configuration.policy, email), - takeUntil(anyComplete(dependencies.account$)), - ); - return policies$; - }), - ); - - return constraints$; - } -} diff --git a/libs/tools/generator/core/src/services/credential-preferences.spec.ts b/libs/tools/generator/core/src/services/credential-preferences.spec.ts deleted file mode 100644 index fc7c3e1bbc..0000000000 --- a/libs/tools/generator/core/src/services/credential-preferences.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { DefaultCredentialPreferences } from "../data"; - -import { PREFERENCES } from "./credential-preferences"; - -describe("PREFERENCES", () => { - describe("deserializer", () => { - it.each([[null], [undefined]])("creates new preferences (= %p)", (value) => { - const result = PREFERENCES.deserializer(value); - - expect(result).toEqual(DefaultCredentialPreferences); - }); - - it("fills missing password preferences", () => { - const input = { ...DefaultCredentialPreferences }; - delete input.password; - - const result = PREFERENCES.deserializer(input as any); - - expect(result).toEqual(DefaultCredentialPreferences); - }); - - it("fills missing email preferences", () => { - const input = { ...DefaultCredentialPreferences }; - delete input.email; - - const result = PREFERENCES.deserializer(input as any); - - expect(result).toEqual(DefaultCredentialPreferences); - }); - - it("fills missing username preferences", () => { - const input = { ...DefaultCredentialPreferences }; - delete input.username; - - const result = PREFERENCES.deserializer(input as any); - - expect(result).toEqual(DefaultCredentialPreferences); - }); - - it("converts updated fields to Dates", () => { - const input = structuredClone(DefaultCredentialPreferences); - input.email.updated = "1970-01-01T00:00:00.100Z" as any; - input.password.updated = "1970-01-01T00:00:00.200Z" as any; - input.username.updated = "1970-01-01T00:00:00.300Z" as any; - - const result = PREFERENCES.deserializer(input as any); - - expect(result.email.updated).toEqual(new Date(100)); - expect(result.password.updated).toEqual(new Date(200)); - expect(result.username.updated).toEqual(new Date(300)); - }); - }); -}); diff --git a/libs/tools/generator/core/src/services/credential-preferences.ts b/libs/tools/generator/core/src/services/credential-preferences.ts deleted file mode 100644 index 3f6a6c1e1b..0000000000 --- a/libs/tools/generator/core/src/services/credential-preferences.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; - -import { DefaultCredentialPreferences } from "../data"; -import { CredentialPreference } from "../types"; - -/** plaintext password generation options */ -export const PREFERENCES = new UserKeyDefinition( - GENERATOR_DISK, - "credentialPreferences", - { - deserializer: (value) => { - const result = (value as any) ?? {}; - - for (const key in DefaultCredentialPreferences) { - // bind `key` to `category` to transmute the type - const category: keyof typeof DefaultCredentialPreferences = key as any; - - const preference = result[category] ?? { ...DefaultCredentialPreferences[category] }; - if (typeof preference.updated === "string") { - preference.updated = new Date(preference.updated); - } - - result[category] = preference; - } - - return result; - }, - clearOn: ["logout"], - }, -); diff --git a/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts new file mode 100644 index 0000000000..81e7ae6ac6 --- /dev/null +++ b/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts @@ -0,0 +1,356 @@ +import { BehaviorSubject, Subject, firstValueFrom, of } from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; +import { Site, VendorId } from "@bitwarden/common/tools/extension"; +import { Bitwarden } from "@bitwarden/common/tools/extension/vendor/bitwarden"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; +import { SemanticLogger, ifEnabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { awaitAsync } from "../../../../../common/spec"; +import { + Algorithm, + CredentialAlgorithm, + CredentialType, + ForwarderExtensionId, + GeneratorMetadata, + Profile, + Type, +} from "../metadata"; +import { CredentialGeneratorProviders } from "../providers"; +import { GenerateRequest, GeneratedCredential } from "../types"; + +import { DefaultCredentialGeneratorService } from "./default-credential-generator.service"; + +// Custom type for jest.fn() mocks to preserve their type +type JestMockFunction any> = jest.Mock, Parameters>; + +// two-level partial that preserves jest.fn() mock types +type MockTwoLevelPartial = { + [K in keyof T]?: T[K] extends object + ? { + [P in keyof T[K]]?: T[K][P] extends (...args: any) => any + ? JestMockFunction + : T[K][P]; + } + : T[K]; +}; + +describe("DefaultCredentialGeneratorService", () => { + let service: DefaultCredentialGeneratorService; + let providers: MockTwoLevelPartial; + let system: any; + let log: SemanticLogger; + let mockExtension: { settings: jest.Mock }; + let account: Account; + let createService: (overrides?: any) => DefaultCredentialGeneratorService; + + beforeEach(() => { + log = ifEnabledSemanticLoggerProvider(false, new ConsoleLogService(true), { + from: "DefaultCredentialGeneratorService tests", + }); + + mockExtension = { settings: jest.fn() }; + + // Use a hard-coded value for mockAccount + account = { + id: "test-account-id" as UserId, + emailVerified: true, + email: "test@example.com", + name: "Test User", + }; + + system = { + log: jest.fn().mockReturnValue(log), + extension: mockExtension, + }; + + providers = { + metadata: { + metadata: jest.fn(), + preference$: jest.fn(), + algorithms$: jest.fn(), + algorithms: jest.fn(), + preferences: jest.fn(), + }, + profile: { + settings: jest.fn(), + constraints$: jest.fn(), + }, + generator: {}, + }; + + // Creating the service instance with a cast to the expected type + createService = (overrides = {}) => { + // Force cast the incomplete providers to the required type + // similar to how the overrides are applied + const providersCast = providers as unknown as CredentialGeneratorProviders; + + const instance = new DefaultCredentialGeneratorService(providersCast, system); + Object.assign(instance, overrides); + return instance; + }; + + service = createService(); + }); + + describe("generate$", () => { + it("should generate credentials when provided a specific algorithm", async () => { + const mockEngine = { + generate: jest + .fn() + .mockReturnValue( + of( + new GeneratedCredential("generatedPassword", Type.password, Date.now(), "unit test"), + ), + ), + }; + const mockMetadata = { + id: Algorithm.password, + engine: { create: jest.fn().mockReturnValue(mockEngine) }, + } as unknown as GeneratorMetadata; + const mockSettings = new BehaviorSubject({ length: 12 }); + providers.metadata!.metadata = jest.fn().mockReturnValue(mockMetadata); + service = createService({ + settings: () => mockSettings as any, + }); + const on$ = new Subject(); + const account$ = new BehaviorSubject(account); + const result$ = new BehaviorSubject(null); + + service.generate$({ on$, account$ }).subscribe(result$); + on$.next({ algorithm: Algorithm.password }); + await awaitAsync(); + + expect(result$.value?.credential).toEqual("generatedPassword"); + expect(providers.metadata!.metadata).toHaveBeenCalledWith(Algorithm.password); + expect(mockMetadata.engine.create).toHaveBeenCalled(); + expect(mockEngine.generate).toHaveBeenCalled(); + }); + + it("should determine preferred algorithm from credential type and generate credentials", async () => { + const mockEngine = { + generate: jest + .fn() + .mockReturnValue( + of(new GeneratedCredential("generatedPassword", "password", Date.now(), "unit test")), + ), + }; + const mockMetadata = { + id: "testAlgorithm", + engine: { create: jest.fn().mockReturnValue(mockEngine) }, + } as unknown as GeneratorMetadata; + const mockSettings = new BehaviorSubject({ length: 12 }); + + providers.metadata!.preference$ = jest + .fn() + .mockReturnValue(of("testAlgorithm" as CredentialAlgorithm)); + providers.metadata!.metadata = jest.fn().mockReturnValue(mockMetadata); + service = createService({ + settings: () => mockSettings as any, + }); + + const on$ = new Subject(); + const account$ = new BehaviorSubject(account); + const result$ = new BehaviorSubject(null); + + service.generate$({ on$, account$ }).subscribe(result$); + on$.next({ type: Type.password }); + await awaitAsync(); + + expect(result$.value?.credential).toBe("generatedPassword"); + expect(result$.value?.category).toBe(Type.password); + expect(providers.metadata!.metadata).toHaveBeenCalledWith("testAlgorithm"); + }); + }); + + describe("algorithms$", () => { + it("should retrieve and map available algorithms for a credential type", async () => { + const mockAlgorithms = [Algorithm.password, Algorithm.passphrase] as CredentialAlgorithm[]; + const mockMetadata1 = { id: Algorithm.password } as GeneratorMetadata; + const mockMetadata2 = { id: Algorithm.passphrase } as GeneratorMetadata; + + providers.metadata!.algorithms$ = jest.fn().mockReturnValue(of(mockAlgorithms)); + providers.metadata!.metadata = jest + .fn() + .mockReturnValueOnce(mockMetadata1) + .mockReturnValueOnce(mockMetadata2); + + const result = await firstValueFrom( + service.algorithms$("password" as CredentialType, { account$: of(account) }), + ); + + expect(result).toEqual([mockMetadata1, mockMetadata2]); + }); + }); + + describe("algorithms", () => { + it("should list algorithm metadata for a single credential type", () => { + providers.metadata!.algorithms = jest + .fn() + .mockReturnValue([Algorithm.password, Algorithm.passphrase] as CredentialAlgorithm[]); + service = createService({ + algorithm: (id: CredentialAlgorithm) => ({ id }) as GeneratorMetadata, + }); + + const result = service.algorithms("password" as CredentialType); + + expect(result).toEqual([{ id: Algorithm.password }, { id: Algorithm.passphrase }]); + expect(providers.metadata!.algorithms).toHaveBeenCalledWith({ type: "password" }); + }); + + it("should list combined algorithm metadata for multiple credential types", () => { + providers.metadata!.algorithms = jest + .fn() + .mockReturnValueOnce([Algorithm.password] as CredentialAlgorithm[]) + .mockReturnValueOnce([Algorithm.username] as CredentialAlgorithm[]); + + service = createService({ + algorithm: (id: CredentialAlgorithm) => ({ id }) as GeneratorMetadata, + }); + + const result = service.algorithms(["password", "username"] as CredentialType[]); + + expect(result).toEqual([{ id: Algorithm.password }, { id: Algorithm.username }]); + expect(providers.metadata!.algorithms).toHaveBeenCalledWith({ type: "password" }); + expect(providers.metadata!.algorithms).toHaveBeenCalledWith({ type: "username" }); + }); + }); + + describe("algorithm", () => { + it("should retrieve metadata for a specific generator algorithm", () => { + const mockMetadata = { id: Algorithm.password } as GeneratorMetadata; + providers.metadata!.metadata = jest.fn().mockReturnValue(mockMetadata); + + const result = service.algorithm(Algorithm.password); + + expect(result).toBe(mockMetadata); + expect(providers.metadata!.metadata).toHaveBeenCalledWith(Algorithm.password); + }); + + it("should log a panic when algorithm ID is invalid", () => { + providers.metadata!.metadata = jest.fn().mockReturnValue(null); + + expect(() => service.algorithm("invalidAlgo" as CredentialAlgorithm)).toThrow( + "invalid credential algorithm", + ); + }); + }); + + describe("forwarder", () => { + it("should retrieve forwarder metadata for a specific vendor", () => { + const vendorId = Vendor.bitwarden; + const forwarderExtensionId: ForwarderExtensionId = { forwarder: vendorId }; + const mockMetadata = { + id: forwarderExtensionId, + type: "email" as CredentialType, + } as GeneratorMetadata; + + providers.metadata!.metadata = jest.fn().mockReturnValue(mockMetadata); + + const result = service.forwarder(vendorId); + + expect(result).toBe(mockMetadata); + expect(providers.metadata!.metadata).toHaveBeenCalledWith(forwarderExtensionId); + }); + + it("should log a panic when vendor ID is invalid", () => { + const invalidVendorId = "invalid-vendor" as VendorId; + providers.metadata!.metadata = jest.fn().mockReturnValue(null); + + expect(() => service.forwarder(invalidVendorId)).toThrow("invalid vendor"); + }); + }); + + describe("preferences", () => { + it("should retrieve credential preferences bound to the user's account", () => { + const mockPreferences = { defaultType: "password" }; + providers.metadata!.preferences = jest.fn().mockReturnValue(mockPreferences); + + const result = service.preferences({ account$: of(account) }); + + expect(result).toBe(mockPreferences); + }); + }); + + describe("settings", () => { + it("should load user settings for account-bound profiles", () => { + const mockSettings = { value: { length: 12 } }; + const mockMetadata = { + id: "test", + profiles: { + [Profile.account]: { id: "accountProfile" }, + }, + } as unknown as GeneratorMetadata; + + providers.profile!.settings = jest.fn().mockReturnValue(mockSettings); + + const result = service.settings(mockMetadata, { account$: of(account) }); + + expect(result).toBe(mockSettings); + }); + + it("should load user settings for extension-bound profiles", () => { + const mockSettings = new BehaviorSubject({ value: { length: 12 } }); + const vendorId = Vendor.bitwarden; + const forwarderProfile = { + id: { forwarder: Bitwarden.id }, + site: Site.forwarder, + type: "extension", + }; + const mockMetadata = { + id: { forwarder: vendorId } as ForwarderExtensionId, + profiles: { + [Profile.account]: forwarderProfile, + }, + } as unknown as GeneratorMetadata; + + mockExtension.settings.mockReturnValue(mockSettings); + + const result = service.settings(mockMetadata, { account$: of(account) }); + + expect(result).toBe(mockSettings); + }); + + it("should log a panic when profile metadata is not found", () => { + const mockMetadata = { + id: "test", + profiles: {}, + } as unknown as GeneratorMetadata; + + expect(() => service.settings(mockMetadata, { account$: of(account) })).toThrow( + "failed to load settings; profile metadata not found", + ); + }); + }); + + describe("policy$", () => { + it("should retrieve policy constraints for a specific profile", async () => { + const mockConstraints = { minLength: 8 }; + const mockMetadata = { + id: "test", + profiles: { + [Profile.account]: { id: "accountProfile" }, + }, + } as unknown as GeneratorMetadata; + + providers.profile!.constraints$ = jest.fn().mockReturnValue(of(mockConstraints)); + + const result = await firstValueFrom(service.policy$(mockMetadata, { account$: of(account) })); + + expect(result).toEqual(mockConstraints); + }); + + it("should log a panic when profile metadata is not found for policy retrieval", () => { + const mockMetadata = { + id: "test", + profiles: {}, + } as unknown as GeneratorMetadata; + + expect(() => service.policy$(mockMetadata, { account$: of(account) })).toThrow( + "failed to load policy; profile metadata not found", + ); + }); + }); +}); diff --git a/libs/tools/generator/core/src/services/default-credential-generator.service.ts b/libs/tools/generator/core/src/services/default-credential-generator.service.ts new file mode 100644 index 0000000000..453139d284 --- /dev/null +++ b/libs/tools/generator/core/src/services/default-credential-generator.service.ts @@ -0,0 +1,219 @@ +import { + ReplaySubject, + concatMap, + filter, + first, + map, + of, + share, + shareReplay, + switchAll, + switchMap, + takeUntil, + tap, + timer, + zip, +} from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BoundDependency, OnDependency } from "@bitwarden/common/tools/dependencies"; +import { VendorId } from "@bitwarden/common/tools/extension"; +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { anyComplete, memoizedMap } from "@bitwarden/common/tools/rx"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; + +import { CredentialGeneratorService } from "../abstractions"; +import { + CredentialAlgorithm, + Profile, + GeneratorMetadata, + GeneratorProfile, + isForwarderProfile, + toVendorId, + CredentialType, +} from "../metadata"; +import { CredentialGeneratorProviders } from "../providers"; +import { GenerateRequest } from "../types"; +import { isAlgorithmRequest, isTypeRequest } from "../types/metadata-request"; + +const ALGORITHM_CACHE_SIZE = 10; +const THREE_MINUTES = 3 * 60 * 1000; + +export class DefaultCredentialGeneratorService implements CredentialGeneratorService { + /** Instantiate the `DefaultCredentialGeneratorService`. + * @param provide application services required by the credential generator. + * @param system low-level services required by the credential generator. + */ + constructor( + private readonly provide: CredentialGeneratorProviders, + private readonly system: SystemServiceProvider, + ) { + this.log = system.log({ type: "DefaultCredentialGeneratorService" }); + } + + private readonly log: SemanticLogger; + + generate$(dependencies: OnDependency & BoundDependency<"account", Account>) { + const request$ = dependencies.on$.pipe(shareReplay({ refCount: true, bufferSize: 1 })); + const account$ = dependencies.account$.pipe(shareReplay({ refCount: true, bufferSize: 1 })); + + // load algorithm metadata + const metadata$ = request$.pipe( + switchMap((request) => { + if (isAlgorithmRequest(request)) { + return of(request.algorithm); + } else if (isTypeRequest(request)) { + return this.provide.metadata.preference$(request.type, { account$ }).pipe(first()); + } else { + this.log.panic(request, "algorithm or category required"); + } + }), + filter((algorithm): algorithm is CredentialAlgorithm => !!algorithm), + memoizedMap((algorithm) => this.provide.metadata.metadata(algorithm), { + size: ALGORITHM_CACHE_SIZE, + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + // load the active profile's settings + const settings$ = zip(request$, metadata$).pipe( + map( + ([request, metadata]) => + [{ ...request, profile: request.profile ?? Profile.account }, metadata] as const, + ), + memoizedMap( + ([request, metadata]) => { + const [profile, algorithm] = [request.profile, metadata.id]; + + // settings$ stays hot and buffers the most recent value in the cache + // for the next `request` + const settings$ = this.settings(metadata, { account$ }, profile).pipe( + tap(() => this.log.debug({ algorithm, profile }, "settings update received")), + share({ + connector: () => new ReplaySubject(1, THREE_MINUTES), + resetOnRefCountZero: () => timer(THREE_MINUTES), + }), + tap({ + subscribe: () => this.log.debug({ algorithm, profile }, "settings hot"), + complete: () => this.log.debug({ algorithm, profile }, "settings cold"), + }), + first(), + ); + + this.log.debug({ algorithm, profile }, "settings cached"); + return settings$; + }, + { key: ([request, metadata]) => `${metadata.id}:${request.profile}` }, + ), + switchAll(), + ); + + // load the algorithm's engine + const engine$ = metadata$.pipe( + memoizedMap( + (metadata) => { + const engine = metadata.engine.create(this.provide.generator); + + this.log.debug({ algorithm: metadata.id }, "engine cached"); + return engine; + }, + { size: ALGORITHM_CACHE_SIZE }, + ), + ); + + // generation proper + const generate$ = zip([request$, settings$, engine$]).pipe( + tap(([request]) => this.log.debug(request, "generating credential")), + concatMap(([request, settings, engine]) => engine.generate(request, settings)), + takeUntil(anyComplete([settings$])), + ); + + return generate$; + } + + algorithms$(type: CredentialType, dependencies: BoundDependency<"account", Account>) { + return this.provide.metadata + .algorithms$({ type }, dependencies) + .pipe(map((algorithms) => algorithms.map((a) => this.algorithm(a)))); + } + + algorithms(type: CredentialType | CredentialType[]) { + const types: CredentialType[] = Array.isArray(type) ? type : [type]; + const algorithms = types + .flatMap((type) => this.provide.metadata.algorithms({ type })) + .map((algorithm) => this.algorithm(algorithm)); + return algorithms; + } + + algorithm(id: CredentialAlgorithm) { + const metadata = this.provide.metadata.metadata(id); + if (!metadata) { + this.log.panic({ algorithm: id }, "invalid credential algorithm"); + } + + return metadata; + } + + forwarder(id: VendorId) { + const metadata = this.provide.metadata.metadata({ forwarder: id }); + if (!metadata) { + this.log.panic({ algorithm: id }, "invalid vendor"); + } + + return metadata; + } + + preferences(dependencies: BoundDependency<"account", Account>) { + return this.provide.metadata.preferences(dependencies); + } + + settings( + metadata: Readonly>, + dependencies: BoundDependency<"account", Account>, + profile: GeneratorProfile = Profile.account, + ) { + const activeProfile = metadata.profiles[profile]; + if (!activeProfile) { + this.log.panic( + { algorithm: metadata.id, profile }, + "failed to load settings; profile metadata not found", + ); + } + + let settings: UserStateSubject; + if (isForwarderProfile(activeProfile)) { + const vendor = toVendorId(metadata.id); + if (!vendor) { + this.log.panic( + { algorithm: metadata.id, profile }, + "failed to load extension profile; vendor not specified", + ); + } + + this.log.info({ profile, vendor, site: activeProfile.site }, "loading extension profile"); + settings = this.system.extension.settings(activeProfile, vendor, dependencies); + } else { + this.log.info({ profile, algorithm: metadata.id }, "loading generator profile"); + settings = this.provide.profile.settings(activeProfile, dependencies); + } + + return settings; + } + + policy$( + metadata: Readonly>, + dependencies: BoundDependency<"account", Account>, + profile: GeneratorProfile = Profile.account, + ) { + const activeProfile = metadata.profiles[profile]; + if (!activeProfile) { + this.log.panic( + { algorithm: metadata.id, profile }, + "failed to load policy; profile metadata not found", + ); + } + + return this.provide.profile.constraints$(activeProfile, dependencies); + } +} diff --git a/libs/tools/generator/core/src/services/default-generator.service.spec.ts b/libs/tools/generator/core/src/services/default-generator.service.spec.ts index eb9642a941..a0f9342fa2 100644 --- a/libs/tools/generator/core/src/services/default-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/default-generator.service.spec.ts @@ -18,7 +18,7 @@ import { DefaultGeneratorService } from "./default-generator.service"; function mockPolicyService(config?: { state?: BehaviorSubject }) { const service = mock(); - const stateValue = config?.state ?? new BehaviorSubject([null]); + const stateValue = config?.state ?? new BehaviorSubject([]); service.policiesByType$.mockReturnValue(stateValue); return service; @@ -119,22 +119,22 @@ describe("Password generator service", () => { it("should update the evaluator when the password generator policy changes", async () => { // set up dependencies - const state = new BehaviorSubject([null]); + const state = new BehaviorSubject([]); const policy = mockPolicyService({ state }); const strategy = mockGeneratorStrategy(); const service = new DefaultGeneratorService(strategy, policy); // model responses for the observable update. The map is called multiple times, // and the array shift ensures reference equality is maintained. - const firstEvaluator = mock>(); - const secondEvaluator = mock>(); + const firstEvaluator: PolicyEvaluator = mock>(); + const secondEvaluator: PolicyEvaluator = mock>(); const evaluators = [firstEvaluator, secondEvaluator]; - strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift()))); + strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift()!))); // act const evaluator$ = service.evaluator$(SomeUser); const firstResult = await firstValueFrom(evaluator$); - state.next([null]); + state.next([]); const secondResult = await firstValueFrom(evaluator$); // assert diff --git a/libs/tools/generator/core/src/services/index.ts b/libs/tools/generator/core/src/services/index.ts index d7184f684a..57094e28dd 100644 --- a/libs/tools/generator/core/src/services/index.ts +++ b/libs/tools/generator/core/src/services/index.ts @@ -1,2 +1,2 @@ export { DefaultGeneratorService } from "./default-generator.service"; -export { CredentialGeneratorService } from "./credential-generator.service"; +export { DefaultCredentialGeneratorService } from "./default-credential-generator.service"; diff --git a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts index 83ed3b0d14..ebeacef81e 100644 --- a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts @@ -2,7 +2,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; import { GeneratorStrategy } from "../abstractions"; -import { DefaultEffUsernameOptions, UsernameDigits } from "../data"; +import { DefaultEffUsernameOptions } from "../data"; import { UsernameRandomizer } from "../engine"; import { newDefaultEvaluator } from "../rx"; import { EffUsernameGenerationOptions, NoPolicy } from "../types"; @@ -10,6 +10,11 @@ import { observe$PerUserId, sharedStateByUserId } from "../util"; import { EFF_USERNAME_SETTINGS } from "./storage"; +const UsernameDigits = Object.freeze({ + enabled: 4, + disabled: 0, +}); + /** Strategy for creating usernames from the EFF wordlist */ export class EffUsernameGeneratorStrategy implements GeneratorStrategy diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts index 5abcea8249..ee521d753a 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts @@ -8,7 +8,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { DefaultPassphraseGenerationOptions, Policies } from "../data"; +import { DefaultPassphraseGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; import { PassphraseGeneratorOptionsEvaluator } from "../policies"; @@ -20,7 +20,7 @@ const SomeUser = "some user" as UserId; describe("Passphrase generation strategy", () => { describe("toEvaluator()", () => { it("should map to the policy evaluator", async () => { - const strategy = new PassphraseGeneratorStrategy(null, null); + const strategy = new PassphraseGeneratorStrategy(null!, null!); const policy = mock({ type: PolicyType.PasswordGenerator, data: { @@ -44,13 +44,18 @@ describe("Passphrase generation strategy", () => { it.each([[[]], [null], [undefined]])( "should map `%p` to a disabled password policy evaluator", async (policies) => { - const strategy = new PassphraseGeneratorStrategy(null, null); + const strategy = new PassphraseGeneratorStrategy(null!, null!); - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + // this case tests when the type system is subverted + const evaluator$ = of(policies!).pipe(strategy.toEvaluator()); const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(Policies.Passphrase.disabledValue); + expect(evaluator.policy).toMatchObject({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }); }, ); }); @@ -58,7 +63,7 @@ describe("Passphrase generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const strategy = new PassphraseGeneratorStrategy(null, provider); + const strategy = new PassphraseGeneratorStrategy(null!, provider); strategy.durableState(SomeUser); @@ -68,7 +73,7 @@ describe("Passphrase generation strategy", () => { describe("defaults$", () => { it("should return the default subaddress options", async () => { - const strategy = new PassphraseGeneratorStrategy(null, null); + const strategy = new PassphraseGeneratorStrategy(null!, null!); const result = await firstValueFrom(strategy.defaults$(SomeUser)); @@ -78,7 +83,7 @@ describe("Passphrase generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const strategy = new PassphraseGeneratorStrategy(null, null); + const strategy = new PassphraseGeneratorStrategy(null!, null!); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); @@ -95,7 +100,7 @@ describe("Passphrase generation strategy", () => { }); it("should map options", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ numWords: 6, @@ -114,7 +119,7 @@ describe("Passphrase generation strategy", () => { }); it("should default numWords", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ capitalize: true, @@ -132,7 +137,7 @@ describe("Passphrase generation strategy", () => { }); it("should default capitalize", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ numWords: 6, @@ -150,7 +155,7 @@ describe("Passphrase generation strategy", () => { }); it("should default includeNumber", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ numWords: 6, @@ -168,7 +173,7 @@ describe("Passphrase generation strategy", () => { }); it("should default wordSeparator", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ numWords: 6, diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts index 759f065b2f..374df84a5b 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts @@ -4,8 +4,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; import { GeneratorStrategy } from "../abstractions"; -import { DefaultPassphraseGenerationOptions, Policies } from "../data"; +import { DefaultPassphraseGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; +import { PassphraseGeneratorOptionsEvaluator, passphraseLeastPrivilege } from "../policies"; import { mapPolicyToEvaluator } from "../rx"; import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; import { observe$PerUserId, optionsToEffWordListRequest, sharedStateByUserId } from "../util"; @@ -30,7 +31,16 @@ export class PassphraseGeneratorStrategy defaults$ = observe$PerUserId(() => DefaultPassphraseGenerationOptions); readonly policy = PolicyType.PasswordGenerator; toEvaluator() { - return mapPolicyToEvaluator(Policies.Passphrase); + return mapPolicyToEvaluator({ + type: PolicyType.PasswordGenerator, + disabledValue: Object.freeze({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }), + combine: passphraseLeastPrivilege, + createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), + }); } // algorithm diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts index 928e0b0dc8..94e7c16be2 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts @@ -8,7 +8,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { DefaultPasswordGenerationOptions, Policies } from "../data"; +import { DefaultPasswordGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; import { PasswordGeneratorOptionsEvaluator } from "../policies"; @@ -20,7 +20,7 @@ const SomeUser = "some user" as UserId; describe("Password generation strategy", () => { describe("toEvaluator()", () => { it("should map to a password policy evaluator", async () => { - const strategy = new PasswordGeneratorStrategy(null, null); + const strategy = new PasswordGeneratorStrategy(null!, null!); const policy = mock({ type: PolicyType.PasswordGenerator, data: { @@ -52,13 +52,22 @@ describe("Password generation strategy", () => { it.each([[[]], [null], [undefined]])( "should map `%p` to a disabled password policy evaluator", async (policies) => { - const strategy = new PasswordGeneratorStrategy(null, null); + const strategy = new PasswordGeneratorStrategy(null!, null!); - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + // this case tests when the type system is subverted + const evaluator$ = of(policies!).pipe(strategy.toEvaluator()); const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(Policies.Password.disabledValue); + expect(evaluator.policy).toMatchObject({ + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }); }, ); }); @@ -66,7 +75,7 @@ describe("Password generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const strategy = new PasswordGeneratorStrategy(null, provider); + const strategy = new PasswordGeneratorStrategy(null!, provider); strategy.durableState(SomeUser); @@ -76,7 +85,7 @@ describe("Password generation strategy", () => { describe("defaults$", () => { it("should return the default subaddress options", async () => { - const strategy = new PasswordGeneratorStrategy(null, null); + const strategy = new PasswordGeneratorStrategy(null!, null!); const result = await firstValueFrom(strategy.defaults$(SomeUser)); @@ -86,7 +95,7 @@ describe("Password generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const strategy = new PasswordGeneratorStrategy(null, null); + const strategy = new PasswordGeneratorStrategy(null!, null!); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); @@ -103,7 +112,7 @@ describe("Password generation strategy", () => { }); it("should map options", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 20, @@ -130,7 +139,7 @@ describe("Password generation strategy", () => { }); it("should disable uppercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 3, @@ -157,7 +166,7 @@ describe("Password generation strategy", () => { }); it("should disable lowercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 3, @@ -184,7 +193,7 @@ describe("Password generation strategy", () => { }); it("should disable digits", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 3, @@ -211,7 +220,7 @@ describe("Password generation strategy", () => { }); it("should disable special", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 3, @@ -238,7 +247,7 @@ describe("Password generation strategy", () => { }); it("should override length with minimums", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 20, @@ -265,7 +274,7 @@ describe("Password generation strategy", () => { }); it("should default uppercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 2, @@ -291,7 +300,7 @@ describe("Password generation strategy", () => { }); it("should default lowercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -317,7 +326,7 @@ describe("Password generation strategy", () => { }); it("should default number", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -343,7 +352,7 @@ describe("Password generation strategy", () => { }); it("should default special", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -369,7 +378,7 @@ describe("Password generation strategy", () => { }); it("should default minUppercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -395,7 +404,7 @@ describe("Password generation strategy", () => { }); it("should default minLowercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -421,7 +430,7 @@ describe("Password generation strategy", () => { }); it("should default minNumber", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -447,7 +456,7 @@ describe("Password generation strategy", () => { }); it("should default minSpecial", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts index 9ff8a3d88b..1a5070901c 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts @@ -2,8 +2,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; import { GeneratorStrategy } from "../abstractions"; -import { Policies, DefaultPasswordGenerationOptions } from "../data"; +import { DefaultPasswordGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; +import { PasswordGeneratorOptionsEvaluator, passwordLeastPrivilege } from "../policies"; import { mapPolicyToEvaluator } from "../rx"; import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types"; import { observe$PerUserId, optionsToRandomAsciiRequest, sharedStateByUserId } from "../util"; @@ -27,7 +28,20 @@ export class PasswordGeneratorStrategy defaults$ = observe$PerUserId(() => DefaultPasswordGenerationOptions); readonly policy = PolicyType.PasswordGenerator; toEvaluator() { - return mapPolicyToEvaluator(Policies.Password); + return mapPolicyToEvaluator({ + type: PolicyType.PasswordGenerator, + disabledValue: { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }, + combine: passwordLeastPrivilege, + createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), + }); } // algorithm diff --git a/libs/tools/generator/core/src/types/algorithm-info.ts b/libs/tools/generator/core/src/types/algorithm-info.ts new file mode 100644 index 0000000000..a3db03600b --- /dev/null +++ b/libs/tools/generator/core/src/types/algorithm-info.ts @@ -0,0 +1,50 @@ +import { CredentialAlgorithm, CredentialType } from "../metadata"; + +// FIXME: deprecate or delete `AlgorithmInfo` once a better translation +// strategy is identified. +export type AlgorithmInfo = { + /** Uniquely identifies the credential configuration + * @example + * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` + * // to pattern test whether the credential describes a forwarder algorithm + * const meta : CredentialGeneratorInfo = // ... + * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; + */ + id: CredentialAlgorithm; + + /** The kind of credential generated by this configuration */ + type: CredentialType; + + /** Localized algorithm name */ + name: string; + + /* Localized generate button label */ + generate: string; + + /** Localized "credential generated" informational message */ + onGeneratedMessage: string; + + /* Localized copy button label */ + copy: string; + + /* Localized dialog button label */ + useGeneratedValue: string; + + /* Localized generated value label */ + credentialType: string; + + /** Localized algorithm description */ + description?: string; + + /** When true, credential generation must be explicitly requested. + * @remarks this property is useful when credential generation + * carries side effects, such as configuring a service external + * to Bitwarden. + */ + onlyOnRequest: boolean; + + /** Well-known fields to display on the options panel or collect from the environment. + * @remarks: at present, this is only used by forwarders + */ + request: readonly string[]; +}; diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts deleted file mode 100644 index 36b0f3046a..0000000000 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { UserKeyDefinition } from "@bitwarden/common/platform/state"; -import { RestClient } from "@bitwarden/common/tools/integration/rpc"; -import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; -import { Constraints } from "@bitwarden/common/tools/types"; - -import { Randomizer } from "../abstractions"; -import { CredentialAlgorithm, CredentialCategory, PolicyConfiguration } from "../types"; - -import { CredentialGenerator } from "./credential-generator"; - -export type GeneratorDependencyProvider = { - randomizer: Randomizer; - client: RestClient; - i18nService: I18nService; -}; - -export type AlgorithmInfo = { - /** Uniquely identifies the credential configuration - * @example - * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` - * // to pattern test whether the credential describes a forwarder algorithm - * const meta : CredentialGeneratorInfo = // ... - * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; - */ - id: CredentialAlgorithm; - - /** The kind of credential generated by this configuration */ - category: CredentialCategory; - - /** Localized algorithm name */ - name: string; - - /* Localized generate button label */ - generate: string; - - /** Localized "credential generated" informational message */ - onGeneratedMessage: string; - - /* Localized copy button label */ - copy: string; - - /* Localized dialog button label */ - useGeneratedValue: string; - - /* Localized generated value label */ - credentialType: string; - - /** Localized algorithm description */ - description?: string; - - /** When true, credential generation must be explicitly requested. - * @remarks this property is useful when credential generation - * carries side effects, such as configuring a service external - * to Bitwarden. - */ - onlyOnRequest: boolean; - - /** Well-known fields to display on the options panel or collect from the environment. - * @remarks: at present, this is only used by forwarders - */ - request: readonly string[]; -}; - -/** Credential generator metadata common across credential generators */ -export type CredentialGeneratorInfo = { - /** Uniquely identifies the credential configuration - * @example - * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` - * // to pattern test whether the credential describes a forwarder algorithm - * const meta : CredentialGeneratorInfo = // ... - * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; - */ - id: CredentialAlgorithm; - - /** The kind of credential generated by this configuration */ - category: CredentialCategory; - - /** Localization key for the credential name */ - nameKey: string; - - /** Localization key for the credential description*/ - descriptionKey?: string; - - /** Localization key for the generate command label */ - generateKey: string; - - /** Localization key for the copy button label */ - copyKey: string; - - /** Localization key for the "credential generated" informational message */ - onGeneratedMessageKey: string; - - /** Localized "use generated credential" button label */ - useGeneratedValueKey: string; - - /** Localization key for describing the kind of credential generated - * by this generator. - */ - credentialTypeKey: string; - - /** When true, credential generation must be explicitly requested. - * @remarks this property is useful when credential generation - * carries side effects, such as configuring a service external - * to Bitwarden. - */ - onlyOnRequest: boolean; - - /** Well-known fields to display on the options panel or collect from the environment. - * @remarks: at present, this is only used by forwarders - */ - request: readonly string[]; -}; - -/** Credential generator metadata that relies upon typed setting and policy definitions. - * @example - * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` - * // to pattern test whether the credential describes a forwarder algorithm - * const meta : CredentialGeneratorInfo = // ... - * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; - */ -export type CredentialGeneratorConfiguration = CredentialGeneratorInfo & { - /** An algorithm that generates credentials when ran. */ - engine: { - /** Factory for the generator - */ - // FIXME: note that this erases the engine's type so that credentials are - // generated uniformly. This property needs to be maintained for - // the credential generator, but engine configurations should return - // the underlying type. `create` may be able to do double-duty w/ an - // engine definition if `CredentialGenerator` can be made covariant. - create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator; - }; - /** Defines the stored parameters for credential generation */ - settings: { - /** 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 */ - constraints: Constraints; - - /** storage location for account-global settings */ - account: UserKeyDefinition | ObjectKey; - - /** storage location for *plaintext* settings imports */ - import?: UserKeyDefinition | ObjectKey, Settings>; - }; - - /** defines how to construct policy for this settings instance */ - policy: PolicyConfiguration; -}; diff --git a/libs/tools/generator/core/src/types/credential-preference.ts b/libs/tools/generator/core/src/types/credential-preference.ts new file mode 100644 index 0000000000..957299e5c3 --- /dev/null +++ b/libs/tools/generator/core/src/types/credential-preference.ts @@ -0,0 +1,10 @@ +import { CredentialAlgorithm, CredentialType } from "../metadata"; + +/** The kind of credential to generate using a compound configuration. */ +// FIXME: extend the preferences to include a preferred forwarder +export type CredentialPreference = { + [Key in CredentialType]: { + algorithm: CredentialAlgorithm; + updated: Date; + }; +}; diff --git a/libs/tools/generator/core/src/types/forwarder-options.ts b/libs/tools/generator/core/src/types/forwarder-options.ts index 7ba04da99a..0608426128 100644 --- a/libs/tools/generator/core/src/types/forwarder-options.ts +++ b/libs/tools/generator/core/src/types/forwarder-options.ts @@ -7,32 +7,65 @@ import { import { EmailDomainSettings, EmailPrefixSettings } from "../engine"; +// FIXME: this type alias is in place for legacy support purposes; +// when replacing the forwarder implementation, eliminate `ForwarderId` and +// `IntegrationId`. The proper type is `VendorId`. /** Identifiers for email forwarding services. * @remarks These are used to select forwarder-specific options. * The must be kept in sync with the forwarder implementations. */ export type ForwarderId = IntegrationId; -/** Metadata format for email forwarding services. */ -export type ForwarderMetadata = { - /** The unique identifier for the forwarder. */ - id: ForwarderId; - - /** The name of the service the forwarder queries. */ - name: string; - - /** Whether the forwarder is valid for self-hosted instances of Bitwarden. */ - validForSelfHosted: boolean; -}; - -/** Options common to all forwarder APIs */ +/** Options common to all forwarder APIs + * @deprecated use {@link ForwarderOptions} instead. + */ export type ApiOptions = ApiSettings & IntegrationRequest; -/** Api configuration for forwarders that support self-hosted installations. */ +/** Api configuration for forwarders that support self-hosted installations. + * @deprecated use {@link ForwarderOptions} instead. + */ export type SelfHostedApiOptions = SelfHostedApiSettings & IntegrationRequest; -/** Api configuration for forwarders that support custom domains. */ +/** Api configuration for forwarders that support custom domains. + * @deprecated use {@link ForwarderOptions} instead. + */ export type EmailDomainOptions = EmailDomainSettings; -/** Api configuration for forwarders that support custom email parts. */ +/** Api configuration for forwarders that support custom email parts. + * @deprecated use {@link ForwarderOptions} instead. + */ export type EmailPrefixOptions = EmailDomainSettings & EmailPrefixSettings; + +/** These options are used by all forwarders; each forwarder uses a different set, + * as defined by `GeneratorMetadata.capabilities.fields`. + */ +export type ForwarderOptions = Partial< + { + /** bearer token that authenticates bitwarden to the forwarder. + * This is required to issue an API request. + */ + token: string; + + /** The base URL of the forwarder's API. + * When this is undefined or empty, the forwarder's default production API is used. + */ + baseUrl: string; + + /** The domain part of the generated email address. + * @remarks The domain should be authorized by the forwarder before + * submitting a request through bitwarden. + * @example If the domain is `domain.io` and the generated username + * is `jd`, then the generated email address will be `jd@domain.io` + */ + domain: string; + + /** A prefix joined to the generated email address' username. + * @example If the prefix is `foo`, the generated username is `bar`, + * and the domain is `domain.io`, then the generated email address is + * `foobar@domain.io`. + */ + prefix: string; + } & EmailDomainSettings & + EmailPrefixSettings & + SelfHostedApiSettings +>; diff --git a/libs/tools/generator/core/src/types/generate-request.ts b/libs/tools/generator/core/src/types/generate-request.ts index c7d5bf9c41..9dbaaee12f 100644 --- a/libs/tools/generator/core/src/types/generate-request.ts +++ b/libs/tools/generator/core/src/types/generate-request.ts @@ -1,6 +1,15 @@ +import { RequireExactlyOne } from "type-fest"; + +import { CredentialType, GeneratorProfile, CredentialAlgorithm } from "../metadata"; + /** Contextual information about the application state when a generator is invoked. */ -export type GenerateRequest = { +export type GenerateRequest = RequireExactlyOne< + { type: CredentialType; algorithm: CredentialAlgorithm }, + "type" | "algorithm" +> & { + profile?: GeneratorProfile; + /** Traces the origin of the generation request. This parameter is * copied to the generated credential. * diff --git a/libs/tools/generator/core/src/types/generated-credential.spec.ts b/libs/tools/generator/core/src/types/generated-credential.spec.ts index 6a498282fe..3d8d2c9bd4 100644 --- a/libs/tools/generator/core/src/types/generated-credential.spec.ts +++ b/libs/tools/generator/core/src/types/generated-credential.spec.ts @@ -1,40 +1,42 @@ -import { CredentialAlgorithm, GeneratedCredential } from "."; +import { Type } from "../metadata"; + +import { GeneratedCredential } from "./generated-credential"; describe("GeneratedCredential", () => { describe("constructor", () => { it("assigns credential", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); expect(result.credential).toEqual("example"); }); it("assigns category", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); - expect(result.category).toEqual("passphrase"); + expect(result.category).toEqual(Type.password); }); it("passes through date parameters", () => { - const result = new GeneratedCredential("example", "password", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); expect(result.generationDate).toEqual(new Date(100)); }); it("converts numeric dates to Dates", () => { - const result = new GeneratedCredential("example", "password", 100); + const result = new GeneratedCredential("example", Type.password, 100); expect(result.generationDate).toEqual(new Date(100)); }); }); it("toJSON converts from a credential into a JSON object", () => { - const credential = new GeneratedCredential("example", "password", new Date(100)); + const credential = new GeneratedCredential("example", Type.password, new Date(100)); const result = credential.toJSON(); expect(result).toEqual({ credential: "example", - category: "password" as CredentialAlgorithm, + category: Type.password, generationDate: 100, }); }); @@ -42,7 +44,7 @@ describe("GeneratedCredential", () => { it("fromJSON converts Json objects into credentials", () => { const jsonValue = { credential: "example", - category: "password" as CredentialAlgorithm, + category: Type.password, generationDate: 100, }; diff --git a/libs/tools/generator/core/src/types/generated-credential.ts b/libs/tools/generator/core/src/types/generated-credential.ts index 99b864b9fd..695e386692 100644 --- a/libs/tools/generator/core/src/types/generated-credential.ts +++ b/libs/tools/generator/core/src/types/generated-credential.ts @@ -1,13 +1,13 @@ import { Jsonify } from "type-fest"; -import { CredentialAlgorithm } from "./generator-type"; +import { CredentialType } from "../metadata"; /** A credential generation result */ export class GeneratedCredential { /** * Instantiates a generated credential * @param credential The value of the generated credential (e.g. a password) - * @param category The kind of credential + * @param category The type of credential * @param generationDate The date that the credential was generated. * Numeric values should are interpreted using {@link Date.valueOf} * semantics. @@ -16,7 +16,9 @@ export class GeneratedCredential { */ constructor( readonly credential: string, - readonly category: CredentialAlgorithm, + // FIXME: create a way to migrate the data stored in `category` to a new `type` + // field. The hard part: This requires the migration occur post-decryption. + readonly category: CredentialType, generationDate: Date | number, readonly source?: string, readonly website?: string, diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts deleted file mode 100644 index c75e432961..0000000000 --- a/libs/tools/generator/core/src/types/generator-type.ts +++ /dev/null @@ -1,83 +0,0 @@ -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]; - -/** A type of username that may be generated by the credential generator. */ -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 & VendorId }; - -/** Returns true when the input algorithm is a forwarder integration. */ -export function isForwarderIntegration( - algorithm: CredentialAlgorithm, -): algorithm is ForwarderIntegration { - return algorithm && typeof algorithm === "object" && "forwarder" in algorithm; -} - -export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorithm) { - if (lhs === rhs) { - return true; - } else if (isForwarderIntegration(lhs) && isForwarderIntegration(rhs)) { - return lhs.forwarder === rhs.forwarder; - } else { - return false; - } -} - -/** A type of credential that may be generated by the credential generator. */ -export type CredentialAlgorithm = - | PasswordAlgorithm - | UsernameAlgorithm - | EmailAlgorithm - | ForwarderIntegration; - -/** Compound credential types supported by the credential generator. */ -export const CredentialCategories = Object.freeze({ - /** Lists algorithms in the "password" credential category */ - password: PasswordAlgorithms as Readonly, - - /** Lists algorithms in the "username" credential category */ - username: UsernameAlgorithms as Readonly, - - /** Lists algorithms in the "email" credential category */ - email: EmailAlgorithms as Readonly<(EmailAlgorithm | ForwarderIntegration)[]>, -}); - -/** Returns true when the input algorithm is a password algorithm. */ -export function isPasswordAlgorithm( - algorithm: CredentialAlgorithm, -): algorithm is PasswordAlgorithm { - return PasswordAlgorithms.includes(algorithm as any); -} - -/** Returns true when the input algorithm is a username algorithm. */ -export function isUsernameAlgorithm( - algorithm: CredentialAlgorithm, -): algorithm is UsernameAlgorithm { - return UsernameAlgorithms.includes(algorithm as any); -} - -/** Returns true when the input algorithm is an email algorithm. */ -export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { - return EmailAlgorithms.includes(algorithm as any) || isForwarderIntegration(algorithm); -} - -/** A type of compound credential that may be generated by the credential generator. */ -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 CredentialType & CredentialCategory]: { - algorithm: CredentialAlgorithm & (typeof AlgorithmsByType)[Key][number]; - updated: Date; - }; -}; diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 3e392257b0..f6460342b0 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -1,16 +1,14 @@ -import { EmailAlgorithm, PasswordAlgorithm, UsernameAlgorithm } from "./generator-type"; - export * from "./boundary"; export * from "./catchall-generator-options"; export * from "./credential-generator"; -export * from "./credential-generator-configuration"; +export * from "./algorithm-info"; export * from "./eff-username-generator-options"; export * from "./forwarder-options"; export * from "./generate-request"; export * from "./generator-constraints"; export * from "./generated-credential"; export * from "./generator-options"; -export * from "./generator-type"; +export * from "./credential-preference"; export * from "./no-policy"; export * from "./passphrase-generation-options"; export * from "./passphrase-generator-policy"; @@ -19,13 +17,3 @@ export * from "./password-generator-policy"; export * from "./policy-configuration"; export * from "./subaddress-generator-options"; export * from "./word-options"; - -/** Provided for backwards compatibility only. - * @deprecated Use one of the Algorithm types instead. - */ -export type GeneratorType = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm; - -/** Provided for backwards compatibility only. - * @deprecated Use one of the Algorithm types instead. - */ -export type PasswordType = PasswordAlgorithm; diff --git a/libs/tools/generator/core/src/types/policy-configuration.ts b/libs/tools/generator/core/src/types/policy-configuration.ts index 07ded88660..e4db437575 100644 --- a/libs/tools/generator/core/src/types/policy-configuration.ts +++ b/libs/tools/generator/core/src/types/policy-configuration.ts @@ -3,8 +3,6 @@ import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/do import { PolicyEvaluator } from "../abstractions"; -import { GeneratorConstraints } from "./generator-constraints"; - /** Determines how to construct a password generator policy */ export type PolicyConfiguration = { type: PolicyType; @@ -22,15 +20,4 @@ export type PolicyConfiguration = { * Use `toConstraints` instead. */ createEvaluator: (policy: Policy) => PolicyEvaluator; - - /** Converts policy service data into actionable policy constraints. - * - * @param policy - the policy to map into policy constraints. - * @param email - the default email to extend. - * - * @remarks this version includes constraints needed for the reactive forms; - * it was introduced so that the constraints can be incrementally introduced - * as the new UI is built. - */ - toConstraints: (policy: Policy, email: string) => GeneratorConstraints; }; diff --git a/libs/tools/generator/core/src/util.spec.ts b/libs/tools/generator/core/src/util.spec.ts index 8ed95a9f26..5aeaeb3c67 100644 --- a/libs/tools/generator/core/src/util.spec.ts +++ b/libs/tools/generator/core/src/util.spec.ts @@ -1,5 +1,6 @@ import { DefaultPassphraseGenerationOptions } from "./data"; -import { optionsToEffWordListRequest, optionsToRandomAsciiRequest, sum } from "./util"; +import { GeneratorConstraints, PassphraseGenerationOptions } from "./types"; +import { optionsToEffWordListRequest, optionsToRandomAsciiRequest, sum, equivalent } from "./util"; describe("sum", () => { it("returns 0 when the list is empty", () => { @@ -424,3 +425,90 @@ describe("optionsToEffWordListRequest", () => { }); }); }); + +describe("equivalent", () => { + // constructs a partial constraints object; only the properties compared + // by `equivalent` are included. + function createConstraints( + policyInEffect: boolean, + numWordsMin?: number, + capitalize?: boolean, + ): GeneratorConstraints { + return { + constraints: { + policyInEffect, + numWords: numWordsMin !== undefined ? { min: numWordsMin } : undefined, + capitalize: capitalize !== undefined ? { requiredValue: capitalize } : undefined, + }, + } as unknown as GeneratorConstraints; + } + + it("should return true for identical constraints", () => { + const lhs = createConstraints(false, 3, true); + const rhs = createConstraints(false, 3, true); + + expect(equivalent(lhs, rhs)).toBe(true); + }); + + it("should return false when policy effects differ", () => { + const lhs = createConstraints(true, 3); + const rhs = createConstraints(false, 3); + + expect(equivalent(lhs, rhs)).toBe(false); + }); + + it("should return false when constraint values differ", () => { + const lhs = createConstraints(false, 3); + const rhs = createConstraints(false, 4); + + expect(equivalent(lhs, rhs)).toBe(false); + }); + + it("should return false when one has additional constraints", () => { + const lhs = createConstraints(false, 3, true); + const rhs = createConstraints(false, 3); + + expect(equivalent(lhs, rhs)).toBe(false); + }); + + it("should handle undefined constraints", () => { + const lhs = createConstraints(false); + const rhs = createConstraints(false); + + expect(equivalent(lhs, rhs)).toBe(true); + }); + + it("should handle empty constraint objects", () => { + const lhs = { + constraints: { + policyInEffect: false, + numWords: {}, + }, + } as unknown as GeneratorConstraints; + const rhs = { + constraints: { + policyInEffect: false, + numWords: {}, + }, + } as unknown as GeneratorConstraints; + + expect(equivalent(lhs, rhs)).toBe(true); + }); + + it("should return false when inner constraint properties differ", () => { + const lhs = { + constraints: { + policyInEffect: false, + numWords: { min: 3, max: 5 }, + }, + } as any; + const rhs = { + constraints: { + policyInEffect: false, + numWords: { min: 3, max: 6 }, + }, + } as unknown as GeneratorConstraints; + + expect(equivalent(lhs, rhs)).toBe(false); + }); +}); diff --git a/libs/tools/generator/core/src/util.ts b/libs/tools/generator/core/src/util.ts index 4b6041ffeb..62f749faf5 100644 --- a/libs/tools/generator/core/src/util.ts +++ b/libs/tools/generator/core/src/util.ts @@ -14,7 +14,11 @@ import { DefaultPassphraseGenerationOptions, DefaultPasswordGenerationOptions, } from "./data"; -import { PassphraseGenerationOptions, PasswordGenerationOptions } from "./types"; +import { + PassphraseGenerationOptions, + PasswordGenerationOptions, + GeneratorConstraints, +} from "./types"; /** construct a method that outputs a copy of `defaultValue` as an observable. */ export function observe$PerUserId( @@ -135,3 +139,30 @@ export function optionsToEffWordListRequest(options: PassphraseGenerationOptions return request; } + +export function equivalent(lhs: GeneratorConstraints, rhs: GeneratorConstraints): boolean { + if (lhs.constraints.policyInEffect !== rhs.constraints.policyInEffect) { + return false; + } + + // safe because `Constraints` shares keys with `T` + const keys = Object.keys(lhs.constraints) as (keyof T)[]; + + // use `for` loop so that `equivalent` can return as soon as the constraints + // differ. Using `array.xyz` would evaluate the whole key list eagerly + for (const k of keys) { + if (!(k in rhs.constraints)) { + return false; + } + + const lhsConstraints: any = lhs.constraints[k] ?? {}; + const rhsConstraints: any = rhs.constraints[k] ?? {}; + + const innerKeys = Object.keys(lhsConstraints); + if (innerKeys.some((k) => lhsConstraints[k] !== rhsConstraints[k])) { + return false; + } + } + + return true; +} diff --git a/libs/tools/generator/core/tsconfig.json b/libs/tools/generator/core/tsconfig.json index a95b588686..303f913ba2 100644 --- a/libs/tools/generator/core/tsconfig.json +++ b/libs/tools/generator/core/tsconfig.json @@ -8,10 +8,6 @@ "@bitwarden/key-management": ["../../../key-management/src"] } }, - "include": [ - "src", - "../extensions/src/history/generator-history.abstraction.ts", - "../extensions/src/navigation/generator-navigation.service.abstraction.ts" - ], + "include": ["src"], "exclude": ["node_modules", "dist"] } diff --git a/libs/tools/generator/extensions/history/src/generated-credential.spec.ts b/libs/tools/generator/extensions/history/src/generated-credential.spec.ts index 846f2ee96b..26a48cb83e 100644 --- a/libs/tools/generator/extensions/history/src/generated-credential.spec.ts +++ b/libs/tools/generator/extensions/history/src/generated-credential.spec.ts @@ -1,40 +1,42 @@ -import { GeneratorCategory, GeneratedCredential } from "."; +import { Type } from "@bitwarden/generator-core"; + +import { GeneratedCredential } from "."; describe("GeneratedCredential", () => { describe("constructor", () => { it("assigns credential", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); expect(result.credential).toEqual("example"); }); it("assigns category", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); - expect(result.category).toEqual("passphrase"); + expect(result.category).toEqual(Type.password); }); it("passes through date parameters", () => { - const result = new GeneratedCredential("example", "password", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); expect(result.generationDate).toEqual(new Date(100)); }); it("converts numeric dates to Dates", () => { - const result = new GeneratedCredential("example", "password", 100); + const result = new GeneratedCredential("example", Type.password, 100); expect(result.generationDate).toEqual(new Date(100)); }); }); it("toJSON converts from a credential into a JSON object", () => { - const credential = new GeneratedCredential("example", "password", new Date(100)); + const credential = new GeneratedCredential("example", Type.password, new Date(100)); const result = credential.toJSON(); expect(result).toEqual({ credential: "example", - category: "password" as GeneratorCategory, + category: Type.password, generationDate: 100, }); }); @@ -42,7 +44,7 @@ describe("GeneratedCredential", () => { it("fromJSON converts Json objects into credentials", () => { const jsonValue = { credential: "example", - category: "password" as GeneratorCategory, + category: Type.password, generationDate: 100, }; @@ -51,7 +53,7 @@ describe("GeneratedCredential", () => { expect(result).toBeInstanceOf(GeneratedCredential); expect(result).toEqual({ credential: "example", - category: "password", + category: Type.password, generationDate: new Date(100), }); }); diff --git a/libs/tools/generator/extensions/history/src/generated-credential.ts b/libs/tools/generator/extensions/history/src/generated-credential.ts index 32efb75225..bec9e5ac7e 100644 --- a/libs/tools/generator/extensions/history/src/generated-credential.ts +++ b/libs/tools/generator/extensions/history/src/generated-credential.ts @@ -1,6 +1,6 @@ import { Jsonify } from "type-fest"; -import { CredentialAlgorithm } from "@bitwarden/generator-core"; +import { CredentialType } from "@bitwarden/generator-core"; /** A credential generation result */ export class GeneratedCredential { @@ -14,7 +14,7 @@ export class GeneratedCredential { */ constructor( readonly credential: string, - readonly category: CredentialAlgorithm, + readonly category: CredentialType, generationDate: Date | number, ) { if (typeof generationDate === "number") { diff --git a/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts b/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts index 3b8a0e05a9..3e3d4002be 100644 --- a/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts +++ b/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts @@ -3,7 +3,7 @@ import { Observable } from "rxjs"; import { UserId } from "@bitwarden/common/types/guid"; -import { CredentialAlgorithm } from "@bitwarden/generator-core"; +import { CredentialType } from "@bitwarden/generator-core"; import { GeneratedCredential } from "./generated-credential"; @@ -29,7 +29,7 @@ export abstract class GeneratorHistoryService { track: ( userId: UserId, credential: string, - category: CredentialAlgorithm, + category: CredentialType, date?: Date, ) => Promise; diff --git a/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts b/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts index 3621b2c24a..caff423438 100644 --- a/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts +++ b/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts @@ -7,6 +7,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; +import { Type } from "@bitwarden/generator-core"; import { KeyService } from "@bitwarden/key-management"; import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../../common/spec"; @@ -25,7 +26,8 @@ describe("LocalGeneratorHistoryService", () => { encryptService.encryptString.mockImplementation((p) => Promise.resolve(p as unknown as EncString), ); - encryptService.decryptString.mockImplementation((c) => Promise.resolve(c.encryptedString)); + // in the test environment `c.encryptedString` always has a value + encryptService.decryptString.mockImplementation((c) => Promise.resolve(c.encryptedString!)); keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); keyService.userKey$.mockImplementation(() => of(true as unknown as UserKey)); }); @@ -50,35 +52,35 @@ describe("LocalGeneratorHistoryService", () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", Type.password); await awaitAsync(); const [result] = await firstValueFrom(history.credentials$(SomeUser)); - expect(result).toMatchObject({ credential: "example", category: "password" }); + expect(result).toMatchObject({ credential: "example", category: Type.password }); }); it("stores a passphrase", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "passphrase"); + await history.track(SomeUser, "example", Type.password); await awaitAsync(); const [result] = await firstValueFrom(history.credentials$(SomeUser)); - expect(result).toMatchObject({ credential: "example", category: "passphrase" }); + expect(result).toMatchObject({ credential: "example", category: Type.password }); }); it("stores a specific date when one is provided", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password", new Date(100)); + await history.track(SomeUser, "example", Type.password, new Date(100)); await awaitAsync(); const [result] = await firstValueFrom(history.credentials$(SomeUser)); expect(result).toEqual({ credential: "example", - category: "password", + category: Type.password, generationDate: new Date(100), }); }); @@ -87,13 +89,13 @@ describe("LocalGeneratorHistoryService", () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); - await history.track(SomeUser, "example", "password"); - await history.track(SomeUser, "example", "passphrase"); + await history.track(SomeUser, "example", Type.password); + await history.track(SomeUser, "example", Type.password); + await history.track(SomeUser, "example", Type.password); await awaitAsync(); const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); - expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(firstResult).toMatchObject({ credential: "example", category: Type.password }); expect(secondResult).toBeUndefined(); }); @@ -101,13 +103,13 @@ describe("LocalGeneratorHistoryService", () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "secondResult", "password"); - await history.track(SomeUser, "firstResult", "password"); + await history.track(SomeUser, "secondResult", Type.password); + await history.track(SomeUser, "firstResult", Type.password); await awaitAsync(); const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); - expect(firstResult).toMatchObject({ credential: "firstResult", category: "password" }); - expect(secondResult).toMatchObject({ credential: "secondResult", category: "password" }); + expect(firstResult).toMatchObject({ credential: "firstResult", category: Type.password }); + expect(secondResult).toMatchObject({ credential: "secondResult", category: Type.password }); }); it("removes history items exceeding maxTotal configuration", async () => { @@ -116,12 +118,12 @@ describe("LocalGeneratorHistoryService", () => { maxTotal: 1, }); - await history.track(SomeUser, "removed result", "password"); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "removed result", Type.password); + await history.track(SomeUser, "example", Type.password); await awaitAsync(); const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); - expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(firstResult).toMatchObject({ credential: "example", category: Type.password }); expect(secondResult).toBeUndefined(); }); @@ -131,8 +133,8 @@ describe("LocalGeneratorHistoryService", () => { maxTotal: 1, }); - await history.track(SomeUser, "some user example", "password"); - await history.track(AnotherUser, "another user example", "password"); + await history.track(SomeUser, "some user example", Type.password); + await history.track(AnotherUser, "another user example", Type.password); await awaitAsync(); const [someFirstResult, someSecondResult] = await firstValueFrom( history.credentials$(SomeUser), @@ -143,12 +145,12 @@ describe("LocalGeneratorHistoryService", () => { expect(someFirstResult).toMatchObject({ credential: "some user example", - category: "password", + category: Type.password, }); expect(someSecondResult).toBeUndefined(); expect(anotherFirstResult).toMatchObject({ credential: "another user example", - category: "password", + category: Type.password, }); expect(anotherSecondResult).toBeUndefined(); }); @@ -167,7 +169,7 @@ describe("LocalGeneratorHistoryService", () => { it("returns null when the credential wasn't found", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", Type.password); const result = await history.take(SomeUser, "not found"); @@ -177,20 +179,20 @@ describe("LocalGeneratorHistoryService", () => { it("returns a matching credential", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", Type.password); const result = await history.take(SomeUser, "example"); expect(result).toMatchObject({ credential: "example", - category: "password", + category: Type.password, }); }); it("removes a matching credential", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", Type.password); await history.take(SomeUser, "example"); await awaitAsync(); diff --git a/libs/tools/generator/extensions/history/src/local-generator-history.service.ts b/libs/tools/generator/extensions/history/src/local-generator-history.service.ts index cbfa55a184..673319c1bf 100644 --- a/libs/tools/generator/extensions/history/src/local-generator-history.service.ts +++ b/libs/tools/generator/extensions/history/src/local-generator-history.service.ts @@ -9,7 +9,7 @@ import { BufferedState } from "@bitwarden/common/tools/state/buffered-state"; import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer"; import { SecretState } from "@bitwarden/common/tools/state/secret-state"; import { UserId } from "@bitwarden/common/types/guid"; -import { CredentialAlgorithm } from "@bitwarden/generator-core"; +import { CredentialType } from "@bitwarden/generator-core"; import { KeyService } from "@bitwarden/key-management"; import { GeneratedCredential } from "./generated-credential"; @@ -36,12 +36,7 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { private _credentialStates = new Map>(); /** {@link GeneratorHistoryService.track} */ - track = async ( - userId: UserId, - credential: string, - category: CredentialAlgorithm, - date?: Date, - ) => { + track = async (userId: UserId, credential: string, category: CredentialType, date?: Date) => { const state = this.getCredentialState(userId); let result: GeneratedCredential = null; diff --git a/libs/tools/generator/core/src/data/forwarders.ts b/libs/tools/generator/extensions/legacy/src/forwarders.ts similarity index 73% rename from libs/tools/generator/core/src/data/forwarders.ts rename to libs/tools/generator/extensions/legacy/src/forwarders.ts index e833fbf41d..cb926ac3f6 100644 --- a/libs/tools/generator/core/src/data/forwarders.ts +++ b/libs/tools/generator/extensions/legacy/src/forwarders.ts @@ -1,4 +1,18 @@ -import { ForwarderMetadata } from "../types"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; + +export type ForwarderId = IntegrationId; + +/** Metadata format for email forwarding services. */ +export type ForwarderMetadata = { + /** The unique identifier for the forwarder. */ + id: ForwarderId; + + /** The name of the service the forwarder queries. */ + name: string; + + /** Whether the forwarder is valid for self-hosted instances of Bitwarden. */ + validForSelfHosted: boolean; +}; /** Metadata about an email forwarding service. * @remarks This is used to populate the forwarder selection list diff --git a/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts b/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts index d932d01319..f575cd3b61 100644 --- a/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts +++ b/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts @@ -1,13 +1,12 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; -import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { UserId } from "@bitwarden/common/types/guid"; import { GeneratorService, DefaultPassphraseGenerationOptions, DefaultPasswordGenerationOptions, - Policies, PassphraseGenerationOptions, PassphraseGeneratorPolicy, PasswordGenerationOptions, @@ -38,12 +37,17 @@ const PasswordGeneratorOptionsEvaluator = policies.PasswordGeneratorOptionsEvalu function createPassphraseGenerator( options: PassphraseGenerationOptions = {}, - policy: PassphraseGeneratorPolicy = Policies.Passphrase.disabledValue, + policy?: PassphraseGeneratorPolicy, ) { let savedOptions = options; const generator = mock>({ evaluator$(id: UserId) { - const evaluator = new PassphraseGeneratorOptionsEvaluator(policy); + const active = policy ?? { + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }; + const evaluator = new PassphraseGeneratorOptionsEvaluator(active); return of(evaluator); }, options$(id: UserId) { @@ -63,12 +67,21 @@ function createPassphraseGenerator( function createPasswordGenerator( options: PasswordGenerationOptions = {}, - policy: PasswordGeneratorPolicy = Policies.Password.disabledValue, + policy?: PasswordGeneratorPolicy, ) { let savedOptions = options; const generator = mock>({ evaluator$(id: UserId) { - const evaluator = new PasswordGeneratorOptionsEvaluator(policy); + const active = policy ?? { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }; + const evaluator = new PasswordGeneratorOptionsEvaluator(active); return of(evaluator); }, options$(id: UserId) { @@ -118,7 +131,13 @@ describe("LegacyPasswordGenerationService", () => { describe("generatePassword", () => { it("invokes the inner password generator to generate passwords", async () => { const innerPassword = createPasswordGenerator(); - const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null, null); + const generator = new LegacyPasswordGenerationService( + null!, + null!, + innerPassword, + null!, + null!, + ); const options = { type: "password" } as PasswordGeneratorOptions; await generator.generatePassword(options); @@ -129,11 +148,11 @@ describe("LegacyPasswordGenerationService", () => { it("invokes the inner passphrase generator to generate passphrases", async () => { const innerPassphrase = createPassphraseGenerator(); const generator = new LegacyPasswordGenerationService( - null, - null, - null, + null!, + null!, + null!, innerPassphrase, - null, + null!, ); const options = { type: "passphrase" } as PasswordGeneratorOptions; @@ -147,11 +166,11 @@ describe("LegacyPasswordGenerationService", () => { it("invokes the inner passphrase generator", async () => { const innerPassphrase = createPassphraseGenerator(); const generator = new LegacyPasswordGenerationService( - null, - null, - null, + null!, + null!, + null!, innerPassphrase, - null, + null!, ); const options = {} as PasswordGeneratorOptions; @@ -185,7 +204,7 @@ describe("LegacyPasswordGenerationService", () => { const navigation = createNavigationGenerator({ type: "passphrase", username: "word", - forwarder: "simplelogin" as IntegrationId, + forwarder: "simplelogin" as VendorId, }); const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( @@ -193,7 +212,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [result] = await generator.getOptions(); @@ -220,16 +239,16 @@ describe("LegacyPasswordGenerationService", () => { }); it("sets default options when an inner service lacks a value", async () => { - const innerPassword = createPasswordGenerator(null); - const innerPassphrase = createPassphraseGenerator(null); - const navigation = createNavigationGenerator(null); + const innerPassword = createPasswordGenerator(null!); + const innerPassphrase = createPassphraseGenerator(null!); + const navigation = createNavigationGenerator(null!); const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( accountService, navigation, innerPassword, innerPassphrase, - null, + null!, ); const [result] = await generator.getOptions(); @@ -277,7 +296,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [, policy] = await generator.getOptions(); @@ -323,7 +342,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); @@ -363,7 +382,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); @@ -409,7 +428,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({}); @@ -441,7 +460,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const options = { type: "password" as const, @@ -474,7 +493,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const options = { type: "passphrase" as const, @@ -496,7 +515,7 @@ describe("LegacyPasswordGenerationService", () => { const navigation = createNavigationGenerator({ type: "password", username: "forwarded", - forwarder: "firefoxrelay" as IntegrationId, + forwarder: "firefoxrelay" as VendorId, }); const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( @@ -504,7 +523,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const options = { type: "passphrase" as const, @@ -533,9 +552,9 @@ describe("LegacyPasswordGenerationService", () => { const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( accountService, - null, - null, - null, + null!, + null!, + null!, history, ); @@ -552,9 +571,9 @@ describe("LegacyPasswordGenerationService", () => { const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( accountService, - null, - null, - null, + null!, + null!, + null!, history, ); diff --git a/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.spec.ts b/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.spec.ts index 08ffce2eba..5a4dce4f4a 100644 --- a/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.spec.ts +++ b/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.spec.ts @@ -1,6 +1,15 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { AddyIo } from "@bitwarden/common/tools/extension/vendor/addyio"; +import { DuckDuckGo } from "@bitwarden/common/tools/extension/vendor/duckduckgo"; +import { Fastmail } from "@bitwarden/common/tools/extension/vendor/fastmail"; +import { ForwardEmail } from "@bitwarden/common/tools/extension/vendor/forwardemail"; +import { Mozilla } from "@bitwarden/common/tools/extension/vendor/mozilla"; +import { SimpleLogin } from "@bitwarden/common/tools/extension/vendor/simplelogin"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; import { UserId } from "@bitwarden/common/types/guid"; import { ApiOptions, @@ -13,13 +22,6 @@ import { DefaultCatchallOptions, DefaultEffUsernameOptions, EffUsernameGenerationOptions, - DefaultAddyIoOptions, - DefaultDuckDuckGoOptions, - DefaultFastmailOptions, - DefaultFirefoxRelayOptions, - DefaultForwardEmailOptions, - DefaultSimpleLoginOptions, - Forwarders, DefaultSubaddressOptions, SubaddressGenerationOptions, policies, @@ -169,7 +171,7 @@ describe("LegacyUsernameGenerationService", () => { // set up an arbitrary forwarder for the username test; all forwarders tested in their own tests const options = { type: "forwarded", - forwardedService: Forwarders.AddyIo.id, + forwardedService: AddyIo.id, } as UsernameGeneratorOptions; const addyIo = createGenerator(null, null); addyIo.generate.mockResolvedValue("addyio@example.com"); @@ -249,7 +251,7 @@ describe("LegacyUsernameGenerationService", () => { describe("generateForwarded", () => { it("should generate a AddyIo username", async () => { const options = { - forwardedService: Forwarders.AddyIo.id, + forwardedService: AddyIo.id, forwardedAnonAddyApiToken: "token", forwardedAnonAddyBaseUrl: "https://example.com", forwardedAnonAddyDomain: "example.com", @@ -284,7 +286,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a DuckDuckGo username", async () => { const options = { - forwardedService: Forwarders.DuckDuckGo.id, + forwardedService: DuckDuckGo.id, forwardedDuckDuckGoToken: "token", website: "example.com", } as UsernameGeneratorOptions; @@ -315,7 +317,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a Fastmail username", async () => { const options = { - forwardedService: Forwarders.Fastmail.id, + forwardedService: Fastmail.id, forwardedFastmailApiToken: "token", website: "example.com", } as UsernameGeneratorOptions; @@ -346,7 +348,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a FirefoxRelay username", async () => { const options = { - forwardedService: Forwarders.FirefoxRelay.id, + forwardedService: Mozilla.id, forwardedFirefoxApiToken: "token", website: "example.com", } as UsernameGeneratorOptions; @@ -377,7 +379,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a ForwardEmail username", async () => { const options = { - forwardedService: Forwarders.ForwardEmail.id, + forwardedService: ForwardEmail.id, forwardedForwardEmailApiToken: "token", forwardedForwardEmailDomain: "example.com", website: "example.com", @@ -410,7 +412,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a SimpleLogin username", async () => { const options = { - forwardedService: Forwarders.SimpleLogin.id, + forwardedService: SimpleLogin.id, forwardedSimpleLoginApiKey: "token", forwardedSimpleLoginBaseUrl: "https://example.com", website: "example.com", @@ -449,7 +451,7 @@ describe("LegacyUsernameGenerationService", () => { const navigation = createNavigationGenerator({ type: "username", username: "catchall", - forwarder: Forwarders.AddyIo.id, + forwarder: AddyIo.id, }); const catchall = createGenerator( @@ -557,7 +559,7 @@ describe("LegacyUsernameGenerationService", () => { subaddressEmail: "foo@example.com", catchallType: "random", catchallDomain: "example.com", - forwardedService: Forwarders.AddyIo.id, + forwardedService: AddyIo.id, forwardedAnonAddyApiToken: "addyIoToken", forwardedAnonAddyDomain: "addyio.example.com", forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", @@ -583,21 +585,36 @@ describe("LegacyUsernameGenerationService", () => { null, DefaultSubaddressOptions, ); - const addyIo = createGenerator( - null, - DefaultAddyIoOptions, - ); - const duckDuckGo = createGenerator(null, DefaultDuckDuckGoOptions); - const fastmail = createGenerator( - null, - DefaultFastmailOptions, - ); - const firefoxRelay = createGenerator(null, DefaultFirefoxRelayOptions); - const forwardEmail = createGenerator( - null, - DefaultForwardEmailOptions, - ); - const simpleLogin = createGenerator(null, DefaultSimpleLoginOptions); + const addyIo = createGenerator(null, { + website: null, + baseUrl: "https://app.addy.io", + token: "", + domain: "", + }); + const duckDuckGo = createGenerator(null, { + website: null, + token: "", + }); + const fastmail = createGenerator(null, { + website: "", + domain: "", + prefix: "", + token: "", + }); + const firefoxRelay = createGenerator(null, { + website: null, + token: "", + }); + const forwardEmail = createGenerator(null, { + website: null, + token: "", + domain: "", + }); + const simpleLogin = createGenerator(null, { + website: null, + baseUrl: "https://app.simplelogin.io", + token: "", + }); const generator = new LegacyUsernameGenerationService( account, @@ -624,16 +641,16 @@ describe("LegacyUsernameGenerationService", () => { subaddressType: DefaultSubaddressOptions.subaddressType, subaddressEmail: DefaultSubaddressOptions.subaddressEmail, forwardedService: DefaultGeneratorNavigation.forwarder, - forwardedAnonAddyApiToken: DefaultAddyIoOptions.token, - forwardedAnonAddyDomain: DefaultAddyIoOptions.domain, - forwardedAnonAddyBaseUrl: DefaultAddyIoOptions.baseUrl, - forwardedDuckDuckGoToken: DefaultDuckDuckGoOptions.token, - forwardedFastmailApiToken: DefaultFastmailOptions.token, - forwardedFirefoxApiToken: DefaultFirefoxRelayOptions.token, - forwardedForwardEmailApiToken: DefaultForwardEmailOptions.token, - forwardedForwardEmailDomain: DefaultForwardEmailOptions.domain, - forwardedSimpleLoginApiKey: DefaultSimpleLoginOptions.token, - forwardedSimpleLoginBaseUrl: DefaultSimpleLoginOptions.baseUrl, + forwardedAnonAddyApiToken: "", + forwardedAnonAddyDomain: "", + forwardedAnonAddyBaseUrl: "https://app.addy.io", + forwardedDuckDuckGoToken: "", + forwardedFastmailApiToken: "", + forwardedFirefoxApiToken: "", + forwardedForwardEmailApiToken: "", + forwardedForwardEmailDomain: "", + forwardedSimpleLoginApiKey: "", + forwardedSimpleLoginBaseUrl: "https://app.simplelogin.io", }); }); }); @@ -678,7 +695,7 @@ describe("LegacyUsernameGenerationService", () => { subaddressEmail: "foo@example.com", catchallType: "random", catchallDomain: "example.com", - forwardedService: Forwarders.AddyIo.id, + forwardedService: AddyIo.id as IntegrationId, forwardedAnonAddyApiToken: "addyIoToken", forwardedAnonAddyDomain: "addyio.example.com", forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", @@ -697,7 +714,7 @@ describe("LegacyUsernameGenerationService", () => { expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, { type: "password", username: "catchall", - forwarder: Forwarders.AddyIo.id, + forwarder: AddyIo.id, }); expect(catchall.saveOptions).toHaveBeenCalledWith(SomeUser, { diff --git a/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.ts b/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.ts index c6e9118535..48da3404cd 100644 --- a/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.ts +++ b/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.ts @@ -3,6 +3,7 @@ import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { UserId } from "@bitwarden/common/types/guid"; import { @@ -14,13 +15,13 @@ import { GeneratorService, CatchallGenerationOptions, EffUsernameGenerationOptions, - Forwarders, SubaddressGenerationOptions, UsernameGeneratorType, ForwarderId, } from "@bitwarden/generator-core"; import { GeneratorNavigationService, GeneratorNavigation } from "@bitwarden/generator-navigation"; +import { Forwarders } from "./forwarders"; import { UsernameGeneratorOptions } from "./username-generation-options"; import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; @@ -89,12 +90,14 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic const stored = this.toStoredOptions(options); switch (options.forwardedService) { case Forwarders.AddyIo.id: + case Vendor.addyio: return this.addyIo.generate(stored.forwarders.addyIo); case Forwarders.DuckDuckGo.id: return this.duckDuckGo.generate(stored.forwarders.duckDuckGo); case Forwarders.Fastmail.id: return this.fastmail.generate(stored.forwarders.fastmail); case Forwarders.FirefoxRelay.id: + case Vendor.mozilla: return this.firefoxRelay.generate(stored.forwarders.firefoxRelay); case Forwarders.ForwardEmail.id: return this.forwardEmail.generate(stored.forwarders.forwardEmail); @@ -232,22 +235,24 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic options: MappedOptions, ) { switch (forwarder) { - case "anonaddy": + case Forwarders.AddyIo.id: + case Vendor.addyio: await this.addyIo.saveOptions(account, options.forwarders.addyIo); return true; - case "duckduckgo": + case Forwarders.DuckDuckGo.id: await this.duckDuckGo.saveOptions(account, options.forwarders.duckDuckGo); return true; - case "fastmail": + case Forwarders.Fastmail.id: await this.fastmail.saveOptions(account, options.forwarders.fastmail); return true; - case "firefoxrelay": + case Forwarders.FirefoxRelay.id: + case Vendor.mozilla: await this.firefoxRelay.saveOptions(account, options.forwarders.firefoxRelay); return true; - case "forwardemail": + case Forwarders.ForwardEmail.id: await this.forwardEmail.saveOptions(account, options.forwarders.forwardEmail); return true; - case "simplelogin": + case Forwarders.SimpleLogin.id: await this.simpleLogin.saveOptions(account, options.forwarders.simpleLogin); return true; default: diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.spec.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.spec.ts index 218121a3a7..82b9e29e91 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.spec.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.spec.ts @@ -24,7 +24,7 @@ describe("GeneratorNavigationEvaluator", () => { describe("applyPolicy", () => { it("returns the input options when a policy is not in effect", () => { - const evaluator = new GeneratorNavigationEvaluator(null); + const evaluator = new GeneratorNavigationEvaluator(null!); const options = { type: "password" as const }; const result = evaluator.applyPolicy(options); @@ -54,7 +54,7 @@ describe("GeneratorNavigationEvaluator", () => { }); it("defaults options to the default generator navigation type when a policy is not in effect", () => { - const evaluator = new GeneratorNavigationEvaluator(null); + const evaluator = new GeneratorNavigationEvaluator(null!); const result = evaluator.sanitize({}); diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts index 2f891cdea0..5446c1f26a 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { PasswordAlgorithms, PolicyEvaluator } from "@bitwarden/generator-core"; +import { AlgorithmsByType, PolicyEvaluator, Type } from "@bitwarden/generator-core"; import { DefaultGeneratorNavigation } from "./default-generator-navigation"; import { GeneratorNavigation } from "./generator-navigation"; @@ -19,7 +19,7 @@ export class GeneratorNavigationEvaluator /** {@link PolicyEvaluator.policyInEffect} */ get policyInEffect(): boolean { - return PasswordAlgorithms.includes(this.policy?.overridePasswordType); + return AlgorithmsByType[Type.password].includes(this.policy?.overridePasswordType); } /** Apply policy to the input options. diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.ts index cba1f91dad..fc920a66e0 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.ts @@ -4,14 +4,14 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models // implement ADR-0002 import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { PasswordType } from "@bitwarden/generator-core"; +import { PasswordAlgorithm } from "@bitwarden/generator-core"; /** Policy settings affecting password generator navigation */ export type GeneratorNavigationPolicy = { /** The type of generator that should be shown by default when opening * the password generator. */ - overridePasswordType?: PasswordType; + overridePasswordType?: PasswordAlgorithm; }; /** Reduces a policy into an accumulator by preferring the password generator diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation.ts index 5a35e57d7b..08e5025244 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation.ts @@ -1,4 +1,5 @@ -import { GeneratorType, ForwarderId, UsernameGeneratorType } from "@bitwarden/generator-core"; +import { VendorId } from "@bitwarden/common/tools/extension"; +import { UsernameGeneratorType, CredentialAlgorithm } from "@bitwarden/generator-core"; /** Stores credential generator UI state. */ export type GeneratorNavigation = { @@ -6,11 +7,11 @@ export type GeneratorNavigation = { * @remarks The legacy generator only supports "password" and "passphrase". * The componentized generator supports all values. */ - type?: GeneratorType; + type?: CredentialAlgorithm; /** When `type === "username"`, this stores the username algorithm. */ username?: UsernameGeneratorType; /** When `username === "forwarded"`, this stores the forwarder implementation. */ - forwarder?: ForwarderId | ""; + forwarder?: VendorId | ""; }; diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index 8d24fc4ace..30775aa8a8 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -28,7 +28,7 @@ import { ToastService, TypographyModule, } from "@bitwarden/components"; -import { CredentialGeneratorService, GenerateRequest, Generators } from "@bitwarden/generator-core"; +import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -122,12 +122,12 @@ export class SendOptionsComponent implements OnInit { } generatePassword = async () => { - const on$ = new BehaviorSubject({ source: "send" }); + const on$ = new BehaviorSubject({ source: "send", type: Type.password }); const account$ = this.accountService.activeAccount$.pipe( pin({ name: () => "send-options.component", distinct: (p, c) => p.id === c.id }), ); const generatedCredential = await firstValueFrom( - this.generatorService.generate$(Generators.password, { on$, account$ }), + this.generatorService.generate$({ on$, account$ }), ); this.sendOptionsForm.patchValue({