From 947e4154a4a90ac847d0b5c376c493bf5178e783 Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Wed, 23 Oct 2024 10:25:29 -0400 Subject: [PATCH 01/11] Tools team code ownership for license lib files (#11664) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ad802d791e8..103401d1c97 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -31,6 +31,7 @@ libs/common/src/tools @bitwarden/team-tools-dev libs/importer @bitwarden/team-tools-dev libs/tools @bitwarden/team-tools-dev bitwarden_license/bit-web/src/app/tools @bitwarden/team-tools-dev +bitwarden_license/bit-common/src/tools @bitwarden/team-tools-dev ## Localization/Crowdin (Tools team) apps/browser/src/_locales @bitwarden/team-tools-dev From dfa7509c8eab3f72e9ab25dc73b6e5a33047dd2e Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 23 Oct 2024 08:20:34 -0700 Subject: [PATCH 02/11] account for potential null config in SendFilePopoutDialogContainerComponent (#11372) --- .../send-file-popout-dialog-container.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts index d3d481063e8..d535bbd86e3 100644 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts @@ -26,8 +26,8 @@ export class SendFilePopoutDialogContainerComponent implements OnInit { ngOnInit() { if ( - this.config.sendType === SendType.File && - this.config.mode === "add" && + this.config?.sendType === SendType.File && + this.config?.mode === "add" && this.filePopoutUtilsService.showFilePopoutMessage(window) ) { this.dialogService.open(SendFilePopoutDialogComponent); From c4fcd53ad2ad75a105da6cec6934faa7a0a7d63f Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 23 Oct 2024 10:23:51 -0500 Subject: [PATCH 03/11] [PM-13776] Generator Icon Button labels (#11623) * update aria labels for generate and copy buttons within the generator components - Using the `appA11yTitle` across all icon buttons - Updated all labels to be targeted towards the credential type rather than just "password" * add copy/generate passphrase translations to desktop * add fixme comments for translations * remove reference to JIRA ticket --- apps/browser/src/_locales/en/messages.json | 6 +++ apps/desktop/src/locales/en/messages.json | 7 ++++ apps/web/src/locales/en/messages.json | 7 ++++ .../src/credential-generator.component.html | 15 +++++--- .../src/credential-generator.component.ts | 38 +++++++++++++++++++ .../src/password-generator.component.html | 15 +++++--- .../src/password-generator.component.ts | 30 +++++++++++++++ .../src/username-generator.component.html | 17 ++++++--- 8 files changed, 117 insertions(+), 18 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 026d3b535ca..7fb21952ddf 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -107,6 +107,9 @@ "copyPassword": { "message": "Copy password" }, + "copyPassphrase": { + "message": "Copy passphrase" + }, "copyNote": { "message": "Copy note" }, @@ -407,6 +410,9 @@ "generatePassword": { "message": "Generate password" }, + "generatePassphrase": { + "message": "Generate passphrase" + }, "regeneratePassword": { "message": "Regenerate password" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 9924a91fa36..f119d7366d6 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -259,6 +259,9 @@ "generatePassword": { "message": "Generate password" }, + "generatePassphrase": { + "message": "Generate passphrase" + }, "type": { "message": "Type" }, @@ -394,6 +397,10 @@ "copyPassword": { "message": "Copy password" }, + "copyPassphrase": { + "message": "Copy passphrase", + "description": "Copy passphrase to clipboard" + }, "copyUri": { "message": "Copy URI" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index c50775efa6e..a27f13f9aee 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -405,6 +405,9 @@ "generatePassword": { "message": "Generate password" }, + "generatePassphrase": { + "message": "Generate passphrase" + }, "checkPassword": { "message": "Check if password has been exposed." }, @@ -663,6 +666,10 @@ "message": "Copy password", "description": "Copy password to clipboard" }, + "copyPassphrase": { + "message": "Copy passphrase", + "description": "Copy passphrase to clipboard" + }, "passwordCopied": { "message": "Password copied" }, diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index b174349ecef..53df58c8480 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -16,18 +16,21 @@
- + + >
{ + if (nav === "password") { + return this.i18nService.t("copyPassword"); + } + + if (nav === "passphrase") { + return this.i18nService.t("copyPassphrase"); + } + + return this.i18nService.t("copyUsername"); + }), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + * + * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. + */ + protected credentialTypeGenerateLabel$ = this.root$.pipe( + map(({ nav }) => { + if (nav === "password") { + return this.i18nService.t("generatePassword"); + } + + if (nav === "passphrase") { + return this.i18nService.t("generatePassphrase"); + } + + return this.i18nService.t("generateUsername"); + }), + ); + protected onRootChanged(nav: RootNavValue) { // prevent subscription cycle if (this.root$.value.nav !== nav) { diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index b4cf8c6cdb6..aecdf0f6a4d 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -14,18 +14,21 @@
- + + >
(null); + /** + * Emits the copy button aria-label respective of the selected credential + * + * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. + */ + protected credentialTypeCopyLabel$ = this.credentialType$.pipe( + map((cred) => { + if (cred === "password") { + return this.i18nService.t("copyPassword"); + } + + return this.i18nService.t("copyPassphrase"); + }), + ); + + /** + * Emits the generate button aria-label respective of the selected credential + * + * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. + */ + protected credentialTypeGenerateLabel$ = this.credentialType$.pipe( + map((cred) => { + if (cred === "password") { + return this.i18nService.t("generatePassword"); + } + + return this.i18nService.t("generatePassphrase"); + }), + ); + /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index e9d7d1c1f8c..ad8cd796123 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -3,18 +3,23 @@
- + + + + >
From 79d7d506df251e18dcbdc69e0c3aa59a85a0c28e Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Wed, 23 Oct 2024 11:39:57 -0400 Subject: [PATCH 04/11] [PM-12996] Updating UI Spacing for bit section header (#11609) * Adding space to the section header * Updating spacing to the left of the bit section header --- .../vault-list-items-container.component.html | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index bea6d9631ca..e89ec9472fb 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -1,18 +1,20 @@ - -

- {{ title }} -

- - {{ ciphers.length }} -
+
+ +

+ {{ title }} +

+ + {{ ciphers.length }} +
+
{{ description }}
From e67577cc39a82ca20a77625898de527397a3ee91 Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Wed, 23 Oct 2024 11:40:11 -0400 Subject: [PATCH 05/11] Updating chipSelect to be the new styling (#11593) --- .../vault-list-filters/vault-list-filters.component.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html index 0e241a81dcb..d9c4fbeee15 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html @@ -1,8 +1,12 @@
-
+ Date: Wed, 23 Oct 2024 12:11:42 -0400 Subject: [PATCH 06/11] [PM-8280] email forwarders (#11563) * forwarder lookup and generation support * localize algorithm names and descriptions in the credential generator service * add encryption support to UserStateSubject * move generic rx utilities to common * move icon button labels to generator configurations --- apps/browser/src/_locales/en/messages.json | 15 + apps/desktop/src/locales/en/messages.json | 18 + apps/web/src/locales/en/messages.json | 15 + libs/common/src/tools/dependencies.ts | 32 +- .../src/tools/integration/integration-id.ts | 14 +- libs/common/src/tools/private-classifier.ts | 31 + libs/common/src/tools/public-classifier.ts | 29 + libs/common/src/tools/rx.spec.ts | 496 +++++++++++++++- libs/common/src/tools/rx.ts | 125 +++- .../src/tools/state/classified-format.ts | 6 + .../tools/state/identity-state-constraint.ts | 26 +- libs/common/src/tools/state/object-key.ts | 53 ++ .../state/state-constraints-dependency.ts | 6 +- .../state/user-state-subject-dependencies.ts | 18 +- .../tools/state/user-state-subject.spec.ts | 315 +++++++--- .../src/tools/state/user-state-subject.ts | 416 +++++++++---- libs/common/src/tools/types.ts | 9 +- .../src/credential-generator.component.html | 27 +- .../src/credential-generator.component.ts | 376 ++++++++---- .../src/forwarder-settings.component.html | 16 + .../src/forwarder-settings.component.ts | 195 +++++++ .../components/src/generator.module.ts | 19 +- .../src/password-generator.component.ts | 54 +- .../src/username-generator.component.html | 23 +- .../src/username-generator.component.ts | 278 +++++++-- libs/tools/generator/components/src/util.ts | 2 +- .../core/src/data/generator-types.ts | 2 +- .../generator/core/src/data/generators.ts | 98 +++- .../generator/core/src/data/integrations.ts | 23 + .../src/engine/forwarder-configuration.ts | 35 +- .../generator/core/src/engine/forwarder.ts | 75 +++ .../generator/core/src/integration/addy-io.ts | 44 +- .../core/src/integration/duck-duck-go.ts | 42 +- .../core/src/integration/fastmail.ts | 46 +- .../core/src/integration/firefox-relay.ts | 42 +- .../core/src/integration/forward-email.ts | 41 +- .../core/src/integration/simple-login.ts | 42 +- libs/tools/generator/core/src/rx.spec.ts | 352 ----------- libs/tools/generator/core/src/rx.ts | 99 +--- .../credential-generator.service.spec.ts | 546 +++++++++++++++--- .../services/credential-generator.service.ts | 181 ++++-- .../credential-generator-configuration.ts | 78 ++- .../core/src/types/generator-type.ts | 31 +- libs/tools/generator/core/src/types/index.ts | 4 +- .../send-ui/src/send-form/send-form.module.ts | 13 +- 45 files changed, 3403 insertions(+), 1005 deletions(-) create mode 100644 libs/common/src/tools/private-classifier.ts create mode 100644 libs/common/src/tools/public-classifier.ts create mode 100644 libs/common/src/tools/state/object-key.ts create mode 100644 libs/tools/generator/components/src/forwarder-settings.component.html create mode 100644 libs/tools/generator/components/src/forwarder-settings.component.ts create mode 100644 libs/tools/generator/core/src/engine/forwarder.ts delete mode 100644 libs/tools/generator/core/src/rx.spec.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 7fb21952ddf..e72daaa1717 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1395,6 +1395,10 @@ "baseUrl": { "message": "Server URL" }, + "selfHostBaseUrl": { + "message": "Self-host server URL", + "description": "Label for field requesting a self-hosted integration service URL" + }, "apiUrl": { "message": "API server URL" }, @@ -2833,6 +2837,9 @@ "generateUsername": { "message": "Generate username" }, + "generateEmail": { + "message": "Generate email" + }, "usernameType": { "message": "Username type" }, @@ -2873,6 +2880,14 @@ "forwardedEmailDesc": { "message": "Generate an email alias with an external forwarding service." }, + "forwarderDomainName": { + "message": "Email domain", + "description": "Labels the domain name email forwarder service option" + }, + "forwarderDomainNameHint": { + "message": "Choose a domain that is supported by the selected service", + "description": "Guidance provided for email forwarding services that support multiple email domains." + }, "forwarderError": { "message": "$SERVICENAME$ error: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f119d7366d6..e04941bdb91 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -835,6 +835,10 @@ "baseUrl": { "message": "Server URL" }, + "selfHostBaseUrl": { + "message": "Self-host server URL", + "description": "Label for field requesting a self-hosted integration service URL" + }, "apiUrl": { "message": "API server URL" }, @@ -1225,6 +1229,9 @@ "message": "Copy number", "description": "Copy credit card number" }, + "copyEmail": { + "message": "Copy email" + }, "copySecurityCode": { "message": "Copy security code", "description": "Copy credit card security code (CVV)" @@ -2359,6 +2366,9 @@ "generateUsername": { "message": "Generate username" }, + "generateEmail": { + "message": "Generate email" + }, "usernameType": { "message": "Username type" }, @@ -2402,6 +2412,14 @@ "forwardedEmailDesc": { "message": "Generate an email alias with an external forwarding service." }, + "forwarderDomainName": { + "message": "Email domain", + "description": "Labels the domain name email forwarder service option" + }, + "forwarderDomainNameHint": { + "message": "Choose a domain that is supported by the selected service", + "description": "Guidance provided for email forwarding services that support multiple email domains." + }, "forwarderError": { "message": "$SERVICENAME$ error: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a27f13f9aee..07d94892adb 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6361,6 +6361,9 @@ "generateUsername": { "message": "Generate username" }, + "generateEmail": { + "message": "Generate email" + }, "usernameType": { "message": "Username type" }, @@ -6466,6 +6469,14 @@ "forwardedEmailDesc": { "message": "Generate an email alias with an external forwarding service." }, + "forwarderDomainName": { + "message": "Email domain", + "description": "Labels the domain name email forwarder service option" + }, + "forwarderDomainNameHint": { + "message": "Choose a domain that is supported by the selected service", + "description": "Guidance provided for email forwarding services that support multiple email domains." + }, "forwarderError": { "message": "$SERVICENAME$ error: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", @@ -8265,6 +8276,10 @@ "baseUrl": { "message": "Server URL" }, + "selfHostBaseUrl": { + "message": "Self-host server URL", + "description": "Label for field requesting a self-hosted integration service URL" + }, "aliasDomain": { "message": "Alias domain" }, diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 8b860591d54..84e2f53fa29 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -3,6 +3,8 @@ import { Observable } from "rxjs"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { UserId } from "@bitwarden/common/types/guid"; +import { UserEncryptor } from "./state/user-encryptor.abstraction"; + /** error emitted when the `SingleUserDependency` changes Ids */ export type UserChangedError = { /** the userId pinned by the single user dependency */ @@ -45,7 +47,35 @@ export type UserDependency = { userId$: Observable; }; -/** A pattern for types that depend upon a fixed userid and return +/** Decorates a type to indicate the user, if any, that the type is usable only by + * a specific user. + */ +export type UserBound = { [P in K]: T } & { + /** The user to which T is bound. */ + userId: UserId; +}; + +/** A pattern for types that depend upon a fixed-key encryptor and return + * an observable. + * + * Consumers of this dependency should emit a `UserChangedError` if + * the bound UserId changes or if the encryptor changes. If + * `singleUserEncryptor$` completes, the consumer should complete + * once all events received prior to the completion event are + * finished processing. The consumer should, where possible, + * prioritize these events in order to complete as soon as possible. + * If `singleUserEncryptor$` emits an unrecoverable error, the consumer + * should also emit the error. + */ +export type SingleUserEncryptorDependency = { + /** A stream that emits an encryptor when subscribed and the user key + * is available, and completes when the user key is no longer available. + * The stream should not emit null or undefined. + */ + singleUserEncryptor$: Observable>; +}; + +/** A pattern for types that depend upon a fixed-value userid and return * an observable. * * Consumers of this dependency should emit a `UserChangedError` if diff --git a/libs/common/src/tools/integration/integration-id.ts b/libs/common/src/tools/integration/integration-id.ts index 46b81c3c4c0..a15db143ee1 100644 --- a/libs/common/src/tools/integration/integration-id.ts +++ b/libs/common/src/tools/integration/integration-id.ts @@ -1,7 +1,13 @@ import { Opaque } from "type-fest"; +export const IntegrationIds = [ + "anonaddy", + "duckduckgo", + "fastmail", + "firefoxrelay", + "forwardemail", + "simplelogin", +] as const; + /** Identifies a vendor integrated into bitwarden */ -export type IntegrationId = Opaque< - "anonaddy" | "duckduckgo" | "fastmail" | "firefoxrelay" | "forwardemail" | "simplelogin", - "IntegrationId" ->; +export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">; diff --git a/libs/common/src/tools/private-classifier.ts b/libs/common/src/tools/private-classifier.ts new file mode 100644 index 00000000000..f9648504b76 --- /dev/null +++ b/libs/common/src/tools/private-classifier.ts @@ -0,0 +1,31 @@ +import { Jsonify } from "type-fest"; + +import { Classifier } from "@bitwarden/common/tools/state/classifier"; + +export class PrivateClassifier implements Classifier, Data> { + constructor(private keys: (keyof Jsonify)[] = undefined) {} + + classify(value: Data): { disclosed: Jsonify>; secret: Jsonify } { + const pickMe = JSON.parse(JSON.stringify(value)); + const keys: (keyof Jsonify)[] = this.keys ?? (Object.keys(pickMe) as any); + + const picked: Partial> = {}; + for (const key of keys) { + picked[key] = pickMe[key]; + } + const secret = picked as Jsonify; + + return { disclosed: null, secret }; + } + + declassify(_disclosed: Jsonify>, secret: Jsonify) { + const result: Partial> = {}; + const keys: (keyof Jsonify)[] = this.keys ?? (Object.keys(secret) as any); + + for (const key of keys) { + result[key] = secret[key]; + } + + return result as Jsonify; + } +} diff --git a/libs/common/src/tools/public-classifier.ts b/libs/common/src/tools/public-classifier.ts new file mode 100644 index 00000000000..82396f1c169 --- /dev/null +++ b/libs/common/src/tools/public-classifier.ts @@ -0,0 +1,29 @@ +import { Jsonify } from "type-fest"; + +import { Classifier } from "@bitwarden/common/tools/state/classifier"; + +export class PublicClassifier implements Classifier> { + constructor(private keys: (keyof Jsonify)[]) {} + + classify(value: Data): { disclosed: Jsonify; secret: Jsonify> } { + const pickMe = JSON.parse(JSON.stringify(value)); + + const picked: Partial> = {}; + for (const key of this.keys) { + picked[key] = pickMe[key]; + } + const disclosed = picked as Jsonify; + + return { disclosed, secret: null }; + } + + declassify(disclosed: Jsonify, _secret: Jsonify>) { + const result: Partial> = {}; + + for (const key of this.keys) { + result[key] = disclosed[key]; + } + + return result as Jsonify; + } +} diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index 8a2c1e38f5c..f6932f01dc1 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -2,11 +2,18 @@ * include structuredClone in test environment. * @jest-environment ../../../../shared/test.environment.ts */ -import { of, firstValueFrom } from "rxjs"; +import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs"; import { awaitAsync, trackEmissions } from "../../spec"; -import { distinctIfShallowMatch, reduceCollection } from "./rx"; +import { + anyComplete, + distinctIfShallowMatch, + on, + ready, + reduceCollection, + withLatestReady, +} from "./rx"; describe("reduceCollection", () => { it.each([[null], [undefined], [[]]])( @@ -84,3 +91,488 @@ describe("distinctIfShallowMatch", () => { 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); + }); +}); diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts index d2c5747a882..d5d0b499ff2 100644 --- a/libs/common/src/tools/rx.ts +++ b/libs/common/src/tools/rx.ts @@ -1,4 +1,21 @@ -import { map, distinctUntilChanged, OperatorFunction } from "rxjs"; +import { + map, + distinctUntilChanged, + OperatorFunction, + Observable, + ignoreElements, + endWith, + race, + pipe, + connect, + ReplaySubject, + concat, + zip, + first, + takeUntil, + withLatestFrom, + concatMap, +} from "rxjs"; /** * An observable operator that reduces an emitted collection to a single object, @@ -36,3 +53,109 @@ export function distinctIfShallowMatch(): OperatorFunction { return isDistinct; }); } + +/** Create an observable that, once subscribed, emits `true` then completes when + * any input completes. If an input is already complete when the subscription + * occurs, it emits immediately. + * @param watch$ the observable(s) to watch for completion; if an array is passed, + * null and undefined members are ignored. If `watch$` is empty, `anyComplete` + * will never complete. + * @returns An observable that emits `true` when any of its inputs + * complete. The observable forwards the first error from its input. + * @remarks This method is particularly useful in combination with `takeUntil` and + * streams that are not guaranteed to complete on their own. + */ +export function anyComplete(watch$: Observable | Observable[]): Observable { + if (Array.isArray(watch$)) { + const completes$ = watch$ + .filter((w$) => !!w$) + .map((w$) => w$.pipe(ignoreElements(), endWith(true))); + const completed$ = race(completes$); + return completed$; + } else { + return watch$.pipe(ignoreElements(), endWith(true)); + } +} + +/** + * Create an observable that delays the input stream until all watches have + * emitted a value. The watched values are not included in the source stream. + * The last emission from the source is output when all the watches have + * emitted at least once. + * @param watch$ the observable(s) to watch for readiness. If `watch$` is empty, + * `ready` will never emit. + * @returns An observable that emits when the source stream emits. The observable + * errors if one of its watches completes before emitting. It also errors if one + * of its watches errors. + */ +export function ready(watch$: Observable | Observable[]) { + const watching$ = Array.isArray(watch$) ? watch$ : [watch$]; + return pipe( + connect>((source$) => { + // this subscription is safe because `source$` connects only after there + // is an external subscriber. + const source = new ReplaySubject(1); + source$.subscribe(source); + + // `concat` is subscribed immediately after it's returned, at which point + // `zip` blocks until all items in `watching$` 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(watching$).pipe(first(), ignoreElements()), source).pipe( + takeUntil(anyComplete(source)), + ); + }), + ); +} + +export function withLatestReady( + watch$: Observable, +): 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); + + // `concat` is subscribed immediately after it's returned, at which point + // `zip` blocks until all items in `watching$` 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), + takeUntil(anyComplete(source)), + ); + }); +} + +/** + * Create an observable that emits the latest value of the source stream + * when `watch$` emits. If `watch$` emits before the stream emits, then + * an emission occurs as soon as a value becomes ready. + * @param watch$ the observable that triggers emissions + * @returns An observable that emits when `watch$` emits. The observable + * errors if its source stream errors. It also errors if `on` errors. It + * completes if its watch completes. + * + * @remarks This works like `audit`, but it repeats emissions when + * watch$ fires. + */ +export function on(watch$: Observable) { + return pipe( + connect>((source$) => { + const source = new ReplaySubject(1); + source$.subscribe(source); + + return watch$ + .pipe( + ready(source), + concatMap(() => source.pipe(first())), + ) + .pipe(takeUntil(anyComplete(source))); + }), + ); +} diff --git a/libs/common/src/tools/state/classified-format.ts b/libs/common/src/tools/state/classified-format.ts index 93147a0fb53..26aca0197c5 100644 --- a/libs/common/src/tools/state/classified-format.ts +++ b/libs/common/src/tools/state/classified-format.ts @@ -17,3 +17,9 @@ export type ClassifiedFormat = { */ readonly disclosed: Jsonify; }; + +export function isClassifiedFormat( + value: any, +): value is ClassifiedFormat { + return "id" in value && "secret" in value && "disclosed" in value; +} diff --git a/libs/common/src/tools/state/identity-state-constraint.ts b/libs/common/src/tools/state/identity-state-constraint.ts index ff7712b9091..df33dad543a 100644 --- a/libs/common/src/tools/state/identity-state-constraint.ts +++ b/libs/common/src/tools/state/identity-state-constraint.ts @@ -1,4 +1,11 @@ -import { Constraints, StateConstraints } from "../types"; +import { BehaviorSubject, Observable } from "rxjs"; + +import { + Constraints, + DynamicStateConstraints, + StateConstraints, + SubjectConstraints, +} from "../types"; // The constraints type shares the properties of the state, // but never has any members @@ -9,16 +16,31 @@ const EMPTY_CONSTRAINTS = new Proxy(Object.freeze({}), { }); /** A constraint that does nothing. */ -export class IdentityConstraint implements StateConstraints { +export class IdentityConstraint + implements StateConstraints, DynamicStateConstraints +{ /** Instantiate the identity constraint */ constructor() {} readonly constraints: Readonly> = EMPTY_CONSTRAINTS; + calibrate() { + return this; + } + adjust(state: State) { return state; } + fix(state: State) { return state; } } + +/** Emits a constraint that does not alter the input state. */ +export function unconstrained$(): Observable> { + const identity = new IdentityConstraint(); + const constraints$ = new BehaviorSubject(identity); + + return constraints$; +} diff --git a/libs/common/src/tools/state/object-key.ts b/libs/common/src/tools/state/object-key.ts new file mode 100644 index 00000000000..88365d5cbd1 --- /dev/null +++ b/libs/common/src/tools/state/object-key.ts @@ -0,0 +1,53 @@ +import { UserKeyDefinition, UserKeyDefinitionOptions } from "../../platform/state"; +// eslint-disable-next-line -- `StateDefinition` used as a type +import type { StateDefinition } from "../../platform/state/state-definition"; + +import { ClassifiedFormat } from "./classified-format"; +import { Classifier } from "./classifier"; + +/** A key for storing JavaScript objects (`{ an: "example" }`) + * in a UserStateSubject. + */ +// FIXME: promote to class: `ObjectConfiguration`. +// The class receives `encryptor`, `prepareNext`, `adjust`, and `fix` +// From `UserStateSubject`. `UserStateSubject` keeps `classify` and +// `declassify`. The class should also include serialization +// facilities (to be used in place of JSON.parse/stringify) in it's +// options. Also allow swap between "classifier" and "classification"; the +// latter is a list of properties/arguments to the specific classifier in-use. +export type ObjectKey> = { + target: "object"; + key: string; + state: StateDefinition; + classifier: Classifier; + format: "plain" | "classified"; + options: UserKeyDefinitionOptions; +}; + +export function isObjectKey(key: any): key is ObjectKey { + return key.target === "object" && "format" in key && "classifier" in key; +} + +export function toUserKeyDefinition( + key: ObjectKey, +) { + if (key.format === "plain") { + const plain = new UserKeyDefinition(key.state, key.key, key.options); + + return plain; + } else if (key.format === "classified") { + const classified = new UserKeyDefinition>( + key.state, + key.key, + { + cleanupDelayMs: key.options.cleanupDelayMs, + deserializer: (jsonValue) => jsonValue as ClassifiedFormat, + clearOn: key.options.clearOn, + }, + ); + + return classified; + } else { + throw new Error(`unknown format: ${key.format}`); + } +} diff --git a/libs/common/src/tools/state/state-constraints-dependency.ts b/libs/common/src/tools/state/state-constraints-dependency.ts index 66bac636bd7..427ff42e7a4 100644 --- a/libs/common/src/tools/state/state-constraints-dependency.ts +++ b/libs/common/src/tools/state/state-constraints-dependency.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { DynamicStateConstraints, StateConstraints } from "../types"; +import { DynamicStateConstraints, StateConstraints, SubjectConstraints } from "../types"; /** A pattern for types that depend upon a dynamic set of constraints. * @@ -10,12 +10,12 @@ import { DynamicStateConstraints, StateConstraints } from "../types"; * last-emitted constraints. If `constraints$` completes, the consumer should * continue using the last-emitted constraints. */ -export type StateConstraintsDependency = { +export type SubjectConstraintsDependency = { /** A stream that emits constraints when subscribed and when the * constraints change. The stream should not emit `null` or * `undefined`. */ - constraints$: Observable | DynamicStateConstraints>; + constraints$: Observable>; }; /** Returns `true` if the input constraint is a `DynamicStateConstraints`. diff --git a/libs/common/src/tools/state/user-state-subject-dependencies.ts b/libs/common/src/tools/state/user-state-subject-dependencies.ts index 7f36ab7cae8..0ba842334bf 100644 --- a/libs/common/src/tools/state/user-state-subject-dependencies.ts +++ b/libs/common/src/tools/state/user-state-subject-dependencies.ts @@ -1,15 +1,23 @@ -import { Simplify } from "type-fest"; +import { RequireExactlyOne, Simplify } from "type-fest"; -import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies"; +import { + Dependencies, + SingleUserDependency, + SingleUserEncryptorDependency, + WhenDependency, +} from "../dependencies"; -import { StateConstraintsDependency } from "./state-constraints-dependency"; +import { SubjectConstraintsDependency } from "./state-constraints-dependency"; /** dependencies accepted by the user state subject */ export type UserStateSubjectDependencies = Simplify< - SingleUserDependency & + RequireExactlyOne< + SingleUserDependency & SingleUserEncryptorDependency, + "singleUserEncryptor$" | "singleUserId$" + > & Partial & Partial> & - Partial> & { + Partial> & { /** Compute the next stored value. If this is not set, values * provided to `next` unconditionally override state. * @param current the value stored in state 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 73971da4ef9..9f5475df9de 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -1,14 +1,50 @@ import { BehaviorSubject, of, Subject } from "rxjs"; +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec"; +import { UserBound } from "../dependencies"; +import { PrivateClassifier } from "../private-classifier"; import { StateConstraints } from "../types"; +import { ClassifiedFormat } from "./classified-format"; +import { ObjectKey } from "./object-key"; +import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubject } from "./user-state-subject"; const SomeUser = "some user" as UserId; type TestType = { foo: string }; +const SomeKey = new UserKeyDefinition(GENERATOR_DISK, "TestKey", { + deserializer: (d) => d as TestType, + clearOn: [], +}); + +const SomeObjectKey = { + target: "object", + key: "TestObjectKey", + state: GENERATOR_DISK, + classifier: new PrivateClassifier(), + format: "classified", + options: { + deserializer: (d) => d as TestType, + clearOn: ["logout"], + }, +} satisfies ObjectKey; + +const SomeEncryptor: UserEncryptor = { + userId: SomeUser, + + encrypt(secret) { + const tmp: any = secret; + return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any); + }, + + decrypt(secret) { + const tmp: any = JSON.parse(secret.encryptedString); + return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any); + }, +}; function fooMaxLength(maxLength: number): StateConstraints { return Object.freeze({ @@ -43,7 +79,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously subject.next({ foo: "next" }); @@ -65,7 +105,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously subject.next({ foo: "next" }); @@ -79,11 +123,35 @@ describe("UserStateSubject", () => { expect(nextValue).toHaveBeenCalledTimes(1); }); + it("ignores repeated singleUserEncryptor$ emissions", async () => { + // this test looks for `nextValue` because a subscription isn't necessary for + // the subject to update + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const nextValue = jest.fn((_, next) => next); + const singleUserEncryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor: null }); + const subject = new UserStateSubject(SomeKey, () => state, { + nextValue, + singleUserEncryptor$, + }); + + // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously + subject.next({ foo: "next" }); + await awaitAsync(); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); + await awaitAsync(); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); + await awaitAsync(); + + expect(nextValue).toHaveBeenCalledTimes(1); + }); + it("waits for constraints$", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new Subject>(); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.next(fooMaxLength(3)); @@ -91,13 +159,28 @@ describe("UserStateSubject", () => { expect(initResult).toEqual({ foo: "ini" }); }); + + it("waits for singleUserEncryptor$", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: {} }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); + const tracker = new ObservableTracker(subject); + + singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor }); + const [initResult] = await tracker.pauseUntilReceived(1); + + expect(initResult).toEqual({ foo: "decrypt(init)" }); + }); }); describe("next", () => { it("emits the next value", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const expected: TestType = { foo: "next" }; let actual: TestType = null; @@ -114,7 +197,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual: TestType = null; subject.subscribe((value) => { @@ -132,7 +215,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => true); - const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -147,7 +230,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => true); const dependencyValue = { bar: "dependency" }; - const subject = new UserStateSubject(state, { + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate, dependencies$: of(dependencyValue), @@ -165,7 +248,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => true); - const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); const expected: TestType = { foo: "next" }; let actual: TestType = null; @@ -183,7 +266,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => false); - const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); subject.next({ foo: "next" }); await awaitAsync(); @@ -200,7 +283,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); - const subject = new UserStateSubject(state, { singleUserId$, nextValue }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -215,7 +298,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const dependencyValue = { bar: "dependency" }; - const subject = new UserStateSubject(state, { + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue, dependencies$: of(dependencyValue), @@ -236,7 +319,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -253,7 +340,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(false); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -265,42 +356,52 @@ describe("UserStateSubject", () => { expect(nextValue).toHaveBeenCalled(); }); - it("waits to evaluate nextValue until singleUserId$ emits", async () => { - // this test looks for `nextValue` because a subscription isn't necessary for + it("waits to evaluate `UserState.update` until singleUserId$ emits", async () => { + // this test looks for `nextMock` because a subscription isn't necessary for // the subject to update. const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new Subject(); - const nextValue = jest.fn((_, next) => next); - const subject = new UserStateSubject(state, { singleUserId$, nextValue }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); + // precondition: subject doesn't update after `next` const nextVal: TestType = { foo: "next" }; subject.next(nextVal); await awaitAsync(); - expect(nextValue).not.toHaveBeenCalled(); + expect(state.nextMock).not.toHaveBeenCalled(); + singleUserId$.next(SomeUser); await awaitAsync(); - expect(nextValue).toHaveBeenCalled(); + expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" }); }); - it("applies constraints$ on init", async () => { - const state = new FakeSingleUserState(SomeUser, { foo: "init" }); - const singleUserId$ = new BehaviorSubject(SomeUser); - const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); - const tracker = new ObservableTracker(subject); + it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: null }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); - const [result] = await tracker.pauseUntilReceived(1); + // precondition: subject doesn't update after `next` + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + expect(state.nextMock).not.toHaveBeenCalled(); - expect(result).toEqual({ foo: "in" }); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor }); + await awaitAsync(); + + const encrypted = { foo: "encrypt(next)" }; + expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null }); }); it("applies dynamic constraints", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(DynamicFooMaxLength); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); const expected: TestType = { foo: "next" }; const emission = tracker.expectEmission(); @@ -311,24 +412,11 @@ describe("UserStateSubject", () => { expect(actual).toEqual({ foo: "" }); }); - it("applies constraints$ on constraints$ emission", async () => { - const state = new FakeSingleUserState(SomeUser, { foo: "init" }); - const singleUserId$ = new BehaviorSubject(SomeUser); - const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); - const tracker = new ObservableTracker(subject); - - constraints$.next(fooMaxLength(1)); - const [, result] = await tracker.pauseUntilReceived(2); - - expect(result).toEqual({ foo: "i" }); - }); - it("applies constraints$ on next", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); subject.next({ foo: "next" }); @@ -341,7 +429,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.next(fooMaxLength(3)); @@ -355,13 +443,17 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new Subject>(); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); - const tracker = new ObservableTracker(subject); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); + const results: any[] = []; + subject.subscribe((r) => { + results.push(r); + }); subject.next({ foo: "next" }); constraints$.next(fooMaxLength(3)); + await awaitAsync(); // `init` is also waiting and is processed before `next` - const [, nextResult] = await tracker.pauseUntilReceived(2); + const [, nextResult] = results; expect(nextResult).toEqual({ foo: "nex" }); }); @@ -370,7 +462,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(3)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.error({ some: "error" }); @@ -384,7 +476,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(3)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.complete(); @@ -399,7 +491,7 @@ describe("UserStateSubject", () => { it("emits errors", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const expected: TestType = { foo: "error" }; let actual: TestType = null; @@ -418,7 +510,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual: TestType = null; subject.subscribe({ @@ -437,7 +529,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let shouldNotRun = false; subject.subscribe({ @@ -457,7 +549,7 @@ describe("UserStateSubject", () => { it("emits completes", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual = false; subject.subscribe({ @@ -475,7 +567,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let shouldNotRun = false; subject.subscribe({ @@ -496,7 +588,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let timesRun = 0; subject.subscribe({ @@ -513,11 +605,36 @@ describe("UserStateSubject", () => { }); describe("subscribe", () => { + it("applies constraints$ on init", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + const [result] = await tracker.pauseUntilReceived(1); + + expect(result).toEqual({ foo: "in" }); + }); + + it("applies constraints$ on constraints$ emission", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + constraints$.next(fooMaxLength(1)); + const [, result] = await tracker.pauseUntilReceived(2); + + expect(result).toEqual({ foo: "i" }); + }); + it("completes when singleUserId$ completes", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual = false; subject.subscribe({ @@ -531,12 +648,32 @@ describe("UserStateSubject", () => { expect(actual).toBeTruthy(); }); + it("completes when singleUserId$ completes", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: null }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); + + let actual = false; + subject.subscribe({ + complete: () => { + actual = true; + }, + }); + singleUserEncryptor$.complete(); + await awaitAsync(); + + expect(actual).toBeTruthy(); + }); + it("completes when when$ completes", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ }); let actual = false; subject.subscribe({ @@ -557,7 +694,7 @@ describe("UserStateSubject", () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const errorUserId = "error" as UserId; let error = false; @@ -572,11 +709,32 @@ describe("UserStateSubject", () => { expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId }); }); + it("errors when singleUserEncryptor$ changes", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: null }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); + const errorUserId = "error" as UserId; + + let error = false; + subject.subscribe({ + error: (e: unknown) => { + error = e as any; + }, + }); + singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor }); + await awaitAsync(); + + expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId }); + }); + it("errors when singleUserId$ errors", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const expected = { error: "description" }; let actual = false; @@ -591,12 +749,31 @@ describe("UserStateSubject", () => { expect(actual).toEqual(expected); }); + it("errors when singleUserEncryptor$ errors", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserEncryptor$ }); + const expected = { error: "description" }; + + let actual = false; + subject.subscribe({ + error: (e: unknown) => { + actual = e as any; + }, + }); + singleUserEncryptor$.error(expected); + await awaitAsync(); + + expect(actual).toEqual(expected); + }); + it("errors when when$ errors", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ }); const expected = { error: "description" }; let actual = false; @@ -616,7 +793,7 @@ describe("UserStateSubject", () => { it("returns the userId to which the subject is bound", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new Subject(); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); expect(subject.userId).toEqual(SomeUser); }); @@ -626,7 +803,7 @@ describe("UserStateSubject", () => { it("emits the next value with an empty constraint", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected: TestType = { foo: "next" }; const emission = tracker.expectEmission(); @@ -642,7 +819,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const tracker = new ObservableTracker(subject.withConstraints$); subject.complete(); @@ -657,7 +834,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected = fooMaxLength(1); const emission = tracker.expectEmission(); @@ -673,7 +850,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(DynamicFooMaxLength); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected: TestType = { foo: "next" }; const emission = tracker.expectEmission(); @@ -690,7 +867,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const expected = fooMaxLength(2); const constraints$ = new BehaviorSubject(expected); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const emission = tracker.expectEmission(); @@ -705,7 +882,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected = fooMaxLength(3); constraints$.next(expected); @@ -722,7 +899,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new Subject>(); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected = fooMaxLength(3); @@ -740,7 +917,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const expected = fooMaxLength(3); const constraints$ = new BehaviorSubject(expected); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); constraints$.error({ some: "error" }); @@ -756,7 +933,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const expected = fooMaxLength(3); const constraints$ = new BehaviorSubject(expected); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); constraints$.complete(); diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 61a9e87c686..89f19ac3c73 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -5,15 +5,10 @@ import { ReplaySubject, filter, map, - Subject, takeUntil, pairwise, - combineLatest, distinctUntilChanged, BehaviorSubject, - race, - ignoreElements, - endWith, startWith, Observable, Subscription, @@ -22,16 +17,32 @@ import { combineLatestWith, catchError, EMPTY, + concatMap, + OperatorFunction, + pipe, + first, + withLatestFrom, + scan, + skip, } from "rxjs"; -import { SingleUserState } from "@bitwarden/common/platform/state"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; -import { WithConstraints } from "../types"; +import { UserBound } from "../dependencies"; +import { anyComplete, ready, withLatestReady } from "../rx"; +import { Constraints, SubjectConstraints, WithConstraints } from "../types"; -import { IdentityConstraint } from "./identity-state-constraint"; +import { ClassifiedFormat, isClassifiedFormat } from "./classified-format"; +import { unconstrained$ } from "./identity-state-constraint"; +import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key"; import { isDynamic } from "./state-constraints-dependency"; +import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"; +type Constrained = { constraints: Readonly>; state: State }; + /** * Adapt a state provider to an rxjs subject. * @@ -44,14 +55,20 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies" * @template State the state stored by the subject * @template Dependencies use-specific dependencies provided by the user. */ -export class UserStateSubject +export class UserStateSubject< + State extends object, + Secret = State, + Disclosed = never, + Dependencies = null, + > extends Observable implements SubjectLike { /** - * Instantiates the user state subject - * @param state the backing store of the subject - * @param dependencies tailor the subject's behavior for a particular + * Instantiates the user state subject bound to a persistent backing store + * @param key identifies the persistent backing store + * @param getState creates a persistent backing store using a key + * @param context tailor the subject's behavior for a particular * purpose. * @param dependencies.when$ blocks updates to the state subject until * this becomes true. When this occurs, only the last-received update @@ -61,93 +78,306 @@ export class UserStateSubject * is available. */ constructor( - private state: SingleUserState, - private dependencies: UserStateSubjectDependencies, + private key: UserKeyDefinition | ObjectKey, + getState: (key: UserKeyDefinition) => SingleUserState, + private context: UserStateSubjectDependencies, ) { super(); + if (isObjectKey(this.key)) { + // classification and encryption only supported with `ObjectKey` + this.objectKey = this.key; + this.stateKey = toUserKeyDefinition(this.key); + this.state = getState(this.stateKey); + } else { + // raw state access granted with `UserKeyDefinition` + this.objectKey = null; + this.stateKey = this.key as UserKeyDefinition; + this.state = getState(this.stateKey); + } + // normalize dependencies - const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe( - distinctUntilChanged(), - ); - const userIdAvailable$ = this.dependencies.singleUserId$.pipe( - startWith(state.userId), - pairwise(), - map(([expectedUserId, actualUserId]) => { - if (expectedUserId === actualUserId) { - return true; - } else { - throw { expectedUserId, actualUserId }; - } - }), - distinctUntilChanged(), - ); - const constraints$ = ( - this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint()) - ).pipe( - // FIXME: this should probably log that an error occurred - catchError(() => EMPTY), - ); + const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged()); - // normalize input in case this `UserStateSubject` is not the only - // observer of the backing store - const input$ = combineLatest([this.input, constraints$]).pipe( - map(([input, constraints]) => { - const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints; - const state = calibration.adjust(input); - return state; - }), - ); + // manage dependencies through replay subjects since `UserStateSubject` + // reads them in multiple places + const encryptor$ = new ReplaySubject(1); + const { singleUserId$, singleUserEncryptor$ } = this.context; + this.encryptor(singleUserEncryptor$ ?? singleUserId$).subscribe(encryptor$); - // when the output subscription completes, its last-emitted value - // loops around to the input for finalization - const finalize$ = this.pipe( - last(), - combineLatestWith(constraints$), - map(([output, constraints]) => { - const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints; - const state = calibration.fix(output); - return state; - }), - ); - const updates$ = concat(input$, finalize$); + const constraints$ = new ReplaySubject>(1); + (this.context.constraints$ ?? unconstrained$()) + .pipe( + // FIXME: this should probably log that an error occurred + catchError(() => EMPTY), + ) + .subscribe(constraints$); - // observe completion - const whenComplete$ = when$.pipe(ignoreElements(), endWith(true)); - const inputComplete$ = this.input.pipe(ignoreElements(), endWith(true)); - const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true)); - const completion$ = race(whenComplete$, inputComplete$, userIdComplete$); + const dependencies$ = new ReplaySubject(1); + if (this.context.dependencies$) { + this.context.dependencies$.subscribe(dependencies$); + } else { + dependencies$.next(null); + } // wire output before input so that output normalizes the current state // before any `next` value is processed this.outputSubscription = this.state.state$ - .pipe( - combineLatestWith(constraints$), - map(([rawState, constraints]) => { - const calibration = isDynamic(constraints) - ? constraints.calibrate(rawState) - : constraints; - const state = calibration.adjust(rawState); - return { - constraints: calibration.constraints, - state, - }; - }), - ) + .pipe(this.declassify(encryptor$), this.adjust(combineLatestWith(constraints$))) .subscribe(this.output); - this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$]) + + const last$ = new ReplaySubject(1); + this.output .pipe( - filter(([_, when]) => when), - map(([state]) => state), - takeUntil(completion$), + last(), + map((o) => o.state), ) + .subscribe(last$); + + // the update stream simulates the stateProvider's "shouldUpdate" + // functionality & applies policy + const updates$ = concat( + this.input.pipe( + this.when(when$), + this.adjust(withLatestReady(constraints$)), + this.prepareUpdate(this, dependencies$), + ), + // when the output subscription completes, its last-emitted value + // loops around to the input for finalization + last$.pipe(this.fix(constraints$), this.prepareUpdate(last$, dependencies$)), + ); + + // classification/encryption bound to the input subscription's lifetime + // to ensure that `fix` has access to the encryptor key + // + // FIXME: this should probably timeout when a lock occurs + this.inputSubscription = updates$ + .pipe(this.classify(encryptor$), takeUntil(anyComplete([when$, this.input, encryptor$]))) .subscribe({ - next: (r) => this.onNext(r), + next: (state) => this.onNext(state), error: (e: unknown) => this.onError(e), complete: () => this.onComplete(), }); } + private stateKey: UserKeyDefinition; + private objectKey: ObjectKey; + + private encryptor( + singleUserEncryptor$: Observable | UserId>, + ): Observable { + return singleUserEncryptor$.pipe( + // normalize inputs + map((maybe): UserBound<"encryptor", UserEncryptor> => { + if (typeof maybe === "object" && "encryptor" in maybe) { + return maybe; + } else if (typeof maybe === "string") { + return { encryptor: null, userId: maybe as UserId }; + } else { + throw new Error(`Invalid encryptor input received for ${this.key.key}.`); + } + }), + // fail the stream if the state desyncs from the bound userId + startWith({ userId: this.state.userId, encryptor: null } as UserBound< + "encryptor", + UserEncryptor + >), + pairwise(), + map(([expected, actual]) => { + if (expected.userId === actual.userId) { + return actual; + } else { + throw { + expectedUserId: expected.userId, + actualUserId: actual.userId, + }; + } + }), + // reduce emissions to when encryptor changes + distinctUntilChanged(), + map(({ encryptor }) => encryptor), + ); + } + + private when(when$: Observable): OperatorFunction { + return pipe( + combineLatestWith(when$.pipe(distinctUntilChanged())), + filter(([_, when]) => !!when), + map(([input]) => input), + ); + } + + private prepareUpdate( + init$: Observable, + dependencies$: Observable, + ): OperatorFunction, State> { + return (input$) => + concat( + // `init$` becomes the accumulator for `scan` + init$.pipe( + first(), + map((init) => [init, null] as const), + ), + input$.pipe( + map((constrained) => constrained.state), + withLatestFrom(dependencies$), + ), + ).pipe( + // scan only emits values that can cause updates + scan(([prev], [pending, dependencies]) => { + const shouldUpdate = this.context.shouldUpdate?.(prev, pending, dependencies) ?? true; + if (shouldUpdate) { + // actual update + const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending; + return [next, dependencies]; + } else { + // false update + return [prev, null]; + } + }), + // the first emission primes `scan`s aggregator + skip(1), + map(([state]) => state), + + // clean up false updates + distinctUntilChanged(), + ); + } + + private adjust( + withConstraints: OperatorFunction]>, + ): OperatorFunction> { + return pipe( + // how constraints are blended with incoming emissions varies: + // * `output` needs to emit when constraints update + // * `input` needs to wait until a message flows through the pipe + withConstraints, + map(([loadedState, constraints]) => { + // bypass nulls + if (!loadedState) { + return { + constraints: {} as Constraints, + state: null, + } satisfies Constrained; + } + + const calibration = isDynamic(constraints) + ? constraints.calibrate(loadedState) + : constraints; + const adjusted = calibration.adjust(loadedState); + + return { + constraints: calibration.constraints, + state: adjusted, + }; + }), + ); + } + + private fix( + constraints$: Observable>, + ): OperatorFunction> { + return pipe( + combineLatestWith(constraints$), + map(([loadedState, constraints]) => { + const calibration = isDynamic(constraints) + ? constraints.calibrate(loadedState) + : constraints; + const fixed = calibration.fix(loadedState); + + return { + constraints: calibration.constraints, + state: fixed, + }; + }), + ); + } + + private declassify(encryptor$: Observable): OperatorFunction { + // short-circuit if they key lacks encryption support + if (!this.objectKey || this.objectKey.format === "plain") { + return (input$) => input$ as Observable; + } + + // if the key supports encryption, enable encryptor support + if (this.objectKey && this.objectKey.format === "classified") { + return pipe( + combineLatestWith(encryptor$), + concatMap(async ([input, encryptor]) => { + // pass through null values + if (input === null || input === undefined) { + return null; + } + + // fail fast if the format is incorrect + if (!isClassifiedFormat(input)) { + throw new Error(`Cannot declassify ${this.key.key}; unknown format.`); + } + + // decrypt classified data + const { secret, disclosed } = input; + const encrypted = EncString.fromJSON(secret); + const decryptedSecret = await encryptor.decrypt(encrypted); + + // assemble into proper state + const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret); + const state = this.objectKey.options.deserializer(declassified); + + return state; + }), + ); + } + + throw new Error(`unknown serialization format: ${this.objectKey.format}`); + } + + private classify(encryptor$: Observable): OperatorFunction { + // short-circuit if they key lacks encryption support; `encryptor` is + // readied to preserve `dependencies.singleUserId$` emission contract + if (!this.objectKey || this.objectKey.format === "plain") { + return pipe( + ready(encryptor$), + map((input) => input as unknown), + ); + } + + // if the key supports encryption, enable encryptor support + if (this.objectKey && this.objectKey.format === "classified") { + return pipe( + withLatestReady(encryptor$), + concatMap(async ([input, encryptor]) => { + // fail fast if there's no value + if (input === null || input === undefined) { + return null; + } + + // split data by classification level + const serialized = JSON.parse(JSON.stringify(input)); + const classified = this.objectKey.classifier.classify(serialized); + + // protect data + const encrypted = await encryptor.encrypt(classified.secret); + const secret = JSON.parse(JSON.stringify(encrypted)); + + // wrap result in classified format envelope for storage + const envelope = { + id: null as void, + secret, + disclosed: classified.disclosed, + } satisfies ClassifiedFormat; + + // deliberate type erasure; the type is restored during `declassify` + return envelope as unknown; + }), + ); + } + + // FIXME: add "encrypted" format --> key contains encryption logic + // CONSIDER: should "classified format" algorithm be embedded in subject keys...? + + throw new Error(`unknown serialization format: ${this.objectKey.format}`); + } + /** The userId to which the subject is bound. */ get userId() { @@ -177,7 +407,8 @@ export class UserStateSubject // using subjects to ensure the right semantics are followed; // if greater efficiency becomes desirable, consider implementing // `SubjectLike` directly - private input = new Subject(); + private input = new ReplaySubject(1); + private state: SingleUserState; private readonly output = new ReplaySubject>(1); /** A stream containing settings and their last-applied constraints. */ @@ -188,25 +419,8 @@ export class UserStateSubject private inputSubscription: Unsubscribable; private outputSubscription: Unsubscribable; - private onNext(value: State) { - const nextValue = this.dependencies.nextValue ?? ((_: State, next: State) => next); - const shouldUpdate = this.dependencies.shouldUpdate ?? ((_: State) => true); - - this.state - .update( - (state, dependencies) => { - const next = nextValue(state, value, dependencies); - return next; - }, - { - shouldUpdate(current, dependencies) { - const update = shouldUpdate(current, value, dependencies); - return update; - }, - combineLatestWith: this.dependencies.dependencies$, - }, - ) - .catch((e: any) => this.onError(e)); + private onNext(value: unknown) { + this.state.update(() => value).catch((e: any) => this.onError(e)); } private onError(value: any) { @@ -232,8 +446,8 @@ export class UserStateSubject private dispose() { if (!this.isDisposed) { // clean up internal subscriptions - this.inputSubscription.unsubscribe(); - this.outputSubscription.unsubscribe(); + this.inputSubscription?.unsubscribe(); + this.outputSubscription?.unsubscribe(); this.inputSubscription = null; this.outputSubscription = null; diff --git a/libs/common/src/tools/types.ts b/libs/common/src/tools/types.ts index ec1903e6225..9b746924278 100644 --- a/libs/common/src/tools/types.ts +++ b/libs/common/src/tools/types.ts @@ -1,5 +1,7 @@ import { Simplify } from "type-fest"; +import { IntegrationId } from "./integration"; + /** Constraints that are shared by all primitive field types */ type PrimitiveConstraint = { /** `true` indicates the field is required; otherwise the field is optional */ @@ -129,6 +131,8 @@ export type StateConstraints = { fix: (state: State) => State; }; +export type SubjectConstraints = StateConstraints | DynamicStateConstraints; + /** Options that provide contextual information about the application state * when a generator is invoked. */ @@ -144,4 +148,7 @@ export type VaultItemRequest = { /** Options that provide contextual information about the application state * when a generator is invoked. */ -export type GenerationRequest = Partial; +export type GenerationRequest = Partial & + Partial<{ + integration: IntegrationId | null; + }>; diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 53df58c8480..4c9fb9e7e49 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -3,7 +3,7 @@ fullWidth class="tw-mb-4" [selected]="(root$ | async).nav" - (selectedChange)="onRootChanged($event)" + (selectedChange)="onRootChanged({ nav: $event })" attr.aria-label="{{ 'type' | i18n }}" > @@ -35,23 +35,23 @@ -
{{ "options" | i18n }}
+

{{ "options" | i18n }}

- + {{ "type" | i18n }} @@ -60,18 +60,29 @@ }} +
+ + {{ "service" | i18n }} + + +
+ diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 86093beecd6..25aff97f16c 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -2,11 +2,12 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } fro import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, - concat, + catchError, + combineLatest, + combineLatestWith, distinctUntilChanged, filter, map, - of, ReplaySubject, Subject, switchMap, @@ -16,25 +17,32 @@ import { import { 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 { UserId } from "@bitwarden/common/types/guid"; +import { ToastService } from "@bitwarden/components"; import { Option } from "@bitwarden/components/src/select/option"; import { + AlgorithmInfo, CredentialAlgorithm, CredentialCategory, - CredentialGeneratorInfo, CredentialGeneratorService, GeneratedCredential, Generators, + getForwarderConfiguration, isEmailAlgorithm, + isForwarderIntegration, isPasswordAlgorithm, + isSameAlgorithm, isUsernameAlgorithm, - PasswordAlgorithm, + toCredentialGeneratorConfiguration, } from "@bitwarden/generator-core"; -/** root category that drills into username and email categories */ +// constants used to identify navigation selections that are not +// generator algorithms const IDENTIFIER = "identifier"; -/** options available for the top-level navigation */ -type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER; +const FORWARDER = "forwarder"; +const NONE_SELECTED = "none"; @Component({ selector: "tools-credential-generator", @@ -43,6 +51,8 @@ type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER; export class CredentialGeneratorComponent implements OnInit, OnDestroy { constructor( private generatorService: CredentialGeneratorService, + private toastService: ToastService, + private logService: LogService, private i18nService: I18nService, private accountService: AccountService, private zone: NgZone, @@ -59,59 +69,25 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { @Output() readonly onGenerated = new EventEmitter(); - protected root$ = new BehaviorSubject<{ nav: RootNavValue }>({ + protected root$ = new BehaviorSubject<{ nav: string }>({ nav: null, }); - /** - * Emits the copy button aria-label respective of the selected credential type - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeCopyLabel$ = this.root$.pipe( - map(({ nav }) => { - if (nav === "password") { - return this.i18nService.t("copyPassword"); - } - - if (nav === "passphrase") { - return this.i18nService.t("copyPassphrase"); - } - - return this.i18nService.t("copyUsername"); - }), - ); - - /** - * Emits the generate button aria-label respective of the selected credential type - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeGenerateLabel$ = this.root$.pipe( - map(({ nav }) => { - if (nav === "password") { - return this.i18nService.t("generatePassword"); - } - - if (nav === "passphrase") { - return this.i18nService.t("generatePassphrase"); - } - - return this.i18nService.t("generateUsername"); - }), - ); - - protected onRootChanged(nav: RootNavValue) { + protected onRootChanged(value: { nav: string }) { // prevent subscription cycle - if (this.root$.value.nav !== nav) { + if (this.root$.value.nav !== value.nav) { this.zone.run(() => { - this.root$.next({ nav }); + this.root$.next(value); }); } } protected username = this.formBuilder.group({ - nav: [null as CredentialAlgorithm], + nav: [null as string], + }); + + protected forwarder = this.formBuilder.group({ + nav: [null as string], }); async ngOnInit() { @@ -130,16 +106,29 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.generatorService .algorithms$(["email", "username"], { userId$: this.userId$ }) .pipe( - map((algorithms) => this.toOptions(algorithms)), + map((algorithms) => { + const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + const usernameOptions = this.toOptions(usernames); + usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") }); + + const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + const forwarderOptions = this.toOptions(forwarders); + forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); + + return [usernameOptions, forwarderOptions] as const; + }), takeUntil(this.destroyed), ) - .subscribe(this.usernameOptions$); + .subscribe(([usernames, forwarders]) => { + this.usernameOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); this.generatorService .algorithms$("password", { userId$: this.userId$ }) .pipe( map((algorithms) => { - const options = this.toOptions(algorithms) as Option[]; + const options = this.toOptions(algorithms); options.push({ value: IDENTIFIER, label: this.i18nService.t("username") }); return options; }), @@ -149,7 +138,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.algorithm$ .pipe( - map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), + map((a) => a?.description), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -162,7 +151,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.algorithm$ .pipe( - map((a) => a.category), + map((a) => a?.category), distinctUntilChanged(), takeUntil(this.destroyed), ) @@ -177,7 +166,22 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { // wire up the generator this.algorithm$ .pipe( + filter((algorithm) => !!algorithm), switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + catchError((error: unknown, generator) => { + if (typeof error === "string") { + this.toastService.showToast({ + message: error, + variant: "error", + title: "", + }); + } else { + this.logService.error(error); + } + + // continue with origin stream + return generator; + }), takeUntil(this.destroyed), ) .subscribe((generated) => { @@ -189,35 +193,116 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { }); }); - // assume the last-visible generator algorithm is the user's preferred one - const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + // normalize cascade selections; introduce subjects to allow changes + // from user selections and changes from preference updates to + // update the template + type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm }; + const activeRoot$ = new Subject(); + const activeIdentifier$ = new Subject(); + const activeForwarder$ = new Subject(); + this.root$ .pipe( - filter(({ nav }) => !!nav), - switchMap((root) => { - if (root.nav === IDENTIFIER) { - return concat(of(this.username.value), this.username.valueChanges); + map( + (root): CascadeValue => + root.nav === IDENTIFIER + ? { nav: root.nav } + : { nav: root.nav, algorithm: JSON.parse(root.nav) }, + ), + 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) }, + ), + 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) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeForwarder$); + + // update forwarder cascade visibility + combineLatest([activeRoot$, activeIdentifier$, activeForwarder$]) + .pipe( + map(([root, username, forwarder]) => { + const showForwarder = !root.algorithm && !username.algorithm; + const forwarderId = + showForwarder && isForwarderIntegration(forwarder.algorithm) + ? forwarder.algorithm.forwarder + : null; + return [showForwarder, forwarderId] as const; + }), + distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]), + takeUntil(this.destroyed), + ) + .subscribe(([showForwarder, forwarderId]) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.showForwarder$.next(showForwarder); + this.forwarderId$.next(forwarderId); + }); + }); + + // update active algorithm + combineLatest([activeRoot$, activeIdentifier$, activeForwarder$]) + .pipe( + map(([root, username, forwarder]) => { + const selection = root.algorithm ?? username.algorithm ?? forwarder.algorithm; + if (selection) { + return this.generatorService.algorithm(selection); } else { - return of(root as { nav: PasswordAlgorithm }); + return null; } }), - filter(({ nav }) => !!nav), + distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + takeUntil(this.destroyed), + ) + .subscribe((algorithm) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + + // assume the last-selected generator algorithm is the user's preferred one + const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + this.algorithm$ + .pipe( + filter((algorithm) => !!algorithm), withLatestFrom(preferences), takeUntil(this.destroyed), ) - .subscribe(([{ nav: algorithm }, preference]) => { + .subscribe(([algorithm, preference]) => { function setPreference(category: CredentialCategory) { const p = preference[category]; - p.algorithm = algorithm; + p.algorithm = algorithm.id; p.updated = new Date(); } // `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference` - if (isEmailAlgorithm(algorithm)) { + if (isEmailAlgorithm(algorithm.id)) { setPreference("email"); - } else if (isUsernameAlgorithm(algorithm)) { + } else if (isUsernameAlgorithm(algorithm.id)) { setPreference("username"); - } else if (isPasswordAlgorithm(algorithm)) { + } else if (isPasswordAlgorithm(algorithm.id)) { setPreference("password"); } else { return; @@ -227,34 +312,74 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { }); // populate the form with the user's preferences to kick off interactivity - preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username, password }) => { - // the last preference set by the user "wins" - const userNav = email.updated > username.updated ? email : username; - const rootNav: any = userNav.updated > password.updated ? IDENTIFIER : password.algorithm; - const credentialType = rootNav === IDENTIFIER ? userNav.algorithm : password.algorithm; - - // update navigation; break subscription loop - this.onRootChanged(rootNav); - this.username.setValue({ nav: userNav.algorithm }, { emitEvent: false }); - - // load algorithm metadata - const algorithm = this.generatorService.algorithm(credentialType); - - // update subjects within the angular zone so that the - // template bindings refresh immediately - this.zone.run(() => { - this.algorithm$.next(algorithm); - }); - }); - - // generate on load unless the generator prohibits it - this.algorithm$ + preferences .pipe( - distinctUntilChanged((prev, next) => prev.id === next.id), - filter((a) => !a.onlyOnRequest), + map(({ email, username, password }) => { + const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; + const usernamePref = email.updated > username.updated ? email : username; + + // inject drilldown flags + const forwarderNav = !forwarderPref + ? NONE_SELECTED + : JSON.stringify(forwarderPref.algorithm); + const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm); + const rootNav = + usernamePref.updated > password.updated + ? IDENTIFIER + : JSON.stringify(password.algorithm); + + // construct cascade metadata + const cascade = { + root: { + selection: { nav: rootNav }, + active: { + nav: rootNav, + algorithm: rootNav === IDENTIFIER ? null : password.algorithm, + } as CascadeValue, + }, + username: { + selection: { nav: userNav }, + active: { + nav: userNav, + algorithm: forwarderPref ? null : usernamePref.algorithm, + }, + }, + forwarder: { + selection: { nav: forwarderNav }, + active: { + nav: forwarderNav, + algorithm: forwarderPref?.algorithm, + }, + }, + }; + + return cascade; + }), takeUntil(this.destroyed), ) - .subscribe(() => this.generate$.next()); + .subscribe(({ root, username, forwarder }) => { + // update navigation; break subscription loop + this.onRootChanged(root.selection); + this.username.setValue(username.selection, { emitEvent: false }); + this.forwarder.setValue(forwarder.selection, { emitEvent: false }); + + // update cascade visibility + activeRoot$.next(root.active); + activeIdentifier$.next(username.active); + activeForwarder$.next(forwarder.active); + }); + + // automatically regenerate when the algorithm switches if the algorithm + // allows it; otherwise set a placeholder + this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.zone.run(() => { + if (!a || a.onlyOnRequest) { + this.value$.next("-"); + } else { + this.generate$.next(); + } + }); + }); } private typeToGenerator$(type: CredentialAlgorithm) { @@ -278,20 +403,61 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { case "passphrase": return this.generatorService.generate$(Generators.passphrase, dependencies); - - default: - throw new Error(`Invalid generator type: "${type}"`); } + + if (isForwarderIntegration(type)) { + const forwarder = getForwarderConfiguration(type.forwarder); + const configuration = toCredentialGeneratorConfiguration(forwarder); + const generator = this.generatorService.generate$(configuration, dependencies); + return generator; + } + + throw new Error(`Invalid generator type: "${type}"`); } - /** Lists the credential types of the username algorithm box. */ - protected usernameOptions$ = new BehaviorSubject[]>([]); + /** 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` + * from being selectable within a dropdown when its value contains a + * `ForwarderIntegration`. + */ + protected rootOptions$ = new BehaviorSubject[]>([]); - /** Lists the top-level credential types supported by the component. */ - protected rootOptions$ = new BehaviorSubject[]>([]); + /** Lists the credential types of the username algorithm box. */ + protected usernameOptions$ = new BehaviorSubject[]>([]); + + /** Lists the credential types of the username algorithm box. */ + protected forwarderOptions$ = new BehaviorSubject[]>([]); + + /** Tracks the currently selected forwarder. */ + 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 algorithm$ = new ReplaySubject(1); + + protected showAlgorithm$ = this.algorithm$.pipe( + combineLatestWith(this.showForwarder$), + map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), + ); + + /** + * Emits the copy button aria-label respective of the selected credential type + */ + protected credentialTypeCopyLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + */ + protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); @@ -308,10 +474,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ protected readonly generate$ = new Subject(); - private toOptions(algorithms: CredentialGeneratorInfo[]) { - const options: Option[] = algorithms.map((algorithm) => ({ - value: algorithm.id, - label: this.i18nService.t(algorithm.nameKey), + private toOptions(algorithms: AlgorithmInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: JSON.stringify(algorithm.id), + label: algorithm.name, })); return options; diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html new file mode 100644 index 00000000000..64566fa9562 --- /dev/null +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -0,0 +1,16 @@ +
+ + {{ "forwarderDomainName" | i18n }} + + {{ "forwarderDomainNameHint" | i18n }} + + + {{ "apiKey" | i18n }} + + + + + {{ "selfHostBaseUrl" | i18n }} + + +
diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts new file mode 100644 index 00000000000..a1e6c7acfd8 --- /dev/null +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -0,0 +1,195 @@ +import { + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, +} from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { + BehaviorSubject, + concatMap, + map, + ReplaySubject, + skip, + Subject, + switchAll, + switchMap, + takeUntil, + withLatestFrom, +} from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + CredentialGeneratorConfiguration, + CredentialGeneratorService, + getForwarderConfiguration, + NoPolicy, + toCredentialGeneratorConfiguration, +} from "@bitwarden/generator-core"; + +import { completeOnAccountSwitch, toValidators } from "./util"; + +const Controls = Object.freeze({ + domain: "domain", + token: "token", + baseUrl: "baseUrl", +}); + +/** Options group for forwarder integrations */ +@Component({ + selector: "tools-forwarder-settings", + templateUrl: "forwarder-settings.component.html", +}) +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 + */ + constructor( + private formBuilder: FormBuilder, + private generatorService: CredentialGeneratorService, + private accountService: AccountService, + ) {} + + /** Binds the component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + @Input({ required: true }) + forwarder: IntegrationId; + + /** 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. + */ + @Output() + readonly onUpdated = new EventEmitter(); + + /** The template's control bindings */ + protected settings = this.formBuilder.group({ + [Controls.domain]: [""], + [Controls.token]: [""], + [Controls.baseUrl]: [""], + }); + + private forwarderId$ = new ReplaySubject(1); + + async ngOnInit() { + const singleUserId$ = this.singleUserId$(); + + const forwarder$ = new ReplaySubject>(1); + this.forwarderId$ + .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)), + takeUntil(this.destroyed$), + ) + .subscribe((forwarder) => { + this.displayDomain = forwarder.request.includes("domain"); + this.displayToken = forwarder.request.includes("token"); + this.displayBaseUrl = forwarder.request.includes("baseUrl"); + + forwarder$.next(forwarder); + }); + + const settings$$ = forwarder$.pipe( + concatMap((forwarder) => this.generatorService.settings(forwarder, { singleUserId$ })), + ); + + // bind settings to the reactive form + settings$$.pipe(switchAll(), takeUntil(this.destroyed$)).subscribe((settings) => { + // skips reactive event emissions to break a subscription cycle + this.settings.patchValue(settings as any, { emitEvent: false }); + }); + + // bind policy to the reactive form + forwarder$ + .pipe( + switchMap((forwarder) => { + const constraints$ = this.generatorService + .policy$(forwarder, { userId$: singleUserId$ }) + .pipe(map(({ constraints }) => [constraints, forwarder] as const)); + + return constraints$; + }), + takeUntil(this.destroyed$), + ) + .subscribe(([constraints, forwarder]) => { + for (const name in Controls) { + const control = this.settings.get(name); + if (forwarder.request.includes(name as any)) { + control.enable({ emitEvent: false }); + control.setValidators( + // the configuration's type erasure affects `toValidators` as well + toValidators(name, forwarder, constraints), + ); + } else { + control.disable({ emitEvent: false }); + control.clearValidators(); + } + } + }); + + // the first emission is the current value; subsequent emissions are updates + settings$$ + .pipe( + map((settings$) => settings$.pipe(skip(1))), + switchAll(), + takeUntil(this.destroyed$), + ) + .subscribe(this.onUpdated); + + // now that outputs are set up, connect inputs + this.settings.valueChanges + .pipe(withLatestFrom(settings$$), takeUntil(this.destroyed$)) + .subscribe(([value, settings]) => { + settings.next(value); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.refresh$.complete(); + if ("forwarder" in changes) { + this.forwarderId$.next(this.forwarder); + } + } + + protected displayDomain: boolean; + protected displayToken: boolean; + protected displayBaseUrl: boolean; + + private singleUserId$() { + // FIXME: this branch should probably scan for the user and make sure + // the account is unlocked + if (this.userId) { + return new BehaviorSubject(this.userId as UserId).asObservable(); + } + + return this.accountService.activeAccount$.pipe( + completeOnAccountSwitch(), + takeUntil(this.destroyed$), + ); + } + + private readonly refresh$ = new Subject(); + + private readonly destroyed$ = new Subject(); + ngOnDestroy(): void { + this.destroyed$.complete(); + } +} diff --git a/libs/tools/generator/components/src/generator.module.ts b/libs/tools/generator/components/src/generator.module.ts index 96622774a3f..58117bec495 100644 --- a/libs/tools/generator/components/src/generator.module.ts +++ b/libs/tools/generator/components/src/generator.module.ts @@ -5,8 +5,11 @@ import { ReactiveFormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CardComponent, @@ -30,6 +33,7 @@ import { import { CatchallSettingsComponent } from "./catchall-settings.component"; import { CredentialGeneratorComponent } from "./credential-generator.component"; +import { ForwarderSettingsComponent } from "./forwarder-settings.component"; import { PassphraseSettingsComponent } from "./passphrase-settings.component"; import { PasswordGeneratorComponent } from "./password-generator.component"; import { PasswordSettingsComponent } from "./password-settings.component"; @@ -67,18 +71,27 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); safeProvider({ provide: CredentialGeneratorService, useClass: CredentialGeneratorService, - deps: [RANDOMIZER, StateProvider, PolicyService], + deps: [ + RANDOMIZER, + StateProvider, + PolicyService, + ApiService, + I18nService, + EncryptService, + CryptoService, + ], }), ], declarations: [ CatchallSettingsComponent, CredentialGeneratorComponent, + ForwarderSettingsComponent, SubaddressSettingsComponent, - UsernameSettingsComponent, PasswordGeneratorComponent, - PasswordSettingsComponent, PassphraseSettingsComponent, + PasswordSettingsComponent, UsernameGeneratorComponent, + UsernameSettingsComponent, ], exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], }) diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 37c40ce8b1b..f6ec1b17e2d 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -21,9 +21,9 @@ import { Generators, PasswordAlgorithm, GeneratedCredential, - CredentialGeneratorInfo, CredentialAlgorithm, isPasswordAlgorithm, + AlgorithmInfo, } from "@bitwarden/generator-core"; /** Options group for passwords */ @@ -52,36 +52,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { /** tracks the currently selected credential type */ protected credentialType$ = new BehaviorSubject(null); - /** - * Emits the copy button aria-label respective of the selected credential - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeCopyLabel$ = this.credentialType$.pipe( - map((cred) => { - if (cred === "password") { - return this.i18nService.t("copyPassword"); - } - - return this.i18nService.t("copyPassphrase"); - }), - ); - - /** - * Emits the generate button aria-label respective of the selected credential - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeGenerateLabel$ = this.credentialType$.pipe( - map((cred) => { - if (cred === "password") { - return this.i18nService.t("generatePassword"); - } - - return this.i18nService.t("generatePassphrase"); - }), - ); - /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -208,12 +178,28 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { protected passwordOptions$ = new BehaviorSubject[]>([]); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected algorithm$ = new ReplaySubject(1); - private toOptions(algorithms: CredentialGeneratorInfo[]) { + /** + * Emits the copy button aria-label respective of the selected credential type + */ + protected credentialTypeCopyLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + */ + protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, - label: this.i18nService.t(algorithm.nameKey), + label: this.i18nService.t(algorithm.name), })); return options; diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index ad8cd796123..3d175f32f78 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -3,45 +3,54 @@
- -
-
{{ "options" | i18n }}
+

{{ "options" | i18n }}

-
+ {{ "type" | i18n }} - + {{ credentialTypeHint$ | async }}
+
+ + {{ "service" | i18n }} + + +
+ this.toOptions(algorithms)), + map((algorithms) => { + const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + const usernameOptions = this.toOptions(usernames); + usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwarder") }); + + const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + const forwarderOptions = this.toOptions(forwarders); + forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); + + return [usernameOptions, forwarderOptions] as const; + }), takeUntil(this.destroyed), ) - .subscribe(this.typeOptions$); + .subscribe(([usernames, forwarders]) => { + this.typeOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); this.algorithm$ .pipe( - map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), + map((a) => a?.description), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -103,7 +137,22 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { // wire up the generator this.algorithm$ .pipe( + filter((algorithm) => !!algorithm), switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + catchError((error: unknown, generator) => { + if (typeof error === "string") { + this.toastService.showToast({ + message: error, + variant: "error", + title: "", + }); + } else { + this.logService.error(error); + } + + // continue with origin stream + return generator; + }), takeUntil(this.destroyed), ) .subscribe((generated) => { @@ -115,20 +164,96 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { }); }); + // normalize cascade selections; introduce subjects to allow changes + // from user selections and changes from preference updates to + // update the template + type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm }; + const activeIdentifier$ = new Subject(); + const activeForwarder$ = new Subject(); + + this.username.valueChanges + .pipe( + map( + (username): CascadeValue => + username.nav === FORWARDER + ? { nav: username.nav } + : { nav: username.nav, algorithm: JSON.parse(username.nav) }, + ), + 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) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeForwarder$); + + // update forwarder cascade visibility + combineLatest([activeIdentifier$, activeForwarder$]) + .pipe( + map(([username, forwarder]) => { + const showForwarder = !username.algorithm; + const forwarderId = + showForwarder && isForwarderIntegration(forwarder.algorithm) + ? forwarder.algorithm.forwarder + : null; + return [showForwarder, forwarderId] as const; + }), + distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]), + takeUntil(this.destroyed), + ) + .subscribe(([showForwarder, forwarderId]) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.showForwarder$.next(showForwarder); + this.forwarderId$.next(forwarderId); + }); + }); + + // update active algorithm + combineLatest([activeIdentifier$, activeForwarder$]) + .pipe( + map(([username, forwarder]) => { + const selection = username.algorithm ?? forwarder.algorithm; + if (selection) { + return this.generatorService.algorithm(selection); + } else { + return null; + } + }), + distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + takeUntil(this.destroyed), + ) + .subscribe((algorithm) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + // assume the last-visible generator algorithm is the user's preferred one const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); - this.credential.valueChanges + this.algorithm$ .pipe( - filter(({ type }) => !!type), + filter((algorithm) => !!algorithm), withLatestFrom(preferences), takeUntil(this.destroyed), ) - .subscribe(([{ type }, preference]) => { - if (isEmailAlgorithm(type)) { - preference.email.algorithm = type; + .subscribe(([algorithm, preference]) => { + if (isEmailAlgorithm(algorithm.id)) { + preference.email.algorithm = algorithm.id; preference.email.updated = new Date(); - } else if (isUsernameAlgorithm(type)) { - preference.username.algorithm = type; + } else if (isUsernameAlgorithm(algorithm.id)) { + preference.username.algorithm = algorithm.id; preference.username.updated = new Date(); } else { return; @@ -137,31 +262,61 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { preferences.next(preference); }); - // populate the form with the user's preferences to kick off interactivity - preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username }) => { - // this generator supports email & username; the last preference - // set by the user "wins" - const preference = email.updated > username.updated ? email.algorithm : username.algorithm; - - // break subscription loop - this.credential.setValue({ type: preference }, { emitEvent: false }); - - const algorithm = this.generatorService.algorithm(preference); - // update subjects within the angular zone so that the - // template bindings refresh immediately - this.zone.run(() => { - this.algorithm$.next(algorithm); - }); - }); - - // generate on load unless the generator prohibits it - this.algorithm$ + preferences .pipe( - distinctUntilChanged((prev, next) => prev.id === next.id), - filter((a) => !a.onlyOnRequest), + map(({ email, username }) => { + const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; + const usernamePref = email.updated > username.updated ? email : username; + + // inject drilldown flags + const forwarderNav = !forwarderPref + ? NONE_SELECTED + : JSON.stringify(forwarderPref.algorithm); + const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm); + + // construct cascade metadata + const cascade = { + username: { + selection: { nav: userNav }, + active: { + nav: userNav, + algorithm: forwarderPref ? null : usernamePref.algorithm, + }, + }, + forwarder: { + selection: { nav: forwarderNav }, + active: { + nav: forwarderNav, + algorithm: forwarderPref?.algorithm, + }, + }, + }; + + return cascade; + }), takeUntil(this.destroyed), ) - .subscribe(() => this.generate$.next()); + .subscribe(({ username, forwarder }) => { + // update navigation; break subscription loop + this.username.setValue(username.selection, { emitEvent: false }); + this.forwarder.setValue(forwarder.selection, { emitEvent: false }); + + // update cascade visibility + activeIdentifier$.next(username.active); + activeForwarder$.next(forwarder.active); + }); + + // automatically regenerate when the algorithm switches if the algorithm + // allows it; otherwise set a placeholder + this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.zone.run(() => { + if (!a || a.onlyOnRequest) { + this.value$.next("-"); + } else { + this.generate$.next(); + } + }); + }); } private typeToGenerator$(type: CredentialAlgorithm) { @@ -179,17 +334,52 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { case "username": return this.generatorService.generate$(Generators.username, dependencies); - - default: - throw new Error(`Invalid generator type: "${type}"`); } + + if (isForwarderIntegration(type)) { + const forwarder = getForwarderConfiguration(type.forwarder); + const configuration = toCredentialGeneratorConfiguration(forwarder); + return this.generatorService.generate$(configuration, dependencies); + } + + throw new Error(`Invalid generator type: "${type}"`); } /** Lists the credential types supported by the component. */ - protected typeOptions$ = new BehaviorSubject[]>([]); + protected typeOptions$ = new BehaviorSubject[]>([]); + + /** Tracks the currently selected forwarder. */ + protected forwarderId$ = new BehaviorSubject(null); + + /** Lists the credential types supported by the component. */ + protected forwarderOptions$ = new BehaviorSubject[]>([]); + + /** Tracks forwarder control visibility */ + protected showForwarder$ = new BehaviorSubject(false); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected algorithm$ = new ReplaySubject(1); + + protected showAlgorithm$ = this.algorithm$.pipe( + combineLatestWith(this.showForwarder$), + map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), + ); + + /** + * Emits the copy button aria-label respective of the selected credential type + */ + protected credentialTypeCopyLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + */ + protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); @@ -203,10 +393,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ protected readonly generate$ = new Subject(); - private toOptions(algorithms: CredentialGeneratorInfo[]) { - const options: Option[] = algorithms.map((algorithm) => ({ - value: algorithm.id, - label: this.i18nService.t(algorithm.nameKey), + private toOptions(algorithms: AlgorithmInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: JSON.stringify(algorithm.id), + label: this.i18nService.t(algorithm.name), })); return options; diff --git a/libs/tools/generator/components/src/util.ts b/libs/tools/generator/components/src/util.ts index 2049a285e25..d6cd4e6fbaf 100644 --- a/libs/tools/generator/components/src/util.ts +++ b/libs/tools/generator/components/src/util.ts @@ -63,7 +63,7 @@ function getConstraint( ) { if (policy && key in policy) { return policy[key] ?? config[key]; - } else if (key in config) { + } else if (config && key in config) { return config[key]; } } diff --git a/libs/tools/generator/core/src/data/generator-types.ts b/libs/tools/generator/core/src/data/generator-types.ts index 6c351b82e33..e54ec34e497 100644 --- a/libs/tools/generator/core/src/data/generator-types.ts +++ b/libs/tools/generator/core/src/data/generator-types.ts @@ -5,7 +5,7 @@ export const PasswordAlgorithms = Object.freeze(["password", "passphrase"] as co 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", "forwarder", "subaddress"] as const); +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([ diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 2c96b0c2d39..d86eb52a8fa 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -1,9 +1,15 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; -import { Randomizer } from "../abstractions"; -import { EmailRandomizer, PasswordRandomizer, UsernameRandomizer } from "../engine"; +import { + EmailRandomizer, + ForwarderConfiguration, + PasswordRandomizer, + UsernameRandomizer, +} from "../engine"; +import { Forwarder } from "../engine/forwarder"; import { DefaultPolicyEvaluator, DynamicPasswordPolicyConstraints, @@ -25,6 +31,7 @@ import { CredentialGenerator, CredentialGeneratorConfiguration, EffUsernameGenerationOptions, + GeneratorDependencyProvider, NoPolicy, PassphraseGenerationOptions, PassphraseGeneratorPolicy, @@ -45,10 +52,15 @@ const PASSPHRASE = Object.freeze({ id: "passphrase", category: "password", nameKey: "passphrase", + generateKey: "generatePassphrase", + copyKey: "copyPassphrase", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new PasswordRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new PasswordRandomizer(dependencies.randomizer); }, }, settings: { @@ -82,10 +94,15 @@ const PASSWORD = Object.freeze({ id: "password", category: "password", nameKey: "password", + generateKey: "generatePassword", + copyKey: "copyPassword", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new PasswordRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new PasswordRandomizer(dependencies.randomizer); }, }, settings: { @@ -127,10 +144,15 @@ const USERNAME = Object.freeze({ id: "username", category: "username", nameKey: "randomWord", + generateKey: "generateUsername", + copyKey: "copyUsername", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new UsernameRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new UsernameRandomizer(dependencies.randomizer); }, }, settings: { @@ -158,10 +180,15 @@ const CATCHALL = Object.freeze({ category: "email", nameKey: "catchallEmail", descriptionKey: "catchallEmailDesc", + generateKey: "generateEmail", + copyKey: "copyEmail", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new EmailRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); }, }, settings: { @@ -189,10 +216,15 @@ const SUBADDRESS = Object.freeze({ category: "email", nameKey: "plusAddressedEmail", descriptionKey: "plusAddressedEmailDesc", + generateKey: "generateEmail", + copyKey: "copyEmail", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new EmailRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); }, }, settings: { @@ -215,6 +247,48 @@ const SUBADDRESS = Object.freeze({ }, } satisfies CredentialGeneratorConfiguration); +export function toCredentialGeneratorConfiguration( + configuration: ForwarderConfiguration, +) { + const forwarder = Object.freeze({ + id: { forwarder: configuration.id }, + category: "email", + nameKey: configuration.name, + descriptionKey: "forwardedEmailDesc", + generateKey: "generateEmail", + copyKey: "copyEmail", + 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.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 */ diff --git a/libs/tools/generator/core/src/data/integrations.ts b/libs/tools/generator/core/src/data/integrations.ts index 6132891b368..71c80fc9dbe 100644 --- a/libs/tools/generator/core/src/data/integrations.ts +++ b/libs/tools/generator/core/src/data/integrations.ts @@ -1,3 +1,7 @@ +import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; + +import { ForwarderConfiguration } from "../engine"; import { AddyIo } from "../integration/addy-io"; import { DuckDuckGo } from "../integration/duck-duck-go"; import { Fastmail } from "../integration/fastmail"; @@ -5,6 +9,13 @@ import { FirefoxRelay } from "../integration/firefox-relay"; import { ForwardEmail } from "../integration/forward-email"; import { SimpleLogin } from "../integration/simple-login"; +/** Fixed list of integrations available to the application + * @example + * + * // Use `toCredentialGeneratorConfiguration(id :ForwarderIntegration)` + * // to convert an integration to a generator configuration + * const generator = toCredentialGeneratorConfiguration(Integrations.AddyIo); + */ export const Integrations = Object.freeze({ AddyIo, DuckDuckGo, @@ -13,3 +24,15 @@ export const Integrations = Object.freeze({ ForwardEmail, SimpleLogin, } as const); + +const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i])); + +export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration { + const maybeForwarder = integrations.get(id); + + if (maybeForwarder && "forwarder" in maybeForwarder) { + return maybeForwarder as ForwarderConfiguration; + } else { + return null; + } +} diff --git a/libs/tools/generator/core/src/engine/forwarder-configuration.ts b/libs/tools/generator/core/src/engine/forwarder-configuration.ts index 95c9add140a..7813f457399 100644 --- a/libs/tools/generator/core/src/engine/forwarder-configuration.ts +++ b/libs/tools/generator/core/src/engine/forwarder-configuration.ts @@ -1,11 +1,14 @@ import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { IntegrationConfiguration } from "@bitwarden/common/tools/integration/integration-configuration"; -import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; +import { ApiSettings, SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc"; import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc/integration-request"; import { RpcConfiguration } from "@bitwarden/common/tools/integration/rpc/rpc-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; +import { Constraints } from "@bitwarden/common/tools/types"; import { ForwarderContext } from "./forwarder-context"; +import { EmailDomainSettings, EmailPrefixSettings } from "./settings"; /** Mixin for transmitting `getAccountId` result. */ export type AccountRequest = { @@ -24,8 +27,16 @@ export type GetAccountIdRpcDef< Request extends IntegrationRequest = IntegrationRequest, > = RpcConfiguration, string>; +export type ForwarderRequestFields = keyof (ApiSettings & + SelfHostedApiSettings & + EmailDomainSettings & + EmailPrefixSettings); + /** Forwarder-specific static definition */ export type ForwarderConfiguration< + // FIXME: simply forwarder settings to an object that has all + // settings properties. The runtime dynamism should be limited + // to which have values, not which have properties listed. Settings extends ApiSettings, Request extends IntegrationRequest = IntegrationRequest, > = IntegrationConfiguration & { @@ -34,12 +45,30 @@ export type ForwarderConfiguration< /** default value of all fields */ defaultSettings: Partial; - /** forwarder settings storage */ + settingsConstraints: Constraints; + + /** Well-known fields to display on the forwarder screen */ + request: readonly ForwarderRequestFields[]; + + /** forwarder settings storage + * @deprecated use local.settings instead + */ settings: UserKeyDefinition; - /** forwarder settings import buffer; `undefined` when there is no buffer. */ + /** forwarder settings import buffer; `undefined` when there is no buffer. + * @deprecated use local.settings import + */ importBuffer?: BufferedKeyDefinition; + /** locally stored data; forwarder-partitioned */ + local: { + /** integration settings storage */ + settings: ObjectKey; + + /** plaintext import buffer - used during data migrations */ + import?: ObjectKey, Settings>; + }; + /** createForwardingEmail RPC definition */ createForwardingEmail: CreateForwardingEmailRpcDef; diff --git a/libs/tools/generator/core/src/engine/forwarder.ts b/libs/tools/generator/core/src/engine/forwarder.ts new file mode 100644 index 00000000000..523c6fdf1ec --- /dev/null +++ b/libs/tools/generator/core/src/engine/forwarder.ts @@ -0,0 +1,75 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ApiSettings, + IntegrationRequest, + RestClient, +} from "@bitwarden/common/tools/integration/rpc"; +import { GenerationRequest } from "@bitwarden/common/tools/types"; + +import { CredentialGenerator, GeneratedCredential } from "../types"; + +import { AccountRequest, ForwarderConfiguration } from "./forwarder-configuration"; +import { ForwarderContext } from "./forwarder-context"; +import { CreateForwardingAddressRpc, GetAccountIdRpc } from "./rpc"; + +/** Generation algorithms that query an email forwarding service to + * create anonymized email addresses. + */ +export class Forwarder implements CredentialGenerator { + /** Instantiates the email forwarder engine + * @param configuration The forwarder to query + * @param client requests data from the forwarding service + * @param i18nService localizes messages sent to the forwarding service + * and user-addressable errors + */ + constructor( + private configuration: ForwarderConfiguration, + private client: RestClient, + private i18nService: I18nService, + ) {} + + async generate(request: GenerationRequest, settings: ApiSettings) { + const requestOptions: IntegrationRequest & AccountRequest = { website: request.website }; + + const getAccount = await this.getAccountId(this.configuration, settings); + if (getAccount) { + requestOptions.accountId = await this.client.fetchJson(getAccount, requestOptions); + } + + 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()); + } + + private createContext( + configuration: ForwarderConfiguration, + settings: Settings, + ) { + return new ForwarderContext(configuration, settings, this.i18nService); + } + + private createForwardingAddress( + configuration: ForwarderConfiguration, + settings: Settings, + ) { + const context = this.createContext(configuration, settings); + const rpc = new CreateForwardingAddressRpc(configuration, context); + return rpc; + } + + private getAccountId( + configuration: ForwarderConfiguration, + settings: Settings, + ) { + if (!configuration.forwarder.getAccountId) { + return null; + } + + const context = this.createContext(configuration, settings); + const rpc = new GetAccountIdRpc(configuration, context); + + return rpc; + } +} diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts index 8f594827e95..2d265ca9bfc 100644 --- a/libs/tools/generator/core/src/integration/addy-io.ts +++ b/libs/tools/generator/core/src/integration/addy-io.ts @@ -1,11 +1,18 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest, SelfHostedApiSettings, } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -44,6 +51,40 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token", "baseUrl", "domain"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + baseUrl: {}, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.AddyIo.local.settings", + key: "addyIoForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.AddyIo.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token", "baseUrl", "domain"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, AddyIoSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "addyIoForwarder", { deserializer: (value) => value, clearOn: [], @@ -52,7 +93,6 @@ const forwarder = Object.freeze({ deserializer: (value) => value, clearOn: ["logout"], }), - createForwardingEmail, } as const); export const AddyIo = Object.freeze({ 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 0c13ac6b632..4c1d672cc60 100644 --- a/libs/tools/generator/core/src/integration/duck-duck-go.ts +++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; 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"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -36,6 +43,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token"], + settingsConstraints: { + token: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.DuckDuckGo.local.settings", + key: "duckDuckGoForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.DuckDuckGo.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, DuckDuckGoSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "duckDuckGoForwarder", { deserializer: (value) => value, clearOn: [], @@ -44,7 +83,6 @@ const forwarder = Object.freeze({ deserializer: (value) => value, clearOn: ["logout"], }), - createForwardingEmail, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/integration/fastmail.ts b/libs/tools/generator/core/src/integration/fastmail.ts index 0987540e036..13aa8db6247 100644 --- a/libs/tools/generator/core/src/integration/fastmail.ts +++ b/libs/tools/generator/core/src/integration/fastmail.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; 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"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, @@ -101,6 +108,41 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + getAccountId, + request: ["token"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + prefix: {}, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.Fastmail.local.settings" + key: "fastmailForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.Fastmail.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, FastmailSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "fastmailForwarder", { deserializer: (value) => value, clearOn: [], @@ -109,8 +151,6 @@ const forwarder = Object.freeze({ deserializer: (value) => value, clearOn: ["logout"], }), - createForwardingEmail, - getAccountId, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/integration/firefox-relay.ts b/libs/tools/generator/core/src/integration/firefox-relay.ts index 4feb8a0bd99..9c965a4c9cd 100644 --- a/libs/tools/generator/core/src/integration/firefox-relay.ts +++ b/libs/tools/generator/core/src/integration/firefox-relay.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; 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"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -40,6 +47,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token"], + settingsConstraints: { + token: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.Firefox.local.settings", + key: "firefoxRelayForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.Firefox.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, FirefoxRelaySettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "firefoxRelayForwarder", { deserializer: (value) => value, clearOn: [], @@ -52,7 +91,6 @@ const forwarder = Object.freeze({ clearOn: ["logout"], }, ), - createForwardingEmail, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/integration/forward-email.ts b/libs/tools/generator/core/src/integration/forward-email.ts index c4ef21d9d30..a128159fcd6 100644 --- a/libs/tools/generator/core/src/integration/forward-email.ts +++ b/libs/tools/generator/core/src/integration/forward-email.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; 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"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -43,6 +50,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + request: ["token", "domain"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.ForwardEmail.local.settings", + key: "forwardEmailForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.ForwardEmail.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token", "domain"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, ForwardEmailSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "forwardEmailForwarder", { deserializer: (value) => value, clearOn: [], diff --git a/libs/tools/generator/core/src/integration/simple-login.ts b/libs/tools/generator/core/src/integration/simple-login.ts index 88730d0578e..d4b297fc37e 100644 --- a/libs/tools/generator/core/src/integration/simple-login.ts +++ b/libs/tools/generator/core/src/integration/simple-login.ts @@ -1,11 +1,18 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest, SelfHostedApiSettings, } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -45,6 +52,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token", "baseUrl"], + settingsConstraints: { + token: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.SimpleLogin.local.settings", + key: "simpleLoginForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.SimpleLogin.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token", "baseUrl"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, SimpleLoginSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "simpleLoginForwarder", { deserializer: (value) => value, clearOn: [], @@ -57,7 +96,6 @@ const forwarder = Object.freeze({ clearOn: ["logout"], }, ), - createForwardingEmail, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/rx.spec.ts b/libs/tools/generator/core/src/rx.spec.ts deleted file mode 100644 index b98e79bb074..00000000000 --- a/libs/tools/generator/core/src/rx.spec.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { EmptyError, Subject, tap } from "rxjs"; - -import { anyComplete, on, ready } from "./rx"; - -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("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); - }); -}); diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts index 851b6cfe7c7..070d34d37d8 100644 --- a/libs/tools/generator/core/src/rx.ts +++ b/libs/tools/generator/core/src/rx.ts @@ -1,18 +1,4 @@ -import { - concat, - concatMap, - connect, - endWith, - first, - ignoreElements, - map, - Observable, - pipe, - race, - ReplaySubject, - takeUntil, - zip, -} from "rxjs"; +import { map, pipe } from "rxjs"; import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx"; @@ -51,86 +37,3 @@ export function newDefaultEvaluator() { return pipe(map((_) => new DefaultPolicyEvaluator())); }; } - -/** Create an observable that, once subscribed, emits `true` then completes when - * any input completes. If an input is already complete when the subscription - * occurs, it emits immediately. - * @param watch$ the observable(s) to watch for completion; if an array is passed, - * null and undefined members are ignored. If `watch$` is empty, `anyComplete` - * will never complete. - * @returns An observable that emits `true` when any of its inputs - * complete. The observable forwards the first error from its input. - * @remarks This method is particularly useful in combination with `takeUntil` and - * streams that are not guaranteed to complete on their own. - */ -export function anyComplete(watch$: Observable | Observable[]): Observable { - if (Array.isArray(watch$)) { - const completes$ = watch$ - .filter((w$) => !!w$) - .map((w$) => w$.pipe(ignoreElements(), endWith(true))); - const completed$ = race(completes$); - return completed$; - } else { - return watch$.pipe(ignoreElements(), endWith(true)); - } -} - -/** - * Create an observable that delays the input stream until all watches have - * emitted a value. The watched values are not included in the source stream. - * The last emission from the source is output when all the watches have - * emitted at least once. - * @param watch$ the observable(s) to watch for readiness. If `watch$` is empty, - * `ready` will never emit. - * @returns An observable that emits when the source stream emits. The observable - * errors if one of its watches completes before emitting. It also errors if one - * of its watches errors. - */ -export function ready(watch$: Observable | Observable[]) { - const watching$ = Array.isArray(watch$) ? watch$ : [watch$]; - return pipe( - connect>((source$) => { - // this subscription is safe because `source$` connects only after there - // is an external subscriber. - const source = new ReplaySubject(1); - source$.subscribe(source); - - // `concat` is subscribed immediately after it's returned, at which point - // `zip` blocks until all items in `watching$` 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(watching$).pipe(first(), ignoreElements()), source).pipe( - takeUntil(anyComplete(source)), - ); - }), - ); -} - -/** - * Create an observable that emits the latest value of the source stream - * when `watch$` emits. If `watch$` emits before the stream emits, then - * an emission occurs as soon as a value becomes ready. - * @param watch$ the observable that triggers emissions - * @returns An observable that emits when `watch$` emits. The observable - * errors if its source stream errors. It also errors if `on` errors. It - * completes if its watch completes. - * - * @remarks This works like `audit`, but it repeats emissions when - * watch$ fires. - */ -export function on(watch$: Observable) { - return pipe( - connect>((source$) => { - const source = new ReplaySubject(1); - source$.subscribe(source); - - return watch$ - .pipe( - ready(source), - concatMap(() => source.pipe(first())), - ) - .pipe(takeUntil(anyComplete(source))); - }), - ); -} 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 index 88f1447e98d..e11e555d6aa 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -1,12 +1,17 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, filter, firstValueFrom, 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 { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { StateConstraints } from "@bitwarden/common/tools/types"; import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; import { FakeStateProvider, @@ -67,15 +72,20 @@ const SomeTime = new Date(1); const SomeAlgorithm = "passphrase"; const SomeCategory = "password"; const SomeNameKey = "passphraseKey"; +const SomeGenerateKey = "generateKey"; +const SomeCopyKey = "copyKey"; // fake the configuration const SomeConfiguration: CredentialGeneratorConfiguration = { id: SomeAlgorithm, category: SomeCategory, nameKey: SomeNameKey, + generateKey: SomeGenerateKey, + copyKey: SomeCopyKey, onlyOnRequest: false, + request: [], engine: { - create: (randomizer) => { + create: (_randomizer) => { return { generate: (request, settings) => { const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; @@ -159,10 +169,22 @@ const stateProvider = new FakeStateProvider(accountService); // fake randomizer const randomizer = mock(); +const i18nService = mock(); + +const apiService = mock(); + +const encryptService = mock(); + +const cryptoService = mock(); + describe("CredentialGeneratorService", () => { beforeEach(async () => { await accountService.switchAccount(SomeUser); policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable()); + i18nService.t.mockImplementation((key) => key); + apiService.fetch.mockImplementation(() => Promise.resolve(mock())); + const keyAvailable = new BehaviorSubject({} as UserKey); + cryptoService.userKey$.mockReturnValue(keyAvailable); jest.clearAllMocks(); }); @@ -170,7 +192,15 @@ describe("CredentialGeneratorService", () => { it("emits a generation for the active user when subscribed", async () => { const settings = { foo: "value" }; await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const result = await generated.expectEmission(); @@ -183,7 +213,15 @@ describe("CredentialGeneratorService", () => { const anotherSettings = { foo: "another value" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); await accountService.switchAccount(AnotherUser); @@ -200,7 +238,15 @@ describe("CredentialGeneratorService", () => { const someSettings = { foo: "some value" }; const anotherSettings = { foo: "another value" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser); @@ -220,7 +266,15 @@ describe("CredentialGeneratorService", () => { it("includes `website$`'s last emitted value", async () => { const settings = { foo: "value" }; await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const website$ = new BehaviorSubject("some website"); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); @@ -233,7 +287,15 @@ describe("CredentialGeneratorService", () => { it("errors when `website$` errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const website$ = new BehaviorSubject("some website"); let error = null; @@ -250,7 +312,15 @@ describe("CredentialGeneratorService", () => { it("completes when `website$` completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const website$ = new BehaviorSubject("some website"); let completed = false; @@ -268,7 +338,15 @@ describe("CredentialGeneratorService", () => { 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); @@ -280,7 +358,15 @@ describe("CredentialGeneratorService", () => { it("emits a generation for a specific user when `user$` emits", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.pipe(filter((u) => !!u)); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); @@ -296,7 +382,15 @@ describe("CredentialGeneratorService", () => { it("errors when `user$` errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser); let error = null; @@ -313,7 +407,15 @@ describe("CredentialGeneratorService", () => { it("completes when `user$` completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser); let completed = false; @@ -331,7 +433,15 @@ describe("CredentialGeneratorService", () => { 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const on$ = new Subject(); const results: any[] = []; @@ -365,7 +475,15 @@ describe("CredentialGeneratorService", () => { it("errors when `on$` errors", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const on$ = new Subject(); let error: any = null; @@ -383,7 +501,15 @@ describe("CredentialGeneratorService", () => { it("completes when `on$` completes", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const on$ = new Subject(); let complete = false; @@ -406,54 +532,86 @@ describe("CredentialGeneratorService", () => { describe("algorithms", () => { it("outputs password generation metadata", () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms("password"); - expect(result).toContain(Generators.password); - expect(result).toContain(Generators.passphrase); + 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).not.toContain(Generators.username); - expect(result).not.toContain(Generators.catchall); + 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms("username"); - expect(result).toContain(Generators.username); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); // this test shouldn't contain entries outside of the current category - expect(result).not.toContain(Generators.catchall); - expect(result).not.toContain(Generators.password); + 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms("email"); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + 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).not.toContain(Generators.username); - expect(result).not.toContain(Generators.password); + 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms(["username", "email"]); - expect(result).toContain(Generators.username); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + 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).not.toContain(Generators.password); + expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); }); }); @@ -461,39 +619,71 @@ describe("CredentialGeneratorService", () => { // 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$("password")); - expect(result).toContain(Generators.password); - expect(result).toContain(Generators.passphrase); + 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$("username")); - expect(result).toContain(Generators.username); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); }); it("returns email metadata", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$("email")); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$(["username", "email"])); - expect(result).toContain(Generators.username); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + 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 @@ -501,13 +691,21 @@ describe("CredentialGeneratorService", () => { it("enforces the active user's policy", async () => { const policy$ = new BehaviorSubject([passwordOverridePolicy]); policyService.getAll$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$(["password"])); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); - expect(result).toContain(Generators.password); - expect(result).not.toContain(Generators.passphrase); + 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 () => { @@ -518,7 +716,15 @@ describe("CredentialGeneratorService", () => { await accountService.switchAccount(SomeUser); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const results: any = []; const sub = generator.algorithms$("password").subscribe((r) => results.push(r)); @@ -533,34 +739,50 @@ describe("CredentialGeneratorService", () => { PolicyType.PasswordGenerator, SomeUser, ); - expect(someResult).toContain(Generators.password); - expect(someResult).not.toContain(Generators.passphrase); + expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); + expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); expect(policyService.getAll$).toHaveBeenNthCalledWith( 2, PolicyType.PasswordGenerator, AnotherUser, ); - expect(anotherResult).toContain(Generators.passphrase); - expect(anotherResult).not.toContain(Generators.password); + 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.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const result = await firstValueFrom(generator.algorithms$("password", { userId$ })); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); - expect(result).toContain(Generators.password); - expect(result).not.toContain(Generators.passphrase); + 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.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const results: any = []; @@ -572,17 +794,25 @@ describe("CredentialGeneratorService", () => { const [someResult, anotherResult] = results; expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); - expect(someResult).toContain(Generators.password); - expect(someResult).not.toContain(Generators.passphrase); + expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); + expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); - expect(anotherResult).toContain(Generators.passphrase); - expect(anotherResult).not.toContain(Generators.password); + 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.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let error = null; @@ -600,7 +830,15 @@ describe("CredentialGeneratorService", () => { it("completes when the arbitrary user's stream completes", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let completed = false; @@ -618,7 +856,15 @@ describe("CredentialGeneratorService", () => { it("ignores repeated arbitrary user emissions", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let count = 0; @@ -642,7 +888,15 @@ describe("CredentialGeneratorService", () => { 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -652,7 +906,15 @@ describe("CredentialGeneratorService", () => { 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, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -664,7 +926,15 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, settings, SomeUser); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -672,7 +942,7 @@ describe("CredentialGeneratorService", () => { }); it("follows changes to the active user", async () => { - // initialize local accound service and state provider because this test is sensitive + // initialize local account service and state provider because this test is sensitive // to some shared data in `FakeAccountService`. const accountService = new FakeAccountService(accounts); const stateProvider = new FakeStateProvider(accountService); @@ -681,7 +951,15 @@ describe("CredentialGeneratorService", () => { const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const results: any = []; const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); @@ -698,7 +976,15 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ })); @@ -711,7 +997,15 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const results: any = []; @@ -730,7 +1024,15 @@ describe("CredentialGeneratorService", () => { it("errors when the arbitrary user's stream errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let error = null; @@ -748,7 +1050,15 @@ describe("CredentialGeneratorService", () => { it("completes when the arbitrary user's stream completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let completed = false; @@ -766,7 +1076,15 @@ describe("CredentialGeneratorService", () => { it("ignores repeated arbitrary user emissions", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let count = 0; @@ -790,7 +1108,15 @@ describe("CredentialGeneratorService", () => { describe("settings", () => { it("writes to the user's state", async () => { const singleUserId$ = new BehaviorSubject(SomeUser).asObservable(); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); subject.next({ foo: "next value" }); @@ -803,7 +1129,15 @@ describe("CredentialGeneratorService", () => { it("waits for the user to become available", async () => { const singleUserId = new BehaviorSubject(null); const singleUserId$ = singleUserId.asObservable(); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); let completed = false; const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => { @@ -821,7 +1155,15 @@ describe("CredentialGeneratorService", () => { describe("policy$", () => { it("creates constraints without policy in effect when there is no policy", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); @@ -830,7 +1172,15 @@ describe("CredentialGeneratorService", () => { }); it("creates constraints with policy in effect when there is a policy", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); @@ -841,7 +1191,15 @@ describe("CredentialGeneratorService", () => { }); it("follows policy emissions", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicySubject = new BehaviorSubject([somePolicy]); @@ -862,7 +1220,15 @@ describe("CredentialGeneratorService", () => { }); it("follows user emissions", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); @@ -884,7 +1250,15 @@ describe("CredentialGeneratorService", () => { }); it("errors when the user errors", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const expectedError = { some: "error" }; @@ -902,7 +1276,15 @@ describe("CredentialGeneratorService", () => { }); it("completes when the user completes", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts index 693ffd654dc..a137c153a64 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -11,38 +11,60 @@ import { ignoreElements, map, Observable, - race, share, skipUntil, switchMap, takeUntil, + takeWhile, withLatestFrom, } 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 { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { OnDependency, SingleUserDependency, + UserBound, UserDependency, } from "@bitwarden/common/tools/dependencies"; -import { isDynamic } from "@bitwarden/common/tools/state/state-constraints-dependency"; +import { IntegrationId, IntegrationMetadata } from "@bitwarden/common/tools/integration"; +import { RestClient } from "@bitwarden/common/tools/integration/rpc"; +import { anyComplete } from "@bitwarden/common/tools/rx"; +import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer"; +import { UserEncryptor } from "@bitwarden/common/tools/state/user-encryptor.abstraction"; +import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { UserId } from "@bitwarden/common/types/guid"; import { Randomizer } from "../abstractions"; -import { Generators } from "../data"; +import { + Generators, + getForwarderConfiguration, + Integrations, + toCredentialGeneratorConfiguration, +} from "../data"; import { availableAlgorithms } from "../policies/available-algorithms-policy"; import { mapPolicyToConstraints } from "../rx"; import { CredentialAlgorithm, CredentialCategories, CredentialCategory, - CredentialGeneratorInfo, + AlgorithmInfo, CredentialPreference, + isForwarderIntegration, + ForwarderIntegration, } from "../types"; -import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; +import { + CredentialGeneratorConfiguration as Configuration, + CredentialGeneratorInfo, + GeneratorDependencyProvider, +} from "../types/credential-generator-configuration"; import { GeneratorConstraints } from "../types/generator-constraints"; import { PREFERENCES } from "./credential-preferences"; @@ -59,17 +81,33 @@ type Generate$Dependencies = Simplify & Partial; + + integration$?: Observable; }; type Algorithms$Dependencies = Partial; +const OPTIONS_FRAME_SIZE = 512; + export class CredentialGeneratorService { constructor( - private randomizer: Randomizer, - private stateProvider: StateProvider, - private policyService: PolicyService, + private readonly randomizer: Randomizer, + private readonly stateProvider: StateProvider, + private readonly policyService: PolicyService, + private readonly apiService: ApiService, + private readonly i18nService: I18nService, + private readonly encryptService: EncryptService, + private readonly cryptoService: CryptoService, ) {} + 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 @@ -84,18 +122,13 @@ export class CredentialGeneratorService { dependencies?: Generate$Dependencies, ) { // instantiate the engine - const engine = configuration.engine.create(this.randomizer); + const engine = configuration.engine.create(this.getDependencyProvider()); // stream blocks until all of these values are received const website$ = dependencies?.website$ ?? new BehaviorSubject(null); const request$ = website$.pipe(map((website) => ({ website }))); const settings$ = this.settings$(configuration, dependencies); - // monitor completion - const requestComplete$ = request$.pipe(ignoreElements(), endWith(true)); - const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true)); - const complete$ = race(requestComplete$, settingsComplete$); - // if on$ triggers before settings are loaded, trigger as soon // as they become available. let readyOn$: Observable = null; @@ -116,7 +149,7 @@ export class CredentialGeneratorService { const generate$ = (readyOn$ ?? settings$).pipe( withLatestFrom(request$, settings$), concatMap(([, request, settings]) => engine.generate(request, settings)), - takeUntil(complete$), + takeUntil(anyComplete([request$, settings$])), ); return generate$; @@ -132,11 +165,11 @@ export class CredentialGeneratorService { algorithms$( category: CredentialCategory, dependencies?: Algorithms$Dependencies, - ): Observable; + ): Observable; algorithms$( category: CredentialCategory[], dependencies?: Algorithms$Dependencies, - ): Observable; + ): Observable; algorithms$( category: CredentialCategory | CredentialCategory[], dependencies?: Algorithms$Dependencies, @@ -163,7 +196,9 @@ export class CredentialGeneratorService { return policies$; }), map((available) => { - const filtered = algorithms.filter((c) => available.has(c.id)); + const filtered = algorithms.filter( + (c) => isForwarderIntegration(c.id) || available.has(c.id), + ); return filtered; }), ); @@ -175,24 +210,79 @@ export class CredentialGeneratorService { * @param category the category or categories of interest * @returns A list containing the requested metadata. */ - algorithms(category: CredentialCategory): CredentialGeneratorInfo[]; - algorithms(category: CredentialCategory[]): CredentialGeneratorInfo[]; - algorithms(category: CredentialCategory | CredentialCategory[]): CredentialGeneratorInfo[] { - const categories = Array.isArray(category) ? category : [category]; + 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]) - .map((c) => (c === "forwarder" ? null : Generators[c])) + .flatMap((c) => CredentialCategories[c] as CredentialAlgorithm[]) + .map((id) => this.algorithm(id)) .filter((info) => info !== null); - return algorithms; + 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): CredentialGeneratorInfo { - return (id === "forwarder" ? null : Generators[id]) ?? null; + 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), + copy: this.i18nService.t(generator.copyKey), + onlyOnRequest: generator.onlyOnRequest, + request: generator.request, + }; + + if (generator.descriptionKey) { + info.description = this.i18nService.t(generator.descriptionKey); + } + + return info; + } + + private encryptor$(userId: UserId) { + const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); + const encryptor$ = this.cryptoService.userKey$(userId).pipe( + // complete when the account locks + takeWhile((key) => !!key), + map((key) => { + const encryptor = new UserKeyEncryptor(userId, this.encryptService, key, packer); + + return { userId, encryptor } satisfies UserBound<"encryptor", UserEncryptor>; + }), + ); + + return encryptor$; } /** Get the settings for the provided configuration @@ -208,27 +298,21 @@ export class CredentialGeneratorService { dependencies?: Settings$Dependencies, ) { const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$; - const completion$ = userId$.pipe(ignoreElements(), endWith(true)); + const constraints$ = this.policy$(configuration, { userId$ }); - const state$ = userId$.pipe( + const settings$ = userId$.pipe( filter((userId) => !!userId), distinctUntilChanged(), switchMap((userId) => { - const state$ = this.stateProvider - .getUserState$(configuration.settings.account, userId) - .pipe(takeUntil(completion$)); - + const state$ = new UserStateSubject( + configuration.settings.account, + (key) => this.stateProvider.getUser(userId, key), + { constraints$, singleUserEncryptor$: this.encryptor$(userId) }, + ); return state$; }), map((settings) => settings ?? structuredClone(configuration.settings.initial)), - ); - - const settings$ = combineLatest([state$, this.policy$(configuration, { userId$ })]).pipe( - map(([settings, policy]) => { - const calibration = isDynamic(policy) ? policy.calibrate(settings) : policy; - const adjusted = calibration.adjust(settings); - return adjusted; - }), + takeUntil(anyComplete(userId$)), ); return settings$; @@ -251,8 +335,11 @@ export class CredentialGeneratorService { ); // FIXME: enforce policy - const state = this.stateProvider.getUser(userId, PREFERENCES); - const subject = new UserStateSubject(state, { ...dependencies }); + const subject = new UserStateSubject( + PREFERENCES, + (key) => this.stateProvider.getUser(userId, key), + { singleUserEncryptor$: this.encryptor$(userId) }, + ); return subject; } @@ -271,10 +358,14 @@ export class CredentialGeneratorService { const userId = await firstValueFrom( dependencies.singleUserId$.pipe(filter((userId) => !!userId)), ); - const state = this.stateProvider.getUser(userId, configuration.settings.account); + const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ }); - const subject = new UserStateSubject(state, { ...dependencies, constraints$ }); + const subject = new UserStateSubject( + configuration.settings.account, + (key) => this.stateProvider.getUser(userId, key), + { constraints$, singleUserEncryptor$: this.encryptor$(userId) }, + ); return subject; } diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts index 8302450d443..1798323ec63 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -1,4 +1,7 @@ +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"; @@ -6,9 +9,58 @@ import { CredentialAlgorithm, CredentialCategory, PolicyConfiguration } from ".. 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 copy button label */ + copy: 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; @@ -21,15 +73,32 @@ export type CredentialGeneratorInfo = { /** Key used to localize the credential description in the I18nService */ descriptionKey?: string; + /* Localized generate button label */ + generateKey: string; + + /* Localized copy button label */ + copyKey: 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. */ +/** 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: { @@ -40,7 +109,7 @@ export type CredentialGeneratorConfiguration = CredentialGener // 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: Randomizer) => CredentialGenerator; + create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator; }; /** Defines the stored parameters for credential generation */ settings: { @@ -51,7 +120,10 @@ export type CredentialGeneratorConfiguration = CredentialGener constraints: Constraints; /** storage location for account-global settings */ - account: UserKeyDefinition; + account: UserKeyDefinition | ObjectKey; + + /** storage location for *plaintext* settings imports */ + import?: UserKeyDefinition | ObjectKey, Settings>; }; /** defines how to construct policy for this settings instance */ diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts index 59727fb98f2..5b74d17fa4a 100644 --- a/libs/tools/generator/core/src/types/generator-type.ts +++ b/libs/tools/generator/core/src/types/generator-type.ts @@ -1,3 +1,5 @@ +import { IntegrationId } from "@bitwarden/common/tools/integration"; + import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types"; /** A type of password that may be generated by the credential generator. */ @@ -9,8 +11,31 @@ 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 }; + +/** 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; +export type CredentialAlgorithm = + | PasswordAlgorithm + | UsernameAlgorithm + | EmailAlgorithm + | ForwarderIntegration; /** Compound credential types supported by the credential generator. */ export const CredentialCategories = Object.freeze({ @@ -21,7 +46,7 @@ export const CredentialCategories = Object.freeze({ username: UsernameAlgorithms as Readonly, /** Lists algorithms in the "email" credential category */ - email: EmailAlgorithms as Readonly, + email: EmailAlgorithms as Readonly<(EmailAlgorithm | ForwarderIntegration)[]>, }); /** Returns true when the input algorithm is a password algorithm. */ @@ -40,7 +65,7 @@ export function isUsernameAlgorithm( /** Returns true when the input algorithm is an email algorithm. */ export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { - return EmailAlgorithms.includes(algorithm as any); + return EmailAlgorithms.includes(algorithm as any) || isForwarderIntegration(algorithm); } /** A type of compound credential that may be generated by the credential generator. */ diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 884d9760078..48272cbf602 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -1,4 +1,4 @@ -import { CredentialAlgorithm, PasswordAlgorithm } from "./generator-type"; +import { EmailAlgorithm, PasswordAlgorithm, UsernameAlgorithm } from "./generator-type"; export * from "./boundary"; export * from "./catchall-generator-options"; @@ -22,7 +22,7 @@ export * from "./word-options"; /** Provided for backwards compatibility only. * @deprecated Use one of the Algorithm types instead. */ -export type GeneratorType = CredentialAlgorithm; +export type GeneratorType = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm; /** Provided for backwards compatibility only. * @deprecated Use one of the Algorithm types instead. diff --git a/libs/tools/send/send-ui/src/send-form/send-form.module.ts b/libs/tools/send/send-ui/src/send-form/send-form.module.ts index 99db65807ac..df10b563913 100644 --- a/libs/tools/send/send-ui/src/send-form/send-form.module.ts +++ b/libs/tools/send/send-ui/src/send-form/send-form.module.ts @@ -2,8 +2,11 @@ import { NgModule } from "@angular/core"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { createRandomizer, @@ -32,7 +35,15 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); safeProvider({ useClass: CredentialGeneratorService, provide: CredentialGeneratorService, - deps: [RANDOMIZER, StateProvider, PolicyService], + deps: [ + RANDOMIZER, + StateProvider, + PolicyService, + ApiService, + I18nService, + EncryptService, + CryptoService, + ], }), ], exports: [SendFormComponent], From 74dabb97bfd6902972b5af0d8156845c463d203a Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 23 Oct 2024 19:05:24 +0200 Subject: [PATCH 07/11] Move process reload ownership to key-management (#10853) --- .../browser/src/background/main.background.ts | 19 +++- .../src/background/runtime.background.ts | 6 +- apps/desktop/src/app/app.component.ts | 12 +- .../src/app/services/services.module.ts | 14 ++- .../abstractions/process-reload.service.ts | 6 + .../services/process-reload.service.ts | 106 ++++++++++++++++++ .../platform/abstractions/system.service.ts | 4 - .../src/platform/services/system.service.ts | 99 +--------------- 8 files changed, 147 insertions(+), 119 deletions(-) create mode 100644 libs/common/src/key-management/abstractions/process-reload.service.ts create mode 100644 libs/common/src/key-management/services/process-reload.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index e5a4087510c..e31b40fe815 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -75,6 +75,8 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; +import { ProcessReloadService } from "@bitwarden/common/key-management/services/process-reload.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -270,6 +272,7 @@ import CommandsBackground from "./commands.background"; import IdleBackground from "./idle.background"; import { NativeMessagingBackground } from "./nativeMessaging.background"; import RuntimeBackground from "./runtime.background"; + export default class MainBackground { messagingService: MessageSender; storageService: BrowserLocalStorageService; @@ -314,6 +317,7 @@ export default class MainBackground { badgeSettingsService: BadgeSettingsServiceAbstraction; domainSettingsService: DomainSettingsService; systemService: SystemServiceAbstraction; + processReloadService: ProcessReloadServiceAbstraction; eventCollectionService: EventCollectionServiceAbstraction; eventUploadService: EventUploadServiceAbstraction; policyService: InternalPolicyServiceAbstraction; @@ -408,7 +412,7 @@ export default class MainBackground { await this.refreshMenu(true); if (this.systemService != null) { await this.systemService.clearPendingClipboard(); - await this.systemService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(this.authService); } }; @@ -1088,15 +1092,18 @@ export default class MainBackground { }; this.systemService = new SystemService( + this.platformUtilsService, + this.autofillSettingsService, + this.taskSchedulerService, + ); + + this.processReloadService = new ProcessReloadService( this.pinService, this.messagingService, - this.platformUtilsService, systemUtilsServiceReloadCallback, - this.autofillSettingsService, this.vaultTimeoutSettingsService, this.biometricStateService, this.accountService, - this.taskSchedulerService, ); // Other fields @@ -1122,7 +1129,7 @@ export default class MainBackground { this.platformUtilsService as BrowserPlatformUtilsService, this.notificationsService, this.autofillSettingsService, - this.systemService, + this.processReloadService, this.environmentService, this.messagingService, this.logService, @@ -1551,7 +1558,7 @@ export default class MainBackground { await this.mainContextMenuHandler?.noAccess(); await this.notificationsService.updateConnection(false); await this.systemService.clearPendingClipboard(); - await this.systemService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(this.authService); } private async needsStorageReseed(userId: UserId): Promise { diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 2bc2eadf261..f934c8544bd 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -6,10 +6,10 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AutofillOverlayVisibility, ExtensionCommand } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -40,7 +40,7 @@ export default class RuntimeBackground { private platformUtilsService: BrowserPlatformUtilsService, private notificationsService: NotificationsService, private autofillSettingsService: AutofillSettingsServiceAbstraction, - private systemService: SystemService, + private processReloadSerivce: ProcessReloadServiceAbstraction, private environmentService: BrowserEnvironmentService, private messagingService: MessagingService, private logService: LogService, @@ -216,7 +216,7 @@ export default class RuntimeBackground { } await this.notificationsService.updateConnection(msg.command === "loggedIn"); - this.systemService.cancelProcessReload(); + this.processReloadSerivce.cancelProcessReload(); if (item) { await BrowserApi.focusWindow(item.commandToRetry.sender.tab.windowId); diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index dceda128c85..83dc1619fa1 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -32,6 +32,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -142,6 +143,7 @@ export class AppComponent implements OnInit, OnDestroy { private notificationsService: NotificationsService, private platformUtilsService: PlatformUtilsService, private systemService: SystemService, + private processReloadService: ProcessReloadServiceAbstraction, private stateService: StateService, private eventUploadService: EventUploadService, private policyService: InternalPolicyService, @@ -213,7 +215,7 @@ export class AppComponent implements OnInit, OnDestroy { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.updateAppMenu(); - this.systemService.cancelProcessReload(); + this.processReloadService.cancelProcessReload(); break; case "loggedOut": this.modalService.closeAll(); @@ -224,7 +226,7 @@ export class AppComponent implements OnInit, OnDestroy { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.updateAppMenu(); await this.systemService.clearPendingClipboard(); - await this.systemService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(this.authService); break; case "authBlocked": // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -268,15 +270,15 @@ export class AppComponent implements OnInit, OnDestroy { this.notificationsService.updateConnection(); await this.updateAppMenu(); await this.systemService.clearPendingClipboard(); - await this.systemService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(this.authService); break; case "startProcessReload": // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.systemService.startProcessReload(this.authService); + this.processReloadService.startProcessReload(this.authService); break; case "cancelProcessReload": - this.systemService.cancelProcessReload(); + this.processReloadService.cancelProcessReload(); break; case "reloadProcess": ipc.platform.reloadProcess(); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index c9b434aa964..36113684425 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -37,6 +37,8 @@ import { import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { ClientType } from "@bitwarden/common/enums"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; +import { ProcessReloadService } from "@bitwarden/common/key-management/services/process-reload.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService, @@ -196,16 +198,22 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: SystemServiceAbstraction, useClass: SystemService, + deps: [ + PlatformUtilsServiceAbstraction, + AutofillSettingsServiceAbstraction, + TaskSchedulerService, + ], + }), + safeProvider({ + provide: ProcessReloadServiceAbstraction, + useClass: ProcessReloadService, deps: [ PinServiceAbstraction, MessagingServiceAbstraction, - PlatformUtilsServiceAbstraction, RELOAD_CALLBACK, - AutofillSettingsServiceAbstraction, VaultTimeoutSettingsService, BiometricStateService, AccountServiceAbstraction, - TaskSchedulerService, ], }), safeProvider({ diff --git a/libs/common/src/key-management/abstractions/process-reload.service.ts b/libs/common/src/key-management/abstractions/process-reload.service.ts new file mode 100644 index 00000000000..e46c1e23199 --- /dev/null +++ b/libs/common/src/key-management/abstractions/process-reload.service.ts @@ -0,0 +1,6 @@ +import { AuthService } from "../../auth/abstractions/auth.service"; + +export abstract class ProcessReloadServiceAbstraction { + abstract startProcessReload(authService: AuthService): Promise; + abstract cancelProcessReload(): void; +} diff --git a/libs/common/src/key-management/services/process-reload.service.ts b/libs/common/src/key-management/services/process-reload.service.ts new file mode 100644 index 00000000000..2f25d63b0fd --- /dev/null +++ b/libs/common/src/key-management/services/process-reload.service.ts @@ -0,0 +1,106 @@ +import { firstValueFrom, map, timeout } from "rxjs"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { BiometricStateService } from "@bitwarden/key-management"; + +import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions"; +import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { UserId } from "../../types/guid"; +import { ProcessReloadServiceAbstraction } from "../abstractions/process-reload.service"; + +export class ProcessReloadService implements ProcessReloadServiceAbstraction { + private reloadInterval: any = null; + + constructor( + private pinService: PinServiceAbstraction, + private messagingService: MessagingService, + private reloadCallback: () => Promise = null, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private biometricStateService: BiometricStateService, + private accountService: AccountService, + ) {} + + async startProcessReload(authService: AuthService): Promise { + const accounts = await firstValueFrom(this.accountService.accounts$); + if (accounts != null) { + const keys = Object.keys(accounts); + if (keys.length > 0) { + for (const userId of keys) { + let status = await firstValueFrom(authService.authStatusFor$(userId as UserId)); + status = await authService.getAuthStatus(userId); + if (status === AuthenticationStatus.Unlocked) { + return; + } + } + } + } + + // A reloadInterval has already been set and is executing + if (this.reloadInterval != null) { + return; + } + + // If there is an active user, check if they have a pinKeyEncryptedUserKeyEphemeral. If so, prevent process reload upon lock. + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + if (userId != null) { + const ephemeralPin = await this.pinService.getPinKeyEncryptedUserKeyEphemeral(userId); + if (ephemeralPin != null) { + return; + } + } + + this.cancelProcessReload(); + await this.executeProcessReload(); + } + + private async executeProcessReload() { + const biometricLockedFingerprintValidated = await firstValueFrom( + this.biometricStateService.fingerprintValidated$, + ); + if (!biometricLockedFingerprintValidated) { + clearInterval(this.reloadInterval); + this.reloadInterval = null; + + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe( + map((a) => a?.id), + timeout(500), + ), + ); + // Replace current active user if they will be logged out on reload + if (activeUserId != null) { + const timeoutAction = await firstValueFrom( + this.vaultTimeoutSettingsService + .getVaultTimeoutActionByUserId$(activeUserId) + .pipe(timeout(500)), // safety feature to avoid this call hanging and stopping process reload from clearing memory + ); + if (timeoutAction === VaultTimeoutAction.LogOut) { + const nextUser = await firstValueFrom( + this.accountService.nextUpAccount$.pipe(map((account) => account?.id ?? null)), + ); + await this.accountService.switchAccount(nextUser); + } + } + + this.messagingService.send("reloadProcess"); + if (this.reloadCallback != null) { + await this.reloadCallback(); + } + return; + } + if (this.reloadInterval == null) { + this.reloadInterval = setInterval(async () => await this.executeProcessReload(), 1000); + } + } + + cancelProcessReload(): void { + if (this.reloadInterval != null) { + clearInterval(this.reloadInterval); + this.reloadInterval = null; + } + } +} diff --git a/libs/common/src/platform/abstractions/system.service.ts b/libs/common/src/platform/abstractions/system.service.ts index 204e336fbf4..7a34a313528 100644 --- a/libs/common/src/platform/abstractions/system.service.ts +++ b/libs/common/src/platform/abstractions/system.service.ts @@ -1,8 +1,4 @@ -import { AuthService } from "../../auth/abstractions/auth.service"; - export abstract class SystemService { - abstract startProcessReload(authService: AuthService): Promise; - abstract cancelProcessReload(): void; abstract clearClipboard(clipboardValue: string, timeoutMs?: number): Promise; abstract clearPendingClipboard(): Promise; } diff --git a/libs/common/src/platform/services/system.service.ts b/libs/common/src/platform/services/system.service.ts index 357737391c2..03e96af75b5 100644 --- a/libs/common/src/platform/services/system.service.ts +++ b/libs/common/src/platform/services/system.service.ts @@ -1,16 +1,6 @@ -import { firstValueFrom, map, Subscription, timeout } from "rxjs"; +import { firstValueFrom, Subscription } from "rxjs"; -import { BiometricStateService } from "@bitwarden/key-management"; - -import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions"; -import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; -import { AccountService } from "../../auth/abstractions/account.service"; -import { AuthService } from "../../auth/abstractions/auth.service"; -import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service"; -import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; -import { UserId } from "../../types/guid"; -import { MessagingService } from "../abstractions/messaging.service"; import { PlatformUtilsService } from "../abstractions/platform-utils.service"; import { SystemService as SystemServiceAbstraction } from "../abstractions/system.service"; import { Utils } from "../misc/utils"; @@ -18,19 +8,12 @@ import { ScheduledTaskNames } from "../scheduling/scheduled-task-name.enum"; import { TaskSchedulerService } from "../scheduling/task-scheduler.service"; export class SystemService implements SystemServiceAbstraction { - private reloadInterval: any = null; private clearClipboardTimeoutSubscription: Subscription; private clearClipboardTimeoutFunction: () => Promise = null; constructor( - private pinService: PinServiceAbstraction, - private messagingService: MessagingService, private platformUtilsService: PlatformUtilsService, - private reloadCallback: () => Promise = null, private autofillSettingsService: AutofillSettingsServiceAbstraction, - private vaultTimeoutSettingsService: VaultTimeoutSettingsService, - private biometricStateService: BiometricStateService, - private accountService: AccountService, private taskSchedulerService: TaskSchedulerService, ) { this.taskSchedulerService.registerTaskHandler( @@ -39,86 +22,6 @@ export class SystemService implements SystemServiceAbstraction { ); } - async startProcessReload(authService: AuthService): Promise { - const accounts = await firstValueFrom(this.accountService.accounts$); - if (accounts != null) { - const keys = Object.keys(accounts); - if (keys.length > 0) { - for (const userId of keys) { - let status = await firstValueFrom(authService.authStatusFor$(userId as UserId)); - status = await authService.getAuthStatus(userId); - if (status === AuthenticationStatus.Unlocked) { - return; - } - } - } - } - - // A reloadInterval has already been set and is executing - if (this.reloadInterval != null) { - return; - } - - // If there is an active user, check if they have a pinKeyEncryptedUserKeyEphemeral. If so, prevent process reload upon lock. - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - if (userId != null) { - const ephemeralPin = await this.pinService.getPinKeyEncryptedUserKeyEphemeral(userId); - if (ephemeralPin != null) { - return; - } - } - - this.cancelProcessReload(); - await this.executeProcessReload(); - } - - private async executeProcessReload() { - const biometricLockedFingerprintValidated = await firstValueFrom( - this.biometricStateService.fingerprintValidated$, - ); - if (!biometricLockedFingerprintValidated) { - clearInterval(this.reloadInterval); - this.reloadInterval = null; - - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe( - map((a) => a?.id), - timeout(500), - ), - ); - // Replace current active user if they will be logged out on reload - if (activeUserId != null) { - const timeoutAction = await firstValueFrom( - this.vaultTimeoutSettingsService - .getVaultTimeoutActionByUserId$(activeUserId) - .pipe(timeout(500)), // safety feature to avoid this call hanging and stopping process reload from clearing memory - ); - if (timeoutAction === VaultTimeoutAction.LogOut) { - const nextUser = await firstValueFrom( - this.accountService.nextUpAccount$.pipe(map((account) => account?.id ?? null)), - ); - await this.accountService.switchAccount(nextUser); - } - } - - this.messagingService.send("reloadProcess"); - if (this.reloadCallback != null) { - await this.reloadCallback(); - } - return; - } - if (this.reloadInterval == null) { - this.reloadInterval = setInterval(async () => await this.executeProcessReload(), 1000); - } - } - - cancelProcessReload(): void { - if (this.reloadInterval != null) { - clearInterval(this.reloadInterval); - this.reloadInterval = null; - } - } - async clearClipboard(clipboardValue: string, timeoutMs: number = null): Promise { this.clearClipboardTimeoutSubscription?.unsubscribe(); From 7b8aac229c8aabb31fd77a2ab9a65f9241d6cd2f Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 23 Oct 2024 10:30:25 -0700 Subject: [PATCH 08/11] [PM-13456] - Password health service (#11658) * add password health service * add spec. fix logic in password reuse * move service to bitwarden_license * revert change to tsconfig * fix spec * fix import --- .../password-health-members.component.ts | 177 +++--------------- .../password-health.component.spec.ts | 78 ++------ .../password-health.component.ts | 172 ++--------------- .../password-health.mock.ts | 66 ------- apps/web/tsconfig.json | 1 + bitwarden_license/bit-common/jest.config.js | 6 +- .../reports/access-intelligence/index.ts | 1 + .../services/ciphers.mock.ts | 128 +++++++++++++ .../access-intelligence/services/index.ts | 2 + .../member-cipher-details-response.mock.ts | 68 +++++++ .../services/password-health.service.spec.ts | 136 ++++++++++++++ .../services/password-health.service.ts | 166 ++++++++++++++++ bitwarden_license/bit-common/test.setup.ts | 1 + 13 files changed, 560 insertions(+), 442 deletions(-) delete mode 100644 apps/web/src/app/tools/access-intelligence/password-health.mock.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts create mode 100644 bitwarden_license/bit-common/test.setup.ts diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts b/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts index fd04974b2ce..30c9ad8dba8 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts @@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { from, map, switchMap, tap } from "rxjs"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +// eslint-disable-next-line no-restricted-imports +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeModule, @@ -28,10 +26,6 @@ import { HeaderModule } from "../../layouts/header/header.module"; import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module"; // eslint-disable-next-line no-restricted-imports import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; -// eslint-disable-next-line no-restricted-imports -import { cipherData } from "../reports/pages/reports-ciphers.mock"; - -import { userData } from "./password-health.mock"; @Component({ standalone: true, @@ -47,24 +41,18 @@ import { userData } from "./password-health.mock"; HeaderModule, TableModule, ], + providers: [PasswordHealthService], }) export class PasswordHealthMembersComponent implements OnInit { passwordStrengthMap = new Map(); - weakPasswordCiphers: CipherView[] = []; - passwordUseMap = new Map(); exposedPasswordMap = new Map(); - dataSource = new TableDataSource(); - totalMembersMap = new Map(); - reportCiphers: CipherView[] = []; - reportCipherIds: string[] = []; - - organization: Organization; + dataSource = new TableDataSource(); loading = true; @@ -73,7 +61,6 @@ export class PasswordHealthMembersComponent implements OnInit { constructor( protected cipherService: CipherService, protected passwordStrengthService: PasswordStrengthServiceAbstraction, - protected organizationService: OrganizationService, protected auditService: AuditService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, @@ -83,151 +70,29 @@ export class PasswordHealthMembersComponent implements OnInit { this.activatedRoute.paramMap .pipe( takeUntilDestroyed(this.destroyRef), - map((params) => params.get("organizationId")), - switchMap((organizationId) => { - return from(this.organizationService.get(organizationId)); + map(async (params) => { + const organizationId = params.get("organizationId"); + await this.setCiphers(organizationId); }), - tap((organization) => { - this.organization = organization; - }), - switchMap(() => from(this.setCiphers())), ) .subscribe(); - - // mock data - will be replaced with actual data - userData.forEach((user) => { - user.cipherIds.forEach((cipherId: string) => { - if (this.totalMembersMap.has(cipherId)) { - this.totalMembersMap.set(cipherId, (this.totalMembersMap.get(cipherId) || 0) + 1); - } else { - this.totalMembersMap.set(cipherId, 1); - } - }); - }); } - async setCiphers() { - // const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id); - const allCiphers = cipherData; - allCiphers.forEach(async (cipher) => { - this.findWeakPassword(cipher); - this.findReusedPassword(cipher); - await this.findExposedPassword(cipher); - }); - this.dataSource.data = this.reportCiphers; - this.loading = false; - } - - protected checkForExistingCipher(ciph: CipherView) { - if (!this.reportCipherIds.includes(ciph.id)) { - this.reportCipherIds.push(ciph.id); - this.reportCiphers.push(ciph); - } - } - - protected async findExposedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword, id } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const exposedCount = await this.auditService.passwordLeaked(login.password); - if (exposedCount > 0) { - this.exposedPasswordMap.set(id, exposedCount); - this.checkForExistingCipher(cipher); - } - } - - protected findReusedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - if (this.passwordUseMap.has(login.password)) { - this.passwordUseMap.set(login.password, this.passwordUseMap.get(login.password) || 0 + 1); - } else { - this.passwordUseMap.set(login.password, 1); - } - - this.checkForExistingCipher(cipher); - } - - protected findWeakPassword(cipher: CipherView): void { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const hasUserName = this.isUserNameNotEmpty(cipher); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - login.username - .substring(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } - } - const { score } = this.passwordStrengthService.getPasswordStrength( - login.password, - null, - userInput.length > 0 ? userInput : null, + async setCiphers(organizationId: string) { + const passwordHealthService = new PasswordHealthService( + this.passwordStrengthService, + this.auditService, + this.cipherService, + organizationId, ); - if (score != null && score <= 2) { - this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); - this.checkForExistingCipher(cipher); - } - } + await passwordHealthService.generateReport(); - private isUserNameNotEmpty(c: CipherView): boolean { - return !Utils.isNullOrWhitespace(c.login.username); - } - - private scoreKey(score: number): [string, BadgeVariant] { - switch (score) { - case 4: - return ["strong", "success"]; - case 3: - return ["good", "primary"]; - case 2: - return ["weak", "warning"]; - default: - return ["veryWeak", "danger"]; - } + this.dataSource.data = passwordHealthService.reportCiphers; + this.exposedPasswordMap = passwordHealthService.exposedPasswordMap; + this.passwordStrengthMap = passwordHealthService.passwordStrengthMap; + this.passwordUseMap = passwordHealthService.passwordUseMap; + this.totalMembersMap = passwordHealthService.totalMembersMap; + this.loading = false; } } diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts b/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts index 4a6d5c50ee1..d41807e7d2d 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts @@ -1,11 +1,11 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute, convertToParamMap } from "@angular/router"; -import { MockProxy, mock } from "jest-mock-extended"; +import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +// eslint-disable-next-line no-restricted-imports +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -14,39 +14,30 @@ import { TableBodyDirective } from "@bitwarden/components/src/table/table.compon import { LooseComponentsModule } from "../../shared"; import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; -// eslint-disable-next-line no-restricted-imports -import { cipherData } from "../reports/pages/reports-ciphers.mock"; import { PasswordHealthComponent } from "./password-health.component"; describe("PasswordHealthComponent", () => { let component: PasswordHealthComponent; let fixture: ComponentFixture; - let passwordStrengthService: MockProxy; - let organizationService: MockProxy; - let cipherServiceMock: MockProxy; - let auditServiceMock: MockProxy; const activeRouteParams = convertToParamMap({ organizationId: "orgId" }); beforeEach(async () => { - passwordStrengthService = mock(); - auditServiceMock = mock(); - organizationService = mock({ - get: jest.fn().mockResolvedValue({ id: "orgId" } as Organization), - }); - cipherServiceMock = mock({ - getAllFromApiForOrganization: jest.fn().mockResolvedValue(cipherData), - }); - await TestBed.configureTestingModule({ imports: [PasswordHealthComponent, PipesModule, TableModule, LooseComponentsModule], declarations: [TableBodyDirective], providers: [ - { provide: CipherService, useValue: cipherServiceMock }, - { provide: PasswordStrengthServiceAbstraction, useValue: passwordStrengthService }, - { provide: OrganizationService, useValue: organizationService }, + { provide: CipherService, useValue: mock() }, { provide: I18nService, useValue: mock() }, - { provide: AuditService, useValue: auditServiceMock }, + { provide: AuditService, useValue: mock() }, + { + provide: PasswordStrengthServiceAbstraction, + useValue: mock(), + }, + { + provide: PasswordHealthService, + useValue: mock(), + }, { provide: ActivatedRoute, useValue: { @@ -69,46 +60,5 @@ describe("PasswordHealthComponent", () => { expect(component).toBeTruthy(); }); - it("should populate reportCiphers with ciphers that have password issues", async () => { - passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 1 } as any); - - auditServiceMock.passwordLeaked.mockResolvedValue(5); - - await component.setCiphers(); - - const cipherIds = component.reportCiphers.map((c) => c.id); - - expect(cipherIds).toEqual([ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - ]); - expect(component.reportCiphers.length).toEqual(3); - }); - - it("should correctly populate passwordStrengthMap", async () => { - passwordStrengthService.getPasswordStrength.mockImplementation((password) => { - let score = 0; - if (password === "123") { - score = 1; - } else { - score = 4; - } - return { score } as any; - }); - - auditServiceMock.passwordLeaked.mockResolvedValue(0); - - await component.setCiphers(); - - expect(component.passwordStrengthMap.size).toBeGreaterThan(0); - expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([ - "veryWeak", - "danger", - ]); - expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([ - "veryWeak", - "danger", - ]); - }); + it("should call generateReport on init", () => {}); }); diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.ts b/apps/web/src/app/tools/access-intelligence/password-health.component.ts index 6e8e62c50db..4b7b8e394d3 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health.component.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health.component.ts @@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { from, map, switchMap, tap } from "rxjs"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +// eslint-disable-next-line no-restricted-imports +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeModule, @@ -43,23 +41,17 @@ import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; HeaderModule, TableModule, ], + providers: [PasswordHealthService], }) export class PasswordHealthComponent implements OnInit { passwordStrengthMap = new Map(); - weakPasswordCiphers: CipherView[] = []; - passwordUseMap = new Map(); exposedPasswordMap = new Map(); dataSource = new TableDataSource(); - reportCiphers: CipherView[] = []; - reportCipherIds: string[] = []; - - organization: Organization; - loading = true; private destroyRef = inject(DestroyRef); @@ -67,7 +59,6 @@ export class PasswordHealthComponent implements OnInit { constructor( protected cipherService: CipherService, protected passwordStrengthService: PasswordStrengthServiceAbstraction, - protected organizationService: OrganizationService, protected auditService: AuditService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, @@ -77,153 +68,28 @@ export class PasswordHealthComponent implements OnInit { this.activatedRoute.paramMap .pipe( takeUntilDestroyed(this.destroyRef), - map((params) => params.get("organizationId")), - switchMap((organizationId) => { - return from(this.organizationService.get(organizationId)); + map(async (params) => { + const organizationId = params.get("organizationId"); + await this.setCiphers(organizationId); }), - tap((organization) => { - this.organization = organization; - }), - switchMap(() => from(this.setCiphers())), ) .subscribe(); } - async setCiphers() { - const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id); - allCiphers.forEach(async (cipher) => { - this.findWeakPassword(cipher); - this.findReusedPassword(cipher); - await this.findExposedPassword(cipher); - }); - this.dataSource.data = this.reportCiphers; - this.loading = false; - - // const reportIssues = allCiphers.map((c) => { - // if (this.passwordStrengthMap.has(c.id)) { - // return c; - // } - - // if (this.passwordUseMap.has(c.id)) { - // return c; - // } - - // if (this.exposedPasswordMap.has(c.id)) { - // return c; - // } - // }); - } - - protected checkForExistingCipher(ciph: CipherView) { - if (!this.reportCipherIds.includes(ciph.id)) { - this.reportCipherIds.push(ciph.id); - this.reportCiphers.push(ciph); - } - } - - protected async findExposedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword, id } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const exposedCount = await this.auditService.passwordLeaked(login.password); - if (exposedCount > 0) { - this.exposedPasswordMap.set(id, exposedCount); - this.checkForExistingCipher(cipher); - } - } - - protected findReusedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - if (this.passwordUseMap.has(login.password)) { - this.passwordUseMap.set(login.password, this.passwordUseMap.get(login.password) || 0 + 1); - } else { - this.passwordUseMap.set(login.password, 1); - } - - this.checkForExistingCipher(cipher); - } - - protected findWeakPassword(cipher: CipherView): void { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const hasUserName = this.isUserNameNotEmpty(cipher); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - login.username - .substring(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } - } - const { score } = this.passwordStrengthService.getPasswordStrength( - login.password, - null, - userInput.length > 0 ? userInput : null, + async setCiphers(organizationId: string) { + const passwordHealthService = new PasswordHealthService( + this.passwordStrengthService, + this.auditService, + this.cipherService, + organizationId, ); - if (score != null && score <= 2) { - this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); - this.checkForExistingCipher(cipher); - } - } + await passwordHealthService.generateReport(); - private isUserNameNotEmpty(c: CipherView): boolean { - return !Utils.isNullOrWhitespace(c.login.username); - } - - private scoreKey(score: number): [string, BadgeVariant] { - switch (score) { - case 4: - return ["strong", "success"]; - case 3: - return ["good", "primary"]; - case 2: - return ["weak", "warning"]; - default: - return ["veryWeak", "danger"]; - } + this.dataSource.data = passwordHealthService.reportCiphers; + this.exposedPasswordMap = passwordHealthService.exposedPasswordMap; + this.passwordStrengthMap = passwordHealthService.passwordStrengthMap; + this.passwordUseMap = passwordHealthService.passwordUseMap; + this.loading = false; } } diff --git a/apps/web/src/app/tools/access-intelligence/password-health.mock.ts b/apps/web/src/app/tools/access-intelligence/password-health.mock.ts deleted file mode 100644 index d01edc37a59..00000000000 --- a/apps/web/src/app/tools/access-intelligence/password-health.mock.ts +++ /dev/null @@ -1,66 +0,0 @@ -export const userData: any[] = [ - { - userName: "David Brent", - email: "david.brent@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - userName: "Tim Canterbury", - email: "tim.canterbury@wernhamhogg.uk", - usesKeyConnector: false, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - userName: "Gareth Keenan", - email: "gareth.keenan@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - "cbea34a8-bde4-46ad-9d19-b05001227nm7", - ], - }, - { - userName: "Dawn Tinsley", - email: "dawn.tinsley@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - ], - }, - { - userName: "Keith Bishop", - email: "keith.bishop@wernhamhogg.uk", - usesKeyConnector: false, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - userName: "Chris Finch", - email: "chris.finch@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - ], - }, -]; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3b0c897e91e..3799945ea98 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -10,6 +10,7 @@ "@bitwarden/auth/common": ["../../libs/auth/src/common"], "@bitwarden/auth/angular": ["../../libs/auth/src/angular"], "@bitwarden/billing": ["../../libs/billing/src"], + "@bitwarden/bit-common/*": ["../../bitwarden_license/bit-common/src/*"], "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], "@bitwarden/generator-components": ["../../libs/tools/generator/components/src"], diff --git a/bitwarden_license/bit-common/jest.config.js b/bitwarden_license/bit-common/jest.config.js index d79f8ae6199..a0441b01883 100644 --- a/bitwarden_license/bit-common/jest.config.js +++ b/bitwarden_license/bit-common/jest.config.js @@ -1,16 +1,16 @@ const { pathsToModuleNameMapper } = require("ts-jest"); - const { compilerOptions } = require("./tsconfig"); - const sharedConfig = require("../../libs/shared/jest.config.angular"); /** @type {import('jest').Config} */ module.exports = { ...sharedConfig, displayName: "bit-common tests", - preset: "ts-jest", testEnvironment: "jsdom", moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { prefix: "/", }), + setupFilesAfterEnv: ["/test.setup.ts"], + transformIgnorePatterns: ["node_modules/(?!(.*\\.mjs$|@angular|rxjs|@bitwarden))"], + moduleFileExtensions: ["ts", "js", "html", "mjs"], }; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts index e69de29bb2d..b2221a94a89 100644 --- a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts @@ -0,0 +1 @@ +export * from "./services"; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts new file mode 100644 index 00000000000..22b9148c840 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts @@ -0,0 +1,128 @@ +export const mockCiphers: any[] = [ + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001228ab1", + organizationId: null, + folderId: null, + name: "Cannot Be Edited", + notes: null, + isDeleted: false, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + password: "123", + }, + edit: false, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001228ab2", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 2", + notes: null, + isDeleted: false, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + password: "123", + hasUris: true, + uris: [ + { + uri: "http://nothing.com", + }, + ], + }, + edit: true, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001228cd3", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 3", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + password: "123", + hasUris: true, + uris: [ + { + uri: "http://example.com", + }, + ], + }, + edit: true, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001228xy4", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 4", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + hasUris: true, + uris: [{ uri: "101domain.com" }], + }, + edit: true, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001227nm5", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 5", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + hasUris: true, + uris: [{ uri: "123formbuilder.com" }], + }, + edit: true, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, +]; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts new file mode 100644 index 00000000000..c7bace84e5b --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts @@ -0,0 +1,2 @@ +export * from "./member-cipher-details-api.service"; +export * from "./password-health.service"; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts new file mode 100644 index 00000000000..78cc105e9b4 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts @@ -0,0 +1,68 @@ +export const mockMemberCipherDetailsResponse: { data: any[] } = { + data: [ + { + UserName: "David Brent", + Email: "david.brent@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + UserName: "Tim Canterbury", + Email: "tim.canterbury@wernhamhogg.uk", + UsesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + UserName: "Gareth Keenan", + Email: "gareth.keenan@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + "cbea34a8-bde4-46ad-9d19-b05001227nm7", + ], + }, + { + UserName: "Dawn Tinsley", + Email: "dawn.tinsley@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + { + UserName: "Keith Bishop", + Email: "keith.bishop@wernhamhogg.uk", + UsesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + UserName: "Chris Finch", + Email: "chris.finch@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + ], +}; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts new file mode 100644 index 00000000000..8f391b7d569 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts @@ -0,0 +1,136 @@ +import { TestBed } from "@angular/core/testing"; + +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { mockCiphers } from "./ciphers.mock"; +import { PasswordHealthService } from "./password-health.service"; + +describe("PasswordHealthService", () => { + let service: PasswordHealthService; + let cipherService: CipherService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + PasswordHealthService, + { + provide: PasswordStrengthServiceAbstraction, + useValue: { + getPasswordStrength: (password: string) => { + const score = password.length < 4 ? 1 : 4; + return { score }; + }, + }, + }, + { + provide: AuditService, + useValue: { + passwordLeaked: (password: string) => Promise.resolve(password === "123" ? 100 : 0), + }, + }, + { + provide: CipherService, + useValue: { + getAllFromApiForOrganization: jest.fn().mockResolvedValue(CipherData), + }, + }, + { provide: "organizationId", useValue: "org1" }, + ], + }); + + service = TestBed.inject(PasswordHealthService); + cipherService = TestBed.inject(CipherService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should initialize properties", () => { + expect(service.reportCiphers).toEqual([]); + expect(service.reportCipherIds).toEqual([]); + expect(service.passwordStrengthMap.size).toBe(0); + expect(service.passwordUseMap.size).toBe(0); + expect(service.exposedPasswordMap.size).toBe(0); + expect(service.totalMembersMap.size).toBe(0); + }); + + describe("generateReport", () => { + beforeEach(async () => { + await service.generateReport(); + }); + + it("should fetch all ciphers for the organization", () => { + expect(cipherService.getAllFromApiForOrganization).toHaveBeenCalledWith("org1"); + }); + + it("should populate reportCiphers with ciphers that have issues", () => { + expect(service.reportCiphers.length).toBeGreaterThan(0); + }); + + it("should detect weak passwords", () => { + expect(service.passwordStrengthMap.size).toBeGreaterThan(0); + expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toEqual([ + "veryWeak", + "danger", + ]); + expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([ + "veryWeak", + "danger", + ]); + expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([ + "veryWeak", + "danger", + ]); + }); + + it("should detect reused passwords", () => { + expect(service.passwordUseMap.get("123")).toBe(3); + }); + + it("should detect exposed passwords", () => { + expect(service.exposedPasswordMap.size).toBeGreaterThan(0); + expect(service.exposedPasswordMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(100); + }); + + it("should calculate total members per cipher", () => { + expect(service.totalMembersMap.size).toBeGreaterThan(0); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(2); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toBe(4); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toBe(5); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm5")).toBe(4); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm7")).toBe(1); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228xy4")).toBe(6); + }); + }); + + describe("findWeakPassword", () => { + it("should add weak passwords to passwordStrengthMap", () => { + const weakCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView; + service.findWeakPassword(weakCipher); + expect(service.passwordStrengthMap.get(weakCipher.id)).toEqual(["veryWeak", "danger"]); + }); + }); + + describe("findReusedPassword", () => { + it("should detect password reuse", () => { + mockCiphers.forEach((cipher) => { + service.findReusedPassword(cipher as CipherView); + }); + const reuseCounts = Array.from(service.passwordUseMap.values()).filter((count) => count > 1); + expect(reuseCounts.length).toBeGreaterThan(0); + }); + }); + + describe("findExposedPassword", () => { + it("should add exposed passwords to exposedPasswordMap", async () => { + const exposedCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView; + await service.findExposedPassword(exposedCipher); + expect(service.exposedPasswordMap.get(exposedCipher.id)).toBe(100); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts new file mode 100644 index 00000000000..ce78aba426d --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts @@ -0,0 +1,166 @@ +import { Inject, Injectable } from "@angular/core"; + +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { BadgeVariant } from "@bitwarden/components"; + +import { mockCiphers } from "./ciphers.mock"; +import { mockMemberCipherDetailsResponse } from "./member-cipher-details-response.mock"; + +@Injectable() +export class PasswordHealthService { + reportCiphers: CipherView[] = []; + + reportCipherIds: string[] = []; + + passwordStrengthMap = new Map(); + + passwordUseMap = new Map(); + + exposedPasswordMap = new Map(); + + totalMembersMap = new Map(); + + constructor( + private passwordStrengthService: PasswordStrengthServiceAbstraction, + private auditService: AuditService, + private cipherService: CipherService, + @Inject("organizationId") private organizationId: string, + ) {} + + async generateReport() { + let allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organizationId); + // TODO remove when actual user member data is available + allCiphers = mockCiphers; + allCiphers.forEach(async (cipher) => { + this.findWeakPassword(cipher); + this.findReusedPassword(cipher); + await this.findExposedPassword(cipher); + }); + + // TODO - fetch actual user member when data is available + mockMemberCipherDetailsResponse.data.forEach((user) => { + user.cipherIds.forEach((cipherId: string) => { + if (this.totalMembersMap.has(cipherId)) { + this.totalMembersMap.set(cipherId, (this.totalMembersMap.get(cipherId) || 0) + 1); + } else { + this.totalMembersMap.set(cipherId, 1); + } + }); + }); + } + + async findExposedPassword(cipher: CipherView) { + const { type, login, isDeleted, viewPassword, id } = cipher; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return; + } + + const exposedCount = await this.auditService.passwordLeaked(login.password); + if (exposedCount > 0) { + this.exposedPasswordMap.set(id, exposedCount); + this.checkForExistingCipher(cipher); + } + } + + findReusedPassword(cipher: CipherView) { + const { type, login, isDeleted, viewPassword } = cipher; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return; + } + + if (this.passwordUseMap.has(login.password)) { + this.passwordUseMap.set(login.password, (this.passwordUseMap.get(login.password) || 0) + 1); + } else { + this.passwordUseMap.set(login.password, 1); + } + + this.checkForExistingCipher(cipher); + } + + findWeakPassword(cipher: CipherView): void { + const { type, login, isDeleted, viewPassword } = cipher; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return; + } + + const hasUserName = this.isUserNameNotEmpty(cipher); + let userInput: string[] = []; + if (hasUserName) { + const atPosition = login.username.indexOf("@"); + if (atPosition > -1) { + userInput = userInput + .concat( + login.username + .substring(0, atPosition) + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/), + ) + .filter((i) => i.length >= 3); + } else { + userInput = login.username + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/) + .filter((i) => i.length >= 3); + } + } + const { score } = this.passwordStrengthService.getPasswordStrength( + login.password, + null, + userInput.length > 0 ? userInput : null, + ); + + if (score != null && score <= 2) { + this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); + this.checkForExistingCipher(cipher); + } + } + + private isUserNameNotEmpty(c: CipherView): boolean { + return !Utils.isNullOrWhitespace(c.login.username); + } + + private scoreKey(score: number): [string, BadgeVariant] { + switch (score) { + case 4: + return ["strong", "success"]; + case 3: + return ["good", "primary"]; + case 2: + return ["weak", "warning"]; + default: + return ["veryWeak", "danger"]; + } + } + + private checkForExistingCipher(ciph: CipherView) { + if (!this.reportCipherIds.includes(ciph.id)) { + this.reportCipherIds.push(ciph.id); + this.reportCiphers.push(ciph); + } + } +} diff --git a/bitwarden_license/bit-common/test.setup.ts b/bitwarden_license/bit-common/test.setup.ts new file mode 100644 index 00000000000..a702c633967 --- /dev/null +++ b/bitwarden_license/bit-common/test.setup.ts @@ -0,0 +1 @@ +import "jest-preset-angular/setup-jest"; From a8299e7040c4b1e69c941843e84e046e2bcdc705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 23 Oct 2024 14:17:24 -0400 Subject: [PATCH 09/11] fix generate a11y binding (#11671) --- .../generator/components/src/credential-generator.component.ts | 2 +- .../generator/components/src/password-generator.component.ts | 2 +- .../generator/components/src/username-generator.component.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 25aff97f16c..c57aaadeece 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -456,7 +456,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ generate }) => generate), ); /** Emits hint key for the currently selected credential type */ diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index f6ec1b17e2d..ff2cc21d541 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -193,7 +193,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ generate }) => generate), ); private toOptions(algorithms: AlgorithmInfo[]) { diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 083ef32a3b0..ea75ef6079c 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -378,7 +378,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ generate }) => generate), ); /** Emits hint key for the currently selected credential type */ From 7c79487f041292a13ca2867ba67b048fe10a63d0 Mon Sep 17 00:00:00 2001 From: SamFrank234 <95505948+SamFrank234@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:21:56 -0400 Subject: [PATCH 10/11] [PM-7565] fix filter icon alignment (#8790) update styles so that folders and subfolders are correctly aligned in vault filters on web and desktop --- apps/desktop/src/scss/left-nav.scss | 2 ++ apps/web/src/scss/vault-filters.scss | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/desktop/src/scss/left-nav.scss b/apps/desktop/src/scss/left-nav.scss index dc882ad265d..4404110ba65 100644 --- a/apps/desktop/src/scss/left-nav.scss +++ b/apps/desktop/src/scss/left-nav.scss @@ -141,6 +141,8 @@ color: themed("headingButtonColor"); } + margin-right: 0.25rem; + &:hover, &:focus { @include themify($themes) { diff --git a/apps/web/src/scss/vault-filters.scss b/apps/web/src/scss/vault-filters.scss index 01c3903c507..27b4b8164f9 100644 --- a/apps/web/src/scss/vault-filters.scss +++ b/apps/web/src/scss/vault-filters.scss @@ -43,6 +43,7 @@ button.toggle-button, button.add-button { + margin-right: 0.25rem; &:hover, &:focus { @include themify($themes) { @@ -98,6 +99,7 @@ } .toggle-button { + margin-right: 0.25rem; &:hover, &:focus { @include themify($themes) { From 22be52d2f3912d911e899ad8c53ac8534047633c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 23 Oct 2024 14:23:28 -0400 Subject: [PATCH 11/11] [PM-12303] fix password state spurious emissions (#11670) * trace generation requests * eliminate spurious save caused by validator changes * fix emissions caused by setting bounds attrbutes --------- Co-authored-by: Daniel James Smith --- .../src/credential-generator.component.html | 16 +++++----- .../src/credential-generator.component.ts | 12 +++++-- .../src/forwarder-settings.component.ts | 2 ++ .../src/passphrase-settings.component.html | 9 +----- .../src/passphrase-settings.component.ts | 11 ++----- .../src/password-generator.component.html | 10 +++--- .../src/password-generator.component.ts | 12 +++++-- .../src/password-settings.component.html | 24 ++------------ .../src/password-settings.component.ts | 31 +------------------ .../src/username-generator.component.html | 16 ++++++---- .../src/username-generator.component.ts | 12 +++++-- 11 files changed, 64 insertions(+), 91 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 4c9fb9e7e49..06ea1f767b7 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -20,9 +20,11 @@ type="button" bitIconButton="bwi-generate" buttonType="main" - (click)="generate$.next()" + (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" - > + > + {{ credentialTypeGenerateLabel$ | async }} +
diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index c57aaadeece..a37de986499 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -376,7 +376,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { if (!a || a.onlyOnRequest) { this.value$.next("-"); } else { - this.generate$.next(); + this.generate("autogenerate"); } }); }); @@ -472,7 +472,15 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { protected readonly userId$ = new BehaviorSubject(null); /** Emits when a new credential is requested */ - protected readonly generate$ = new Subject(); + private readonly generate$ = new Subject(); + + /** Request a new value from the generator + * @param requestor a label used to trace generation request + * origin in the debugger. + */ + protected generate(requestor: string) { + this.generate$.next(requestor); + } private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts index a1e6c7acfd8..67e93c611ee 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.ts +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -143,6 +143,8 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy control.clearValidators(); } } + + this.settings.updateValueAndValidity({ emitEvent: false }); }); // the first emission is the current value; subsequent emissions are updates diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index 2a3f4b5a287..25e9684e864 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -7,14 +7,7 @@ {{ "numWords" | i18n }} - +
diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 82524eba4d8..4c171e0c205 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -91,9 +91,8 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { .get(Controls.wordSeparator) .setValidators(toValidators(Controls.wordSeparator, Generators.passphrase, constraints)); - // forward word boundaries to the template (can't do it through the rx form) - this.minNumWords = constraints.numWords.min; - this.maxNumWords = constraints.numWords.max; + this.settings.updateValueAndValidity({ emitEvent: false }); + this.policyInEffect = constraints.policyInEffect; this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly); @@ -104,12 +103,6 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); } - /** attribute binding for numWords[min] */ - protected minNumWords: number; - - /** attribute binding for numWords[max] */ - protected maxNumWords: number; - /** display binding for enterprise policy notice */ protected policyInEffect: boolean; diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index aecdf0f6a4d..96aa8f00b1c 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -18,9 +18,11 @@ type="button" bitIconButton="bwi-generate" buttonType="main" - (click)="generate$.next()" + (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" - > + > + {{ credentialTypeGenerateLabel$ | async }} + + > + {{ credentialTypeGenerateLabel$ | async }} + + > + {{ credentialTypeCopyLabel$ | async }} + @@ -44,7 +48,7 @@ diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index ea75ef6079c..838177d030d 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -313,7 +313,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { if (!a || a.onlyOnRequest) { this.value$.next("-"); } else { - this.generate$.next(); + this.generate("autogenerate"); } }); }); @@ -391,7 +391,15 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { protected readonly userId$ = new BehaviorSubject(null); /** Emits when a new credential is requested */ - protected readonly generate$ = new Subject(); + private readonly generate$ = new Subject(); + + /** Request a new value from the generator + * @param requestor a label used to trace generation request + * origin in the debugger. + */ + protected generate(requestor: string) { + this.generate$.next(requestor); + } private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({