|
-
-
-
-
-
-
-
- {{ "disabled" | i18n }}
-
-
-
- {{ "password" | i18n }}
-
-
-
- {{ "maxAccessCountReached" | i18n }}
-
-
-
- {{ "expired" | i18n }}
-
-
-
- {{ "pendingDeletion" | i18n }}
-
+
+
+
+
+
+
+
+
+ {{ "disabled" | i18n }}
+
+
+
+ {{ "password" | i18n }}
+
+
+
+ {{ "maxAccessCountReached" | i18n }}
+
+
+
+ {{ "expired" | i18n }}
+
+
+
+ {{ "pendingDeletion" | i18n }}
+
+
|
{{ s.deletionDate | date: "medium" }}
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.html
index ff0c21e4525..ee4e0ec5233 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.html
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.html
@@ -21,7 +21,7 @@
-
+
-
+
diff --git a/libs/importer/src/components/dialog/import-error-dialog.component.html b/libs/importer/src/components/dialog/import-error-dialog.component.html
index c1ad8b53932..da4cc1de421 100644
--- a/libs/importer/src/components/dialog/import-error-dialog.component.html
+++ b/libs/importer/src/components/dialog/import-error-dialog.component.html
@@ -21,9 +21,9 @@
-
+
-
+
diff --git a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html
index 1ad42f95ec2..f379f466b4a 100644
--- a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html
+++ b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html
@@ -26,7 +26,7 @@
-
+
@@ -43,6 +43,6 @@
[appA11yTitle]="'deleteCustomField' | i18n: customFieldForm.value.label"
(click)="removeField()"
>
-
+
diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html
index 4869714332c..cefd6305973 100644
--- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html
+++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html
@@ -12,7 +12,7 @@
-
+
-
+
From abb314a0e78e326bc248c43182cf7fba503d5516 Mon Sep 17 00:00:00 2001
From: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Date: Tue, 25 Mar 2025 13:32:11 -0400
Subject: [PATCH 011/228] [PM-19432] Fix Multiple WS Connections (#13985)
* Test facilitation changes
* Fix multiple connections to SignalR
---
.../internal/signalr-connection.service.ts | 46 +++++++++++++------
1 file changed, 33 insertions(+), 13 deletions(-)
diff --git a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts
index e5d210266c0..8bea98cb506 100644
--- a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts
+++ b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts
@@ -23,6 +23,11 @@ export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResp
export type SignalRNotification = Heartbeat | ReceiveMessage;
+export type TimeoutManager = {
+ setTimeout: (handler: TimerHandler, timeout: number) => number;
+ clearTimeout: (timeoutId: number) => void;
+};
+
class SignalRLogger implements ILogger {
constructor(private readonly logService: LogService) {}
@@ -51,11 +56,14 @@ export class SignalRConnectionService {
constructor(
private readonly apiService: ApiService,
private readonly logService: LogService,
+ private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
+ new HubConnectionBuilder(),
+ private readonly timeoutManager: TimeoutManager = globalThis,
) {}
connect$(userId: UserId, notificationsUrl: string) {
return new Observable((subsciber) => {
- const connection = new HubConnectionBuilder()
+ const connection = this.hubConnectionBuilderFactory()
.withUrl(notificationsUrl + "/hub", {
accessTokenFactory: () => this.apiService.getActiveBearerToken(),
skipNegotiation: true,
@@ -76,48 +84,60 @@ export class SignalRConnectionService {
let reconnectSubscription: Subscription | null = null;
// Create schedule reconnect function
- const scheduleReconnect = (): Subscription => {
+ const scheduleReconnect = () => {
if (
connection == null ||
connection.state !== HubConnectionState.Disconnected ||
(reconnectSubscription != null && !reconnectSubscription.closed)
) {
- return Subscription.EMPTY;
+ // Skip scheduling a new reconnect, either the connection isn't disconnected
+ // or an active reconnect is already scheduled.
+ return;
}
- const randomTime = this.random();
- const timeoutHandler = setTimeout(() => {
+ // If we've somehow gotten here while the subscriber is closed,
+ // we do not want to reconnect. So leave.
+ if (subsciber.closed) {
+ return;
+ }
+
+ const randomTime = this.randomReconnectTime();
+ const timeoutHandler = this.timeoutManager.setTimeout(() => {
connection
.start()
- .then(() => (reconnectSubscription = null))
+ .then(() => {
+ reconnectSubscription = null;
+ })
.catch(() => {
- reconnectSubscription = scheduleReconnect();
+ scheduleReconnect();
});
}, randomTime);
- return new Subscription(() => clearTimeout(timeoutHandler));
+ reconnectSubscription = new Subscription(() =>
+ this.timeoutManager.clearTimeout(timeoutHandler),
+ );
};
connection.onclose((error) => {
- reconnectSubscription = scheduleReconnect();
+ scheduleReconnect();
});
// Start connection
connection.start().catch(() => {
- reconnectSubscription = scheduleReconnect();
+ scheduleReconnect();
});
return () => {
+ // Cancel any possible scheduled reconnects
+ reconnectSubscription?.unsubscribe();
connection?.stop().catch((error) => {
this.logService.error("Error while stopping SignalR connection", error);
- // TODO: Does calling stop call `onclose`?
- reconnectSubscription?.unsubscribe();
});
};
});
}
- private random() {
+ private randomReconnectTime() {
return (
Math.floor(Math.random() * (MAX_RECONNECT_TIME - MIN_RECONNECT_TIME + 1)) + MIN_RECONNECT_TIME
);
From 15b2b46b85b16e3e477dc562ccac80e2a9f2bdab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?=
Date: Tue, 25 Mar 2025 17:08:30 -0400
Subject: [PATCH 012/228] [PM-18665] introduce metadata provider (#13744)
---
.../integration/integration-context.spec.ts | 15 +-
.../tools/integration/integration-metadata.ts | 4 +-
.../src/tools/log/disabled-semantic-logger.ts | 8 +-
.../tools/log/semantic-logger.abstraction.ts | 1 +
.../generator/core/src/data/integrations.ts | 8 +-
.../generator/core/src/integration/addy-io.ts | 3 +-
.../core/src/integration/duck-duck-go.ts | 3 +-
.../core/src/integration/fastmail.ts | 3 +-
.../core/src/integration/firefox-relay.ts | 3 +-
.../core/src/integration/forward-email.ts | 3 +-
.../core/src/integration/simple-login.ts | 3 +-
.../core/src/metadata/algorithm-metadata.ts | 16 +-
.../core/src/metadata/email/catchall.spec.ts | 6 +-
.../core/src/metadata/email/forwarder.ts | 79 +++-
.../src/metadata/email/plus-address.spec.ts | 6 +-
.../generator/core/src/metadata/index.ts | 18 +-
.../metadata/password/eff-word-list.spec.ts | 28 +-
.../metadata/password/random-password.spec.ts | 12 +-
.../metadata/username/eff-word-list.spec.ts | 6 +-
.../policies/available-algorithms-policy.ts | 32 +-
.../generator-metadata-provider.spec.ts | 438 ++++++++++++++++++
.../services/generator-metadata-provider.ts | 252 ++++++++++
.../credential-generator-configuration.ts | 4 +-
.../core/src/types/generator-type.ts | 8 +-
.../core/src/types/metadata-request.ts | 13 +
25 files changed, 910 insertions(+), 62 deletions(-)
create mode 100644 libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts
create mode 100644 libs/tools/generator/core/src/services/generator-metadata-provider.ts
create mode 100644 libs/tools/generator/core/src/types/metadata-request.ts
diff --git a/libs/common/src/tools/integration/integration-context.spec.ts b/libs/common/src/tools/integration/integration-context.spec.ts
index 42581c08dee..67a40afb337 100644
--- a/libs/common/src/tools/integration/integration-context.spec.ts
+++ b/libs/common/src/tools/integration/integration-context.spec.ts
@@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended";
import { I18nService } from "../../platform/abstractions/i18n.service";
+import { VendorId } from "../extension";
import { IntegrationContext } from "./integration-context";
import { IntegrationId } from "./integration-id";
@@ -8,7 +9,7 @@ import { IntegrationMetadata } from "./integration-metadata";
const EXAMPLE_META = Object.freeze({
// arbitrary
- id: "simplelogin" as IntegrationId,
+ id: "simplelogin" as IntegrationId & VendorId,
name: "Example",
// arbitrary
extends: ["forwarder"],
@@ -34,7 +35,7 @@ describe("IntegrationContext", () => {
it("throws when the baseurl isn't defined in metadata", () => {
const noBaseUrl: IntegrationMetadata = {
- id: "simplelogin" as IntegrationId, // arbitrary
+ id: "simplelogin" as IntegrationId & VendorId, // arbitrary
name: "Example",
extends: ["forwarder"], // arbitrary
selfHost: "maybe",
@@ -56,7 +57,7 @@ describe("IntegrationContext", () => {
it("ignores settings when selfhost is 'never'", () => {
const selfHostNever: IntegrationMetadata = {
- id: "simplelogin" as IntegrationId, // arbitrary
+ id: "simplelogin" as IntegrationId & VendorId, // arbitrary
name: "Example",
extends: ["forwarder"], // arbitrary
baseUrl: "example.com",
@@ -71,7 +72,7 @@ describe("IntegrationContext", () => {
it("always reads the settings when selfhost is 'always'", () => {
const selfHostAlways: IntegrationMetadata = {
- id: "simplelogin" as IntegrationId, // arbitrary
+ id: "simplelogin" as IntegrationId & VendorId, // arbitrary
name: "Example",
extends: ["forwarder"], // arbitrary
baseUrl: "example.com",
@@ -86,7 +87,7 @@ describe("IntegrationContext", () => {
it("fails when the settings are empty and selfhost is 'always'", () => {
const selfHostAlways: IntegrationMetadata = {
- id: "simplelogin" as IntegrationId, // arbitrary
+ id: "simplelogin" as IntegrationId & VendorId, // arbitrary
name: "Example",
extends: ["forwarder"], // arbitrary
baseUrl: "example.com",
@@ -101,7 +102,7 @@ describe("IntegrationContext", () => {
it("reads from the metadata by default when selfhost is 'maybe'", () => {
const selfHostMaybe: IntegrationMetadata = {
- id: "simplelogin" as IntegrationId, // arbitrary
+ id: "simplelogin" as IntegrationId & VendorId, // arbitrary
name: "Example",
extends: ["forwarder"], // arbitrary
baseUrl: "example.com",
@@ -117,7 +118,7 @@ describe("IntegrationContext", () => {
it("overrides the metadata when selfhost is 'maybe'", () => {
const selfHostMaybe: IntegrationMetadata = {
- id: "simplelogin" as IntegrationId, // arbitrary
+ id: "simplelogin" as IntegrationId & VendorId, // arbitrary
name: "Example",
extends: ["forwarder"], // arbitrary
baseUrl: "example.com",
diff --git a/libs/common/src/tools/integration/integration-metadata.ts b/libs/common/src/tools/integration/integration-metadata.ts
index e460aae828c..2073b16feb0 100644
--- a/libs/common/src/tools/integration/integration-metadata.ts
+++ b/libs/common/src/tools/integration/integration-metadata.ts
@@ -1,10 +1,12 @@
+import { VendorId } from "../extension";
+
import { ExtensionPointId } from "./extension-point-id";
import { IntegrationId } from "./integration-id";
/** The capabilities and descriptive content for an integration */
export type IntegrationMetadata = {
/** Uniquely identifies the integrator. */
- id: IntegrationId;
+ id: IntegrationId & VendorId;
/** Brand name of the integrator. */
name: string;
diff --git a/libs/common/src/tools/log/disabled-semantic-logger.ts b/libs/common/src/tools/log/disabled-semantic-logger.ts
index 054c3ed390b..21ea48bbe51 100644
--- a/libs/common/src/tools/log/disabled-semantic-logger.ts
+++ b/libs/common/src/tools/log/disabled-semantic-logger.ts
@@ -12,7 +12,11 @@ export class DisabledSemanticLogger implements SemanticLogger {
error(_content: Jsonify, _message?: string): void {}
- panic(_content: Jsonify, message?: string): never {
- throw new Error(message);
+ panic(content: Jsonify, message?: string): never {
+ if (typeof content === "string" && !message) {
+ throw new Error(content);
+ } else {
+ throw new Error(message);
+ }
}
}
diff --git a/libs/common/src/tools/log/semantic-logger.abstraction.ts b/libs/common/src/tools/log/semantic-logger.abstraction.ts
index 196d1f3f12c..51aaa917378 100644
--- a/libs/common/src/tools/log/semantic-logger.abstraction.ts
+++ b/libs/common/src/tools/log/semantic-logger.abstraction.ts
@@ -9,6 +9,7 @@ export interface SemanticLogger {
*/
debug(message: string): void;
+ // FIXME: replace Jsonify parameter with structural logging schema type
/** Logs the content at debug priority.
* Debug messages are used for diagnostics, and are typically disabled
* in production builds.
diff --git a/libs/tools/generator/core/src/data/integrations.ts b/libs/tools/generator/core/src/data/integrations.ts
index 21c883cae02..ffe4676fcd7 100644
--- a/libs/tools/generator/core/src/data/integrations.ts
+++ b/libs/tools/generator/core/src/data/integrations.ts
@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
+import { VendorId } from "@bitwarden/common/tools/extension";
import { IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
@@ -29,8 +30,11 @@ export const Integrations = Object.freeze({
const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i]));
-export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration {
- const maybeForwarder = integrations.get(id);
+export function getForwarderConfiguration(
+ id: IntegrationId | VendorId,
+): ForwarderConfiguration {
+ // these casts are for compatibility; `IntegrationId` is the old form of `VendorId`
+ const maybeForwarder = integrations.get(id as string as IntegrationId & VendorId);
if (maybeForwarder && "forwarder" in maybeForwarder) {
return maybeForwarder as ForwarderConfiguration;
diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts
index d9f2b9f121d..631c5fdb510 100644
--- a/libs/tools/generator/core/src/integration/addy-io.ts
+++ b/libs/tools/generator/core/src/integration/addy-io.ts
@@ -3,6 +3,7 @@ import {
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
+import { VendorId } from "@bitwarden/common/tools/extension";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import {
ApiSettings,
@@ -100,7 +101,7 @@ const forwarder = Object.freeze({
export const AddyIo = Object.freeze({
// integration
- id: "anonaddy" as IntegrationId,
+ id: "anonaddy" as IntegrationId & VendorId,
name: "Addy.io",
extends: ["forwarder"],
diff --git a/libs/tools/generator/core/src/integration/duck-duck-go.ts b/libs/tools/generator/core/src/integration/duck-duck-go.ts
index 0bcdd560503..d2bd6173a14 100644
--- a/libs/tools/generator/core/src/integration/duck-duck-go.ts
+++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts
@@ -3,6 +3,7 @@ import {
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
+import { VendorId } from "@bitwarden/common/tools/extension";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
@@ -89,7 +90,7 @@ const forwarder = Object.freeze({
// integration-wide configuration
export const DuckDuckGo = Object.freeze({
- id: "duckduckgo" as IntegrationId,
+ id: "duckduckgo" as IntegrationId & VendorId,
name: "DuckDuckGo",
baseUrl: "https://quack.duckduckgo.com/api",
selfHost: "never",
diff --git a/libs/tools/generator/core/src/integration/fastmail.ts b/libs/tools/generator/core/src/integration/fastmail.ts
index 69b908badc9..bfde1aa70f5 100644
--- a/libs/tools/generator/core/src/integration/fastmail.ts
+++ b/libs/tools/generator/core/src/integration/fastmail.ts
@@ -5,6 +5,7 @@ import {
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
+import { VendorId } from "@bitwarden/common/tools/extension";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
@@ -159,7 +160,7 @@ const forwarder = Object.freeze({
// integration-wide configuration
export const Fastmail = Object.freeze({
- id: "fastmail" as IntegrationId,
+ id: "fastmail" as IntegrationId & VendorId,
name: "Fastmail",
baseUrl: "https://api.fastmail.com",
selfHost: "maybe",
diff --git a/libs/tools/generator/core/src/integration/firefox-relay.ts b/libs/tools/generator/core/src/integration/firefox-relay.ts
index ae65611905f..9f40a3631ff 100644
--- a/libs/tools/generator/core/src/integration/firefox-relay.ts
+++ b/libs/tools/generator/core/src/integration/firefox-relay.ts
@@ -3,6 +3,7 @@ import {
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
+import { VendorId } from "@bitwarden/common/tools/extension";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
@@ -97,7 +98,7 @@ const forwarder = Object.freeze({
// integration-wide configuration
export const FirefoxRelay = Object.freeze({
- id: "firefoxrelay" as IntegrationId,
+ id: "firefoxrelay" as IntegrationId & VendorId,
name: "Firefox Relay",
baseUrl: "https://relay.firefox.com/api",
selfHost: "never",
diff --git a/libs/tools/generator/core/src/integration/forward-email.ts b/libs/tools/generator/core/src/integration/forward-email.ts
index d67b8d588bf..34b4602b94b 100644
--- a/libs/tools/generator/core/src/integration/forward-email.ts
+++ b/libs/tools/generator/core/src/integration/forward-email.ts
@@ -3,6 +3,7 @@ import {
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
+import { VendorId } from "@bitwarden/common/tools/extension";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
@@ -101,7 +102,7 @@ const forwarder = Object.freeze({
export const ForwardEmail = Object.freeze({
// integration metadata
- id: "forwardemail" as IntegrationId,
+ id: "forwardemail" as IntegrationId & VendorId,
name: "Forward Email",
extends: ["forwarder"],
diff --git a/libs/tools/generator/core/src/integration/simple-login.ts b/libs/tools/generator/core/src/integration/simple-login.ts
index 1581f3861f5..efbac69cec2 100644
--- a/libs/tools/generator/core/src/integration/simple-login.ts
+++ b/libs/tools/generator/core/src/integration/simple-login.ts
@@ -3,6 +3,7 @@ import {
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
+import { VendorId } from "@bitwarden/common/tools/extension";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import {
ApiSettings,
@@ -103,7 +104,7 @@ const forwarder = Object.freeze({
// integration-wide configuration
export const SimpleLogin = Object.freeze({
- id: "simplelogin" as IntegrationId,
+ id: "simplelogin" as IntegrationId & VendorId,
name: "SimpleLogin",
selfHost: "maybe",
extends: ["forwarder"],
diff --git a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts
index f776dd76e54..c07deef5535 100644
--- a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts
+++ b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts
@@ -1,5 +1,7 @@
import { CredentialAlgorithm, CredentialType } from "./type";
+type I18nKeyOrLiteral = string | { literal: string };
+
/** Credential generator metadata common across credential generators */
export type AlgorithmMetadata = {
/** Uniquely identifies the credential configuration
@@ -23,25 +25,25 @@ export type AlgorithmMetadata = {
/** Localization keys */
i18nKeys: {
/** descriptive name of the algorithm */
- name: string;
+ name: I18nKeyOrLiteral;
/** explanatory text for the algorithm */
- description?: string;
+ description?: I18nKeyOrLiteral;
/** labels the generate action */
- generateCredential: string;
+ generateCredential: I18nKeyOrLiteral;
/** message informing users when the generator produces a new credential */
- credentialGenerated: string;
+ credentialGenerated: I18nKeyOrLiteral;
/* labels the action that assigns a generated value to a domain object */
- useCredential: string;
+ useCredential: I18nKeyOrLiteral;
/** labels the generated output */
- credentialType: string;
+ credentialType: I18nKeyOrLiteral;
/** labels the copy output action */
- copyCredential: string;
+ copyCredential: I18nKeyOrLiteral;
};
/** fine-tunings for generator user experiences */
diff --git a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts
index f63f141842c..d6cc1795e0b 100644
--- a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts
+++ b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts
@@ -19,11 +19,13 @@ describe("email - catchall generator metadata", () => {
});
describe("profiles[account]", () => {
- let accountProfile: CoreProfileMetadata = null;
+ let accountProfile: CoreProfileMetadata = null!;
beforeEach(() => {
const profile = catchall.profiles[Profile.account];
- if (isCoreProfile(profile)) {
+ if (isCoreProfile(profile!)) {
accountProfile = profile;
+ } else {
+ throw new Error("this branch should never run");
}
});
diff --git a/libs/tools/generator/core/src/metadata/email/forwarder.ts b/libs/tools/generator/core/src/metadata/email/forwarder.ts
index 1dfc219d466..f4f150f33fa 100644
--- a/libs/tools/generator/core/src/metadata/email/forwarder.ts
+++ b/libs/tools/generator/core/src/metadata/email/forwarder.ts
@@ -1,4 +1,75 @@
-// Forwarders are pending integration with the extension API
-//
-// They use the 300-block of weights and derive their metadata
-// using logic similar to `toCredentialGeneratorConfiguration`
+import { ExtensionMetadata, ExtensionStorageKey } from "@bitwarden/common/tools/extension/type";
+import { SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc";
+import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
+
+import { getForwarderConfiguration } from "../../data";
+import { EmailDomainSettings, EmailPrefixSettings } from "../../engine";
+import { Forwarder } from "../../engine/forwarder";
+import { GeneratorDependencyProvider } from "../../types";
+import { Profile, Type } from "../data";
+import { GeneratorMetadata } from "../generator-metadata";
+import { ForwarderProfileMetadata } from "../profile-metadata";
+
+// These options are used by all forwarders; each forwarder uses a different set,
+// as defined by `GeneratorMetadata.capabilities.fields`.
+type ForwarderOptions = Partial;
+
+// update the extension metadata
+export function toForwarderMetadata(
+ extension: ExtensionMetadata,
+): GeneratorMetadata {
+ if (extension.site.id !== "forwarder") {
+ throw new Error(
+ `expected forwarder extension; received ${extension.site.id} (${extension.product.vendor.id})`,
+ );
+ }
+
+ const name = { literal: extension.product.name ?? extension.product.vendor.name };
+
+ const generator: GeneratorMetadata = {
+ id: { forwarder: extension.product.vendor.id },
+ category: Type.email,
+ weight: 300,
+ i18nKeys: {
+ name,
+ description: "forwardedEmailDesc",
+ generateCredential: "generateEmail",
+ credentialGenerated: "emailGenerated",
+ useCredential: "useThisEmail",
+ credentialType: "email",
+ copyCredential: "copyEmail",
+ },
+ capabilities: {
+ autogenerate: false,
+ fields: [...extension.requestedFields],
+ },
+ engine: {
+ create(dependencies: GeneratorDependencyProvider) {
+ const config = getForwarderConfiguration(extension.product.vendor.id);
+ return new Forwarder(config, dependencies.client, dependencies.i18nService);
+ },
+ },
+ profiles: {
+ [Profile.account]: {
+ type: "extension",
+ site: "forwarder",
+ storage: {
+ key: "forwarder",
+ frame: 512,
+ options: {
+ deserializer: (value) => value,
+ clearOn: ["logout"],
+ },
+ } satisfies ExtensionStorageKey,
+ constraints: {
+ default: {},
+ create() {
+ return new IdentityConstraint();
+ },
+ },
+ } satisfies ForwarderProfileMetadata,
+ },
+ };
+
+ return generator;
+}
diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts
index 2ac7645ed30..063cb71c23a 100644
--- a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts
+++ b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts
@@ -19,11 +19,13 @@ describe("email - plus address generator metadata", () => {
});
describe("profiles[account]", () => {
- let accountProfile: CoreProfileMetadata = null;
+ let accountProfile: CoreProfileMetadata = null!;
beforeEach(() => {
const profile = plusAddress.profiles[Profile.account];
- if (isCoreProfile(profile)) {
+ if (isCoreProfile(profile!)) {
accountProfile = profile;
+ } else {
+ throw new Error("this branch should never run");
}
});
diff --git a/libs/tools/generator/core/src/metadata/index.ts b/libs/tools/generator/core/src/metadata/index.ts
index 79806fd1bcc..d9437822270 100644
--- a/libs/tools/generator/core/src/metadata/index.ts
+++ b/libs/tools/generator/core/src/metadata/index.ts
@@ -1,12 +1,24 @@
-import { AlgorithmsByType as ABT } from "./data";
+import {
+ Algorithm as AlgorithmData,
+ AlgorithmsByType as AlgorithmsByTypeData,
+ Type as TypeData,
+} from "./data";
import { CredentialType, CredentialAlgorithm } from "./type";
// `CredentialAlgorithm` is defined in terms of `ABT`; supplying
// type information in the barrel file breaks a circular dependency.
/** Credential generation algorithms grouped by purpose. */
-export const AlgorithmsByType: Record> = ABT;
+export const AlgorithmsByType: Record<
+ CredentialType,
+ ReadonlyArray
+> = AlgorithmsByTypeData;
+export const Algorithms: ReadonlyArray = Object.freeze(
+ Object.values(AlgorithmData),
+);
+export const Types: ReadonlyArray = Object.freeze(Object.values(TypeData));
-export { Profile, Type } from "./data";
+export { Profile, Type, Algorithm } from "./data";
+export { toForwarderMetadata } from "./email/forwarder";
export { GeneratorMetadata } from "./generator-metadata";
export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata";
export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type";
diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts
index 57961a60033..e02d63d3d59 100644
--- a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts
+++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts
@@ -22,19 +22,21 @@ describe("password - eff words generator metadata", () => {
});
describe("profiles[account]", () => {
- let accountProfile: CoreProfileMetadata = null;
+ let accountProfile: CoreProfileMetadata | null = null;
beforeEach(() => {
const profile = effPassphrase.profiles[Profile.account];
- if (isCoreProfile(profile)) {
+ if (isCoreProfile(profile!)) {
accountProfile = profile;
+ } else {
+ accountProfile = null;
}
});
describe("storage.options.deserializer", () => {
it("returns its input", () => {
- const value: PassphraseGenerationOptions = { ...accountProfile.storage.initial };
+ const value: PassphraseGenerationOptions = { ...accountProfile!.storage.initial };
- const result = accountProfile.storage.options.deserializer(value);
+ const result = accountProfile!.storage.options.deserializer(value);
expect(result).toBe(value);
});
@@ -46,15 +48,15 @@ describe("password - eff words generator metadata", () => {
// enclosed behaviors change.
it("creates a passphrase policy constraints", () => {
- const context = { defaultConstraints: accountProfile.constraints.default };
+ const context = { defaultConstraints: accountProfile!.constraints.default };
- const constraints = accountProfile.constraints.create([], context);
+ const constraints = accountProfile!.constraints.create([], context);
expect(constraints).toBeInstanceOf(PassphrasePolicyConstraints);
});
it("forwards the policy to the constraints", () => {
- const context = { defaultConstraints: accountProfile.constraints.default };
+ const context = { defaultConstraints: accountProfile!.constraints.default };
const policies = [
{
type: PolicyType.PasswordGenerator,
@@ -66,13 +68,13 @@ describe("password - eff words generator metadata", () => {
},
] as Policy[];
- const constraints = accountProfile.constraints.create(policies, context);
+ const constraints = accountProfile!.constraints.create(policies, context);
- expect(constraints.constraints.numWords.min).toEqual(6);
+ expect(constraints.constraints.numWords?.min).toEqual(6);
});
it("combines multiple policies in the constraints", () => {
- const context = { defaultConstraints: accountProfile.constraints.default };
+ const context = { defaultConstraints: accountProfile!.constraints.default };
const policies = [
{
type: PolicyType.PasswordGenerator,
@@ -92,10 +94,10 @@ describe("password - eff words generator metadata", () => {
},
] as Policy[];
- const constraints = accountProfile.constraints.create(policies, context);
+ const constraints = accountProfile!.constraints.create(policies, context);
- expect(constraints.constraints.numWords.min).toEqual(6);
- expect(constraints.constraints.capitalize.requiredValue).toEqual(true);
+ expect(constraints.constraints.numWords?.min).toEqual(6);
+ expect(constraints.constraints.capitalize?.requiredValue).toEqual(true);
});
});
});
diff --git a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts
index d91ceaac248..9e38c50ee2a 100644
--- a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts
+++ b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts
@@ -22,11 +22,13 @@ describe("password - characters generator metadata", () => {
});
describe("profiles[account]", () => {
- let accountProfile: CoreProfileMetadata = null;
+ let accountProfile: CoreProfileMetadata = null!;
beforeEach(() => {
const profile = password.profiles[Profile.account];
- if (isCoreProfile(profile)) {
+ if (isCoreProfile(profile!)) {
accountProfile = profile;
+ } else {
+ throw new Error("this branch should never run");
}
});
@@ -69,7 +71,7 @@ describe("password - characters generator metadata", () => {
const constraints = accountProfile.constraints.create(policies, context);
- expect(constraints.constraints.length.min).toEqual(10);
+ expect(constraints.constraints.length?.min).toEqual(10);
});
it("combines multiple policies in the constraints", () => {
@@ -97,8 +99,8 @@ describe("password - characters generator metadata", () => {
const constraints = accountProfile.constraints.create(policies, context);
- expect(constraints.constraints.length.min).toEqual(14);
- expect(constraints.constraints.special.requiredValue).toEqual(true);
+ expect(constraints.constraints.length?.min).toEqual(14);
+ expect(constraints.constraints.special?.requiredValue).toEqual(true);
});
});
});
diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts
index aba9680a448..d47d5ec9fcb 100644
--- a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts
+++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts
@@ -20,11 +20,13 @@ describe("username - eff words generator metadata", () => {
});
describe("profiles[account]", () => {
- let accountProfile: CoreProfileMetadata = null;
+ let accountProfile: CoreProfileMetadata = null!;
beforeEach(() => {
const profile = effWordList.profiles[Profile.account];
- if (isCoreProfile(profile)) {
+ if (isCoreProfile(profile!)) {
accountProfile = profile;
+ } else {
+ throw new Error("this branch should never run");
}
});
diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts
index f37a8b21a3f..0c44a1a0408 100644
--- a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts
+++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts
@@ -5,13 +5,41 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
-import { CredentialAlgorithm, EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "..";
+import {
+ CredentialAlgorithm as LegacyAlgorithm,
+ EmailAlgorithms,
+ PasswordAlgorithms,
+ UsernameAlgorithms,
+} from "..";
+import { CredentialAlgorithm } from "../metadata";
/** Reduces policies to a set of available algorithms
* @param policies the policies to reduce
* @returns the resulting `AlgorithmAvailabilityPolicy`
*/
-export function availableAlgorithms(policies: Policy[]): CredentialAlgorithm[] {
+export function availableAlgorithms(policies: Policy[]): LegacyAlgorithm[] {
+ const overridePassword = policies
+ .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled)
+ .reduce(
+ (type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)),
+ null as LegacyAlgorithm,
+ );
+
+ const policy: LegacyAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms];
+ if (overridePassword) {
+ policy.push(overridePassword);
+ } else {
+ policy.push(...PasswordAlgorithms);
+ }
+
+ return policy;
+}
+
+/** Reduces policies to a set of available algorithms
+ * @param policies the policies to reduce
+ * @returns the resulting `AlgorithmAvailabilityPolicy`
+ */
+export function availableAlgorithms_vNext(policies: Policy[]): CredentialAlgorithm[] {
const overridePassword = policies
.filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled)
.reduce(
diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts b/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts
new file mode 100644
index 00000000000..958e5608449
--- /dev/null
+++ b/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts
@@ -0,0 +1,438 @@
+import { mock } from "jest-mock-extended";
+import { BehaviorSubject, ReplaySubject, firstValueFrom } from "rxjs";
+
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
+import { Account } from "@bitwarden/common/auth/abstractions/account.service";
+import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
+import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction";
+import {
+ ExtensionMetadata,
+ ExtensionSite,
+ Site,
+ SiteId,
+ SiteMetadata,
+} from "@bitwarden/common/tools/extension";
+import { ExtensionService } from "@bitwarden/common/tools/extension/extension.service";
+import { Bitwarden } from "@bitwarden/common/tools/extension/vendor/bitwarden";
+import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log";
+import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
+import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
+import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
+import { deepFreeze } from "@bitwarden/common/tools/util";
+import { UserId } from "@bitwarden/common/types/guid";
+
+import { FakeAccountService, FakeStateProvider } from "../../../../../common/spec";
+import { Algorithm, AlgorithmsByType, CredentialAlgorithm, Type, Types } from "../metadata";
+import catchall from "../metadata/email/catchall";
+import plusAddress from "../metadata/email/plus-address";
+import passphrase from "../metadata/password/eff-word-list";
+import password from "../metadata/password/random-password";
+import effWordList from "../metadata/username/eff-word-list";
+import { CredentialPreference } from "../types";
+
+import { PREFERENCES } from "./credential-preferences";
+import { GeneratorMetadataProvider } from "./generator-metadata-provider";
+
+const SomeUser = "some user" as UserId;
+const SomeAccount = {
+ id: SomeUser,
+ email: "someone@example.com",
+ emailVerified: true,
+ name: "Someone",
+};
+const SomeAccount$ = new BehaviorSubject(SomeAccount);
+
+const SomeEncryptor: UserEncryptor = {
+ userId: SomeUser,
+
+ encrypt(secret) {
+ const tmp: any = secret;
+ return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any);
+ },
+
+ decrypt(secret) {
+ const tmp: any = JSON.parse(secret.encryptedString!);
+ return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any);
+ },
+};
+
+const SomeAccountService = new FakeAccountService({
+ [SomeUser]: SomeAccount,
+});
+
+const SomeStateProvider = new FakeStateProvider(SomeAccountService);
+
+const SystemProvider = {
+ encryptor: {
+ userEncryptor$: () => {
+ return new BehaviorSubject({ encryptor: SomeEncryptor, userId: SomeUser }).asObservable();
+ },
+ organizationEncryptor$() {
+ throw new Error("`organizationEncryptor$` should never be invoked.");
+ },
+ } as LegacyEncryptorProvider,
+ state: SomeStateProvider,
+ log: disabledSemanticLoggerProvider,
+} as UserStateSubjectDependencyProvider;
+
+const SomeSiteId: SiteId = Site.forwarder;
+
+const SomeSite: SiteMetadata = Object.freeze({
+ id: SomeSiteId,
+ availableFields: [],
+});
+
+const SomePolicyService = mock();
+
+const SomeExtensionService = mock();
+
+const ApplicationProvider = {
+ /** Policy configured by the administrative console */
+ policy: SomePolicyService,
+
+ /** Client extension metadata and profile access */
+ extension: SomeExtensionService,
+
+ /** Event monitoring and diagnostic interfaces */
+ log: disabledSemanticLoggerProvider,
+} as SystemServiceProvider;
+
+describe("GeneratorMetadataProvider", () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ SomeExtensionService.site.mockImplementation(() => new ExtensionSite(SomeSite, new Map()));
+ });
+
+ describe("constructor", () => {
+ it("throws when the forwarder site isn't defined by the extension service", () => {
+ SomeExtensionService.site.mockReturnValue(undefined);
+ expect(() => new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [])).toThrow(
+ "forwarder extension site not found",
+ );
+ });
+ });
+
+ describe("metadata", () => {
+ it("returns algorithm metadata", async () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
+ password,
+ ]);
+
+ const metadata = provider.metadata(password.id);
+
+ expect(metadata).toEqual(password);
+ });
+
+ it("returns forwarder metadata", async () => {
+ const extensionMetadata: ExtensionMetadata = {
+ site: SomeSite,
+ product: { vendor: Bitwarden },
+ host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" },
+ requestedFields: [],
+ };
+ const application = {
+ ...ApplicationProvider,
+ extension: mock({
+ site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])),
+ }),
+ };
+ const provider = new GeneratorMetadataProvider(SystemProvider, application, []);
+
+ const metadata = provider.metadata({ forwarder: Bitwarden.id });
+
+ expect(metadata.id).toEqual({ forwarder: Bitwarden.id });
+ });
+
+ it("panics when metadata not found", async () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ expect(() => provider.metadata("not found" as any)).toThrow("metadata not found");
+ });
+
+ it("panics when an extension not found", async () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ expect(() => provider.metadata({ forwarder: "not found" as any })).toThrow(
+ "extension not found",
+ );
+ });
+ });
+
+ describe("types", () => {
+ it("returns the credential types", async () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ const result = provider.types();
+
+ expect(result).toEqual(expect.arrayContaining(Types));
+ });
+ });
+
+ describe("algorithms", () => {
+ it("returns the password category's algorithms", () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ const result = provider.algorithms({ type: Type.password });
+
+ expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.password]));
+ });
+
+ it("returns the username category's algorithms", () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ const result = provider.algorithms({ type: Type.username });
+
+ expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.username]));
+ });
+
+ it("returns the email category's algorithms", () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ const result = provider.algorithms({ type: Type.email });
+
+ expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.email]));
+ });
+
+ it("includes forwarder vendors in the email category's algorithms", () => {
+ const extensionMetadata: ExtensionMetadata = {
+ site: SomeSite,
+ product: { vendor: Bitwarden },
+ host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" },
+ requestedFields: [],
+ };
+ const application = {
+ ...ApplicationProvider,
+ extension: mock({
+ site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])),
+ }),
+ };
+ const provider = new GeneratorMetadataProvider(SystemProvider, application, []);
+
+ const result = provider.algorithms({ type: Type.email });
+
+ expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }]));
+ });
+
+ it.each([
+ [Algorithm.catchall],
+ [Algorithm.passphrase],
+ [Algorithm.password],
+ [Algorithm.plusAddress],
+ [Algorithm.username],
+ ])("returns explicit algorithms (=%p)", (algorithm) => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ const result = provider.algorithms({ algorithm });
+
+ expect(result).toEqual([algorithm]);
+ });
+
+ it("returns explicit forwarders", () => {
+ const extensionMetadata: ExtensionMetadata = {
+ site: SomeSite,
+ product: { vendor: Bitwarden },
+ host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" },
+ requestedFields: [],
+ };
+ const application = {
+ ...ApplicationProvider,
+ extension: mock({
+ site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])),
+ }),
+ };
+ const provider = new GeneratorMetadataProvider(SystemProvider, application, []);
+
+ const result = provider.algorithms({ algorithm: { forwarder: Bitwarden.id } });
+
+ expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }]));
+ });
+
+ it("returns an empty array when the algorithm is invalid", () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ // `any` cast required because this test subverts the type system
+ const result = provider.algorithms({ algorithm: "an invalid algorithm" as any });
+
+ expect(result).toEqual([]);
+ });
+
+ it("returns an empty array when the forwarder is invalid", () => {
+ const extensionMetadata: ExtensionMetadata = {
+ site: SomeSite,
+ product: { vendor: Bitwarden },
+ host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" },
+ requestedFields: [],
+ };
+ const application = {
+ ...ApplicationProvider,
+ extension: mock({
+ site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])),
+ }),
+ };
+ const provider = new GeneratorMetadataProvider(SystemProvider, application, []);
+
+ // `any` cast required because this test subverts the type system
+ const result = provider.algorithms({
+ algorithm: { forwarder: "an invalid forwarder" as any },
+ });
+
+ expect(result).toEqual([]);
+ });
+
+ it("panics when neither an algorithm nor a category is specified", () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ // `any` cast required because this test subverts the type system
+ expect(() => provider.algorithms({} as any)).toThrow("algorithm or type required");
+ });
+ });
+
+ describe("algorithms$", () => {
+ it.each([
+ [Algorithm.catchall, catchall],
+ [Algorithm.username, effWordList],
+ [Algorithm.password, password],
+ ])("gets a specific algorithm", async (algorithm, metadata) => {
+ SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([]));
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
+ metadata,
+ ]);
+ const result = new ReplaySubject(1);
+
+ provider.algorithms$({ algorithm }, { account$: SomeAccount$ }).subscribe(result);
+
+ await expect(firstValueFrom(result)).resolves.toEqual([algorithm]);
+ });
+
+ it.each([
+ [Type.email, [catchall, plusAddress]],
+ [Type.username, [effWordList]],
+ [Type.password, [password, passphrase]],
+ ])("gets a category of algorithms", async (category, metadata) => {
+ SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([]));
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, metadata);
+ const result = new ReplaySubject(1);
+
+ provider.algorithms$({ type: category }, { account$: SomeAccount$ }).subscribe(result);
+
+ const expectedAlgorithms = expect.arrayContaining(metadata.map((m) => m.id));
+ await expect(firstValueFrom(result)).resolves.toEqual(expectedAlgorithms);
+ });
+
+ it("omits algorithms blocked by policy", async () => {
+ const policy = new Policy({
+ type: PolicyType.PasswordGenerator,
+ enabled: true,
+ data: {
+ overridePasswordType: Algorithm.password,
+ },
+ } as any);
+ SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([policy]));
+ const metadata = [password, passphrase];
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, metadata);
+ const algorithmResult = new ReplaySubject(1);
+ const categoryResult = new ReplaySubject(1);
+
+ provider
+ .algorithms$({ algorithm: Algorithm.passphrase }, { account$: SomeAccount$ })
+ .subscribe(algorithmResult);
+ provider
+ .algorithms$({ type: Type.password }, { account$: SomeAccount$ })
+ .subscribe(categoryResult);
+
+ await expect(firstValueFrom(algorithmResult)).resolves.toEqual([]);
+ await expect(firstValueFrom(categoryResult)).resolves.toEqual([password.id]);
+ });
+
+ it("omits algorithms whose metadata is unavailable", async () => {
+ SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([]));
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
+ password,
+ ]);
+ const algorithmResult = new ReplaySubject(1);
+ const categoryResult = new ReplaySubject(1);
+
+ provider
+ .algorithms$({ algorithm: Algorithm.passphrase }, { account$: SomeAccount$ })
+ .subscribe(algorithmResult);
+ provider
+ .algorithms$({ type: Type.password }, { account$: SomeAccount$ })
+ .subscribe(categoryResult);
+
+ await expect(firstValueFrom(algorithmResult)).resolves.toEqual([]);
+ await expect(firstValueFrom(categoryResult)).resolves.toEqual([password.id]);
+ });
+
+ it("panics when neither algorithm nor category are specified", () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ expect(() => provider.algorithms$({} as any, { account$: SomeAccount$ })).toThrow(
+ "algorithm or type required",
+ );
+ });
+ });
+
+ describe("preference$", () => {
+ const preferences: CredentialPreference = deepFreeze({
+ [Type.email]: { algorithm: Algorithm.catchall, updated: new Date() },
+ [Type.username]: { algorithm: Algorithm.username, updated: new Date() },
+ [Type.password]: { algorithm: Algorithm.password, updated: new Date() },
+ });
+ beforeEach(async () => {
+ await SomeStateProvider.setUserState(PREFERENCES, preferences, SomeAccount.id);
+ });
+
+ it.each([
+ [Type.email, catchall],
+ [Type.username, effWordList],
+ [Type.password, password],
+ ])("emits the user's %s preference", async (type, metadata) => {
+ SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([]));
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
+ metadata,
+ ]);
+ const result = new ReplaySubject(1);
+
+ provider.preference$(type, { account$: SomeAccount$ }).subscribe(result);
+
+ await expect(firstValueFrom(result)).resolves.toEqual(preferences[type].algorithm);
+ });
+
+ it("emits a default when the user's preference is unavailable", async () => {
+ SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([]));
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
+ plusAddress,
+ ]);
+ const result = new ReplaySubject(1);
+
+ // precondition: the preferred email is excluded from the provided metadata
+ expect(preferences.email.algorithm).not.toEqual(plusAddress.id);
+
+ provider.preference$(Type.email, { account$: SomeAccount$ }).subscribe(result);
+
+ await expect(firstValueFrom(result)).resolves.toEqual(plusAddress.id);
+ });
+
+ it("emits undefined when the user's preference is unavailable and there is no metadata", async () => {
+ SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([]));
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+ const result = new ReplaySubject(1);
+
+ provider.preference$(Type.email, { account$: SomeAccount$ }).subscribe(result);
+
+ await expect(firstValueFrom(result)).resolves.toBeUndefined();
+ });
+ });
+
+ describe("preferences", () => {
+ it("returns a user state subject", () => {
+ const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
+
+ const subject = provider.preferences({ account$: SomeAccount$ });
+
+ expect(subject).toBeInstanceOf(UserStateSubject);
+ });
+ });
+});
diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.ts b/libs/tools/generator/core/src/services/generator-metadata-provider.ts
new file mode 100644
index 00000000000..f8c07283f5a
--- /dev/null
+++ b/libs/tools/generator/core/src/services/generator-metadata-provider.ts
@@ -0,0 +1,252 @@
+import {
+ Observable,
+ combineLatestWith,
+ distinctUntilChanged,
+ map,
+ shareReplay,
+ switchMap,
+ takeUntil,
+} from "rxjs";
+
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { Account } from "@bitwarden/common/auth/abstractions/account.service";
+import { BoundDependency } from "@bitwarden/common/tools/dependencies";
+import { ExtensionSite } from "@bitwarden/common/tools/extension";
+import { SemanticLogger } from "@bitwarden/common/tools/log";
+import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
+import { anyComplete, pin } from "@bitwarden/common/tools/rx";
+import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
+import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
+
+import {
+ GeneratorMetadata,
+ AlgorithmsByType,
+ CredentialAlgorithm,
+ CredentialType,
+ isForwarderExtensionId,
+ toForwarderMetadata,
+ Type,
+ Algorithms,
+ Types,
+} from "../metadata";
+import { availableAlgorithms_vNext } from "../policies/available-algorithms-policy";
+import { CredentialPreference } from "../types";
+import {
+ AlgorithmRequest,
+ TypeRequest,
+ MetadataRequest,
+ isAlgorithmRequest,
+ isTypeRequest,
+} from "../types/metadata-request";
+
+import { PREFERENCES } from "./credential-preferences";
+
+/** Surfaces contextual information to credential generators */
+export class GeneratorMetadataProvider {
+ /** Instantiates the context provider
+ * @param system dependency providers for user state subjects
+ * @param application dependency providers for system services
+ */
+ constructor(
+ private readonly system: UserStateSubjectDependencyProvider,
+ private readonly application: SystemServiceProvider,
+ algorithms: ReadonlyArray>,
+ ) {
+ this.log = system.log({ type: "GeneratorMetadataProvider" });
+
+ const site = application.extension.site("forwarder");
+ if (!site) {
+ this.log.panic("forwarder extension site not found");
+ }
+ this.site = site;
+
+ this._metadata = new Map(algorithms.map((a) => [a.id, a] as const));
+ }
+
+ private readonly site: ExtensionSite;
+ private readonly log: SemanticLogger;
+
+ private _metadata: Map>;
+
+ /** Retrieve an algorithm's generator metadata
+ * @param algorithm identifies the algorithm
+ * @returns the algorithm's generator metadata
+ * @throws when the algorithm doesn't identify a known metadata entry
+ */
+ metadata(algorithm: CredentialAlgorithm) {
+ let result = null;
+ if (isForwarderExtensionId(algorithm)) {
+ const extension = this.site.extensions.get(algorithm.forwarder);
+ if (!extension) {
+ this.log.panic(algorithm, "extension not found");
+ }
+
+ result = toForwarderMetadata(extension);
+ } else {
+ result = this._metadata.get(algorithm);
+ }
+
+ if (!result) {
+ this.log.panic({ algorithm }, "metadata not found");
+ }
+
+ return result;
+ }
+
+ /** retrieve credential types */
+ types(): ReadonlyArray {
+ return Types;
+ }
+
+ /** Retrieve the credential algorithm ids that match the request.
+ * @param requested when this has a `type` property, the method
+ * returns all algorithms with the same credential type. When this has an `algorithm`
+ * property, the method returns 0 or 1 matching algorithms.
+ * @returns the matching algorithms. This method always returns an array;
+ * the array is empty when no algorithms match the input criteria.
+ * @throws when neither `requested.algorithm` nor `requested.type` contains
+ * a value.
+ * @remarks this method enforces technical requirements only.
+ * If you want these algorithms with policy controls applied, use `algorithms$`.
+ */
+ algorithms(requested: AlgorithmRequest): CredentialAlgorithm[];
+ algorithms(requested: TypeRequest): CredentialAlgorithm[];
+ algorithms(requested: MetadataRequest): CredentialAlgorithm[] {
+ let algorithms: CredentialAlgorithm[];
+ if (isTypeRequest(requested)) {
+ let forwarders: CredentialAlgorithm[] = [];
+ if (requested.type === Type.email) {
+ forwarders = Array.from(this.site.extensions.keys()).map((forwarder) => ({ forwarder }));
+ }
+
+ algorithms = AlgorithmsByType[requested.type].concat(forwarders);
+ } else if (isAlgorithmRequest(requested) && isForwarderExtensionId(requested.algorithm)) {
+ algorithms = this.site.extensions.has(requested.algorithm.forwarder)
+ ? [requested.algorithm]
+ : [];
+ } else if (isAlgorithmRequest(requested)) {
+ algorithms = Algorithms.includes(requested.algorithm) ? [requested.algorithm] : [];
+ } else {
+ this.log.panic(requested, "algorithm or type required");
+ }
+
+ return algorithms;
+ }
+
+ // emits a function that returns `true` when the input algorithm is available
+ private isAvailable$(
+ dependencies: BoundDependency<"account", Account>,
+ ): Observable<(a: CredentialAlgorithm) => boolean> {
+ const id$ = dependencies.account$.pipe(
+ map((account) => account.id),
+ pin(),
+ shareReplay({ bufferSize: 1, refCount: true }),
+ );
+
+ const available$ = id$.pipe(
+ switchMap((id) => {
+ const policies$ = this.application.policy.getAll$(PolicyType.PasswordGenerator, id).pipe(
+ map((p) => availableAlgorithms_vNext(p).filter((a) => this._metadata.has(a))),
+ map((p) => new Set(p)),
+ // complete policy emissions otherwise `switchMap` holds `available$` open indefinitely
+ takeUntil(anyComplete(id$)),
+ );
+ return policies$;
+ }),
+ map(
+ (available) =>
+ function (a: CredentialAlgorithm) {
+ return isForwarderExtensionId(a) || available.has(a);
+ },
+ ),
+ );
+
+ return available$;
+ }
+
+ /** Retrieve credential algorithms filtered by the user's active policy.
+ * @param requested when this has a `type` property, the method
+ * returns all algorithms with a matching credential type. When this has an `algorithm`
+ * property, the method returns 0 or 1 matching algorithms.
+ * @param dependencies.account the account requesting algorithm access;
+ * this parameter controls which policy, if any, is applied.
+ * @returns an observable that emits matching algorithms. When no algorithms
+ * match the request, an empty array is emitted.
+ * @throws when neither `requested.algorithm` nor `requested.type` contains
+ * a value.
+ * @remarks this method applies policy controls. In particular, it excludes
+ * algorithms prohibited by a policy control. If you want lists of algorithms
+ * supported by the client, use `algorithms`.
+ */
+ algorithms$(
+ requested: AlgorithmRequest,
+ dependencies: BoundDependency<"account", Account>,
+ ): Observable;
+ algorithms$(
+ requested: TypeRequest,
+ dependencies: BoundDependency<"account", Account>,
+ ): Observable;
+ algorithms$(
+ requested: MetadataRequest,
+ dependencies: BoundDependency<"account", Account>,
+ ): Observable {
+ if (isTypeRequest(requested)) {
+ const { type } = requested;
+ return this.isAvailable$(dependencies).pipe(
+ map((isAvailable) => this.algorithms({ type }).filter(isAvailable)),
+ );
+ } else if (isAlgorithmRequest(requested)) {
+ const { algorithm } = requested;
+ return this.isAvailable$(dependencies).pipe(
+ map((isAvailable) => (isAvailable(algorithm) ? [algorithm] : [])),
+ );
+ } else {
+ this.log.panic(requested, "algorithm or type required");
+ }
+ }
+
+ preference$(type: CredentialType, dependencies: BoundDependency<"account", Account>) {
+ const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
+
+ const algorithm$ = this.preferences({ account$ }).pipe(
+ combineLatestWith(this.isAvailable$({ account$ })),
+ map(([preferences, isAvailable]) => {
+ const algorithm: CredentialAlgorithm = preferences[type].algorithm;
+ if (isAvailable(algorithm)) {
+ return algorithm;
+ }
+
+ const algorithms = type ? this.algorithms({ type: type }) : [];
+ // `?? null` because logging types must be `Jsonify`
+ const defaultAlgorithm = algorithms.find(isAvailable) ?? null;
+ this.log.debug(
+ { algorithm, defaultAlgorithm, credentialType: type },
+ "preference not available; defaulting the generator algorithm",
+ );
+
+ // `?? undefined` so that interface is ADR-14 compliant
+ return defaultAlgorithm ?? undefined;
+ }),
+ distinctUntilChanged(),
+ );
+
+ return algorithm$;
+ }
+
+ /** Get a subject bound to credential generator preferences.
+ * @param dependencies.account$ identifies the account to which the preferences are bound
+ * @returns a subject bound to the user's preferences
+ * @remarks Preferences determine which algorithms are used when generating a
+ * credential from a credential type (e.g. `PassX` or `Username`). Preferences
+ * should not be used to hold navigation history. Use @bitwarden/generator-navigation
+ * instead.
+ */
+ preferences(
+ dependencies: BoundDependency<"account", Account>,
+ ): UserStateSubject {
+ // FIXME: enforce policy
+ const subject = new UserStateSubject(PREFERENCES, this.system, dependencies);
+
+ return subject;
+ }
+}
diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts
index 08aec48a9e7..36b0f3046a9 100644
--- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts
+++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts
@@ -133,7 +133,9 @@ export type CredentialGeneratorConfiguration = CredentialGener
};
/** Defines the stored parameters for credential generation */
settings: {
- /** value used when an account's settings haven't been initialized */
+ /** value used when an account's settings haven't been initialized
+ * @deprecated use `ObjectKey.initial` for your desired storage property instead
+ */
initial: Readonly>;
/** Application-global constraints that apply to account settings */
diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts
index 5b74d17fa4a..c75e4329610 100644
--- a/libs/tools/generator/core/src/types/generator-type.ts
+++ b/libs/tools/generator/core/src/types/generator-type.ts
@@ -1,6 +1,8 @@
+import { VendorId } from "@bitwarden/common/tools/extension";
import { IntegrationId } from "@bitwarden/common/tools/integration";
import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types";
+import { AlgorithmsByType, CredentialType } from "../metadata";
/** A type of password that may be generated by the credential generator. */
export type PasswordAlgorithm = (typeof PasswordAlgorithms)[number];
@@ -11,7 +13,7 @@ export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number];
/** A type of email address that may be generated by the credential generator. */
export type EmailAlgorithm = (typeof EmailAlgorithms)[number];
-export type ForwarderIntegration = { forwarder: IntegrationId };
+export type ForwarderIntegration = { forwarder: IntegrationId & VendorId };
/** Returns true when the input algorithm is a forwarder integration. */
export function isForwarderIntegration(
@@ -74,8 +76,8 @@ export type CredentialCategory = keyof typeof CredentialCategories;
/** The kind of credential to generate using a compound configuration. */
// FIXME: extend the preferences to include a preferred forwarder
export type CredentialPreference = {
- [Key in CredentialCategory]: {
- algorithm: (typeof CredentialCategories)[Key][number];
+ [Key in CredentialType & CredentialCategory]: {
+ algorithm: CredentialAlgorithm & (typeof AlgorithmsByType)[Key][number];
updated: Date;
};
};
diff --git a/libs/tools/generator/core/src/types/metadata-request.ts b/libs/tools/generator/core/src/types/metadata-request.ts
new file mode 100644
index 00000000000..e9cae7060f0
--- /dev/null
+++ b/libs/tools/generator/core/src/types/metadata-request.ts
@@ -0,0 +1,13 @@
+import { CredentialAlgorithm, CredentialType } from "../metadata";
+
+export type AlgorithmRequest = { algorithm: CredentialAlgorithm };
+export type TypeRequest = { type: CredentialType };
+export type MetadataRequest = Partial;
+
+export function isAlgorithmRequest(request: MetadataRequest): request is AlgorithmRequest {
+ return !!request.algorithm;
+}
+
+export function isTypeRequest(request: MetadataRequest): request is TypeRequest {
+ return !!request.type;
+}
From f3a26497520e4fccc3365965811dcb72256156db Mon Sep 17 00:00:00 2001
From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com>
Date: Tue, 25 Mar 2025 16:34:43 -0500
Subject: [PATCH 013/228] refactor(auth): [PM-18148] replace app-link-sso
directive with LinkSsoService
Removes the app-link-sso directive and adds a LinkSsoService which is used to link an organization with SSO.
Resolves PM-18148
---
apps/web/src/app/auth/core/services/index.ts | 1 +
.../core/services/link-sso.service.spec.ts | 154 ++++++++++++++++++
.../auth/core/services/link-sso.service.ts | 91 +++++++++++
apps/web/src/app/core/core.module.ts | 13 ++
.../components/link-sso.directive.ts | 26 ---
.../organization-options.component.html | 4 +-
.../organization-options.component.ts | 33 +++-
.../vault-filter/vault-filter.module.ts | 3 +-
8 files changed, 287 insertions(+), 38 deletions(-)
create mode 100644 apps/web/src/app/auth/core/services/link-sso.service.spec.ts
create mode 100644 apps/web/src/app/auth/core/services/link-sso.service.ts
delete mode 100644 apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts
diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts
index 1e8eec759b1..11c8dd98872 100644
--- a/apps/web/src/app/auth/core/services/index.ts
+++ b/apps/web/src/app/auth/core/services/index.ts
@@ -4,3 +4,4 @@ export * from "./webauthn-login";
export * from "./set-password-jit";
export * from "./registration";
export * from "./two-factor-auth";
+export * from "./link-sso.service";
diff --git a/apps/web/src/app/auth/core/services/link-sso.service.spec.ts b/apps/web/src/app/auth/core/services/link-sso.service.spec.ts
new file mode 100644
index 00000000000..70b52999875
--- /dev/null
+++ b/apps/web/src/app/auth/core/services/link-sso.service.spec.ts
@@ -0,0 +1,154 @@
+import { mock, MockProxy } from "jest-mock-extended";
+import { BehaviorSubject } from "rxjs";
+
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
+import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
+import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
+import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+import {
+ PasswordGenerationServiceAbstraction,
+ PasswordGeneratorOptions,
+} from "@bitwarden/generator-legacy";
+
+import { LinkSsoService } from "./link-sso.service";
+
+describe("LinkSsoService", () => {
+ let sut: LinkSsoService;
+
+ let mockSsoLoginService: MockProxy;
+ let mockApiService: MockProxy;
+ let mockCryptoFunctionService: MockProxy;
+ let mockEnvironmentService: MockProxy;
+ let mockPasswordGenerationService: MockProxy;
+ let mockPlatformUtilsService: MockProxy;
+
+ const mockEnvironment$ = new BehaviorSubject({
+ getIdentityUrl: jest.fn().mockReturnValue("https://identity.bitwarden.com"),
+ });
+
+ beforeEach(() => {
+ // Create mock implementations
+ mockSsoLoginService = mock();
+ mockApiService = mock();
+ mockCryptoFunctionService = mock();
+ mockEnvironmentService = mock();
+ mockPasswordGenerationService = mock();
+ mockPlatformUtilsService = mock();
+
+ // Set up environment service to return our mock environment
+ mockEnvironmentService.environment$ = mockEnvironment$;
+
+ // Set up API service mocks
+ const mockResponse = { Token: "mockSsoToken" };
+ mockApiService.preValidateSso.mockResolvedValue(new SsoPreValidateResponse(mockResponse));
+ mockApiService.getSsoUserIdentifier.mockResolvedValue("mockUserIdentifier");
+
+ // Set up password generation service mock
+ mockPasswordGenerationService.generatePassword.mockImplementation(
+ async (options: PasswordGeneratorOptions) => {
+ return "mockGeneratedPassword";
+ },
+ );
+
+ // Set up crypto function service mock
+ mockCryptoFunctionService.hash.mockResolvedValue(new Uint8Array([1, 2, 3, 4]));
+
+ // Create the service under test with mock dependencies
+ sut = new LinkSsoService(
+ mockSsoLoginService,
+ mockApiService,
+ mockCryptoFunctionService,
+ mockEnvironmentService,
+ mockPasswordGenerationService,
+ mockPlatformUtilsService,
+ );
+
+ // Mock Utils.fromBufferToUrlB64
+ jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue("mockCodeChallenge");
+
+ // Mock window.location
+ Object.defineProperty(window, "location", {
+ value: {
+ origin: "https://bitwarden.com",
+ },
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("linkSso", () => {
+ it("throws an error when identifier is null", async () => {
+ await expect(sut.linkSso(null as unknown as string)).rejects.toThrow(
+ "SSO identifier is required",
+ );
+ });
+
+ it("throws an error when identifier is empty", async () => {
+ await expect(sut.linkSso("")).rejects.toThrow("SSO identifier is required");
+ });
+
+ it("calls preValidateSso with the provided identifier", async () => {
+ await sut.linkSso("org123");
+
+ expect(mockApiService.preValidateSso).toHaveBeenCalledWith("org123");
+ });
+
+ it("generates a password for code verifier", async () => {
+ await sut.linkSso("org123");
+
+ expect(mockPasswordGenerationService.generatePassword).toHaveBeenCalledWith({
+ type: "password",
+ length: 64,
+ uppercase: true,
+ lowercase: true,
+ number: true,
+ special: false,
+ });
+ });
+
+ it("sets the code verifier in the ssoLoginService", async () => {
+ await sut.linkSso("org123");
+
+ expect(mockSsoLoginService.setCodeVerifier).toHaveBeenCalledWith("mockGeneratedPassword");
+ });
+
+ it("generates a state and sets it in the ssoLoginService", async () => {
+ await sut.linkSso("org123");
+
+ const expectedState =
+ "mockGeneratedPassword_returnUri='/settings/organizations'_identifier=org123";
+ expect(mockSsoLoginService.setSsoState).toHaveBeenCalledWith(expectedState);
+ });
+
+ it("gets the SSO user identifier from the API", async () => {
+ await sut.linkSso("org123");
+
+ expect(mockApiService.getSsoUserIdentifier).toHaveBeenCalled();
+ });
+
+ it("launches the authorize URL with the correct parameters", async () => {
+ await sut.linkSso("org123");
+
+ expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
+ expect.stringContaining("https://identity.bitwarden.com/connect/authorize"),
+ { sameWindow: true },
+ );
+
+ const launchUriArg = mockPlatformUtilsService.launchUri.mock.calls[0][0];
+ expect(launchUriArg).toContain("client_id=web");
+ expect(launchUriArg).toContain(
+ "redirect_uri=https%3A%2F%2Fbitwarden.com%2Fsso-connector.html",
+ );
+ expect(launchUriArg).toContain("response_type=code");
+ expect(launchUriArg).toContain("code_challenge=mockCodeChallenge");
+ expect(launchUriArg).toContain("ssoToken=mockSsoToken");
+ expect(launchUriArg).toContain("user_identifier=mockUserIdentifier");
+ });
+ });
+});
diff --git a/apps/web/src/app/auth/core/services/link-sso.service.ts b/apps/web/src/app/auth/core/services/link-sso.service.ts
new file mode 100644
index 00000000000..3d51525add1
--- /dev/null
+++ b/apps/web/src/app/auth/core/services/link-sso.service.ts
@@ -0,0 +1,91 @@
+import { firstValueFrom } from "rxjs";
+
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
+import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
+import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+import {
+ PasswordGenerationServiceAbstraction,
+ PasswordGeneratorOptions,
+} from "@bitwarden/generator-legacy";
+
+/**
+ * Provides a service for linking SSO.
+ */
+export class LinkSsoService {
+ constructor(
+ private ssoLoginService: SsoLoginServiceAbstraction,
+ private apiService: ApiService,
+ private cryptoFunctionService: CryptoFunctionService,
+ private environmentService: EnvironmentService,
+ private passwordGenerationService: PasswordGenerationServiceAbstraction,
+ private platformUtilsService: PlatformUtilsService,
+ ) {}
+
+ /**
+ * Links SSO to an organization.
+ * Ported from the SsoComponent
+ * @param identifier The identifier of the organization to link to.
+ */
+ async linkSso(identifier: string) {
+ if (identifier == null || identifier === "") {
+ throw new Error("SSO identifier is required");
+ }
+
+ const redirectUri = window.location.origin + "/sso-connector.html";
+ const clientId = "web";
+ const returnUri = "/settings/organizations";
+
+ const response = await this.apiService.preValidateSso(identifier);
+
+ const passwordOptions: PasswordGeneratorOptions = {
+ type: "password",
+ length: 64,
+ uppercase: true,
+ lowercase: true,
+ number: true,
+ special: false,
+ };
+
+ const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
+ const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
+ const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
+ await this.ssoLoginService.setCodeVerifier(codeVerifier);
+
+ let state = await this.passwordGenerationService.generatePassword(passwordOptions);
+ state += `_returnUri='${returnUri}'`;
+ state += `_identifier=${identifier}`;
+
+ // Save state
+ await this.ssoLoginService.setSsoState(state);
+
+ const env = await firstValueFrom(this.environmentService.environment$);
+
+ let authorizeUrl =
+ env.getIdentityUrl() +
+ "/connect/authorize?" +
+ "client_id=" +
+ clientId +
+ "&redirect_uri=" +
+ encodeURIComponent(redirectUri) +
+ "&" +
+ "response_type=code&scope=api offline_access&" +
+ "state=" +
+ state +
+ "&code_challenge=" +
+ codeChallenge +
+ "&" +
+ "code_challenge_method=S256&response_mode=query&" +
+ "domain_hint=" +
+ encodeURIComponent(identifier) +
+ "&ssoToken=" +
+ encodeURIComponent(response.token);
+
+ const userIdentifier = await this.apiService.getSsoUserIdentifier();
+ authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
+
+ this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true });
+ }
+}
diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts
index cc9024490d6..9e6f88d18d6 100644
--- a/apps/web/src/app/core/core.module.ts
+++ b/apps/web/src/app/core/core.module.ts
@@ -116,6 +116,7 @@ import {
WebLoginDecryptionOptionsService,
WebTwoFactorAuthComponentService,
WebTwoFactorAuthDuoComponentService,
+ LinkSsoService,
} from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
@@ -345,6 +346,18 @@ const safeProviders: SafeProvider[] = [
useClass: WebSsoComponentService,
deps: [I18nServiceAbstraction],
}),
+ safeProvider({
+ provide: LinkSsoService,
+ useClass: LinkSsoService,
+ deps: [
+ SsoLoginServiceAbstraction,
+ ApiService,
+ CryptoFunctionService,
+ EnvironmentService,
+ PasswordGenerationServiceAbstraction,
+ PlatformUtilsService,
+ ],
+ }),
safeProvider({
provide: TwoFactorAuthDuoComponentService,
useClass: WebTwoFactorAuthDuoComponentService,
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts
deleted file mode 100644
index a1781889c49..00000000000
--- a/apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-// FIXME: Update this file to be type safe and remove this and next line
-// @ts-strict-ignore
-import { AfterContentInit, Directive, HostListener, Input } from "@angular/core";
-
-import { SsoComponent } from "@bitwarden/angular/auth/components/sso.component";
-import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
-
-@Directive({
- selector: "[app-link-sso]",
-})
-export class LinkSsoDirective extends SsoComponent implements AfterContentInit {
- @Input() organization: Organization;
- returnUri = "/settings/organizations";
- redirectUri = window.location.origin + "/sso-connector.html";
- clientId = "web";
-
- @HostListener("click", ["$event"])
- async onClick($event: MouseEvent) {
- $event.preventDefault();
- await this.submit(this.returnUri, true);
- }
-
- async ngAfterContentInit() {
- this.identifier = this.organization.identifier;
- }
-}
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html
index 0b94b6e2be2..0fe243ed20a 100644
--- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html
+++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html
@@ -50,10 +50,10 @@
{{ "unlinkSso" | i18n }}
-
+
+
|