1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 08:43:33 +00:00

[PM-16792] [PM-16822] Encapsulate encryptor and state provision within UserStateSubject (#13195)

This commit is contained in:
✨ Audrey ✨
2025-02-21 18:00:51 -05:00
committed by GitHub
parent 077e0f89cc
commit b4bfacf6e3
40 changed files with 1437 additions and 1362 deletions

View File

@@ -67,6 +67,7 @@ const forwarder = Object.freeze({
key: "addyIoForwarder",
target: "object",
format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<AddyIoSettings>(),
state: GENERATOR_DISK,
initial: defaultSettings,

View File

@@ -56,6 +56,7 @@ const forwarder = Object.freeze({
key: "duckDuckGoForwarder",
target: "object",
format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<DuckDuckGoSettings>(),
state: GENERATOR_DISK,
initial: defaultSettings,

View File

@@ -126,6 +126,7 @@ const forwarder = Object.freeze({
key: "fastmailForwarder",
target: "object",
format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<FastmailSettings>(),
state: GENERATOR_DISK,
initial: defaultSettings,

View File

@@ -60,6 +60,7 @@ const forwarder = Object.freeze({
key: "firefoxRelayForwarder",
target: "object",
format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<FirefoxRelaySettings>(),
state: GENERATOR_DISK,
initial: defaultSettings,

View File

@@ -63,6 +63,7 @@ const forwarder = Object.freeze({
key: "forwardEmailForwarder",
target: "object",
format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<ForwardEmailSettings>(),
state: GENERATOR_DISK,
initial: defaultSettings,

View File

@@ -66,6 +66,7 @@ const forwarder = Object.freeze({
key: "simpleLoginForwarder",
target: "object",
format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<SimpleLoginSettings>(),
state: GENERATOR_DISK,
initial: defaultSettings,

View File

@@ -1,7 +1,7 @@
// FIXME: remove ts-strict-ignore once `FakeAccountService` implements ts strict support
// @ts-strict-ignore
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
import { BehaviorSubject, firstValueFrom, map, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction";
import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log";
import { StateConstraints } from "@bitwarden/common/tools/types";
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
@@ -164,11 +165,13 @@ const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId;
const accounts = {
[SomeUser]: {
id: SomeUser,
name: "some user",
email: "some.user@example.com",
emailVerified: true,
},
[AnotherUser]: {
id: AnotherUser,
name: "some other user",
email: "some.other.user@example.com",
emailVerified: true,
@@ -187,16 +190,26 @@ const i18nService = mock<I18nService>();
const apiService = mock<ApiService>();
const encryptor = mock<UserEncryptor>();
const encryptorProvider = mock<LegacyEncryptorProvider>();
const encryptorProvider = mock<LegacyEncryptorProvider>({
userEncryptor$(_, dependencies) {
return dependencies.singleUserId$.pipe(map((userId) => ({ userId, encryptor })));
},
});
const account$ = new BehaviorSubject(accounts[SomeUser]);
const providers = {
encryptor: encryptorProvider,
state: stateProvider,
log: disabledSemanticLoggerProvider,
};
describe("CredentialGeneratorService", () => {
beforeEach(async () => {
await accountService.switchAccount(SomeUser);
policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable());
i18nService.t.mockImplementation((key) => key);
i18nService.t.mockImplementation((key: string) => key);
apiService.fetch.mockImplementation(() => Promise.resolve(mock<Response>()));
const encryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor });
encryptorProvider.userEncryptor$.mockReturnValue(encryptor$);
jest.clearAllMocks();
});
@@ -205,18 +218,16 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const on$ = new Subject<GenerateRequest>();
let complete = false;
// confirm no emission during subscription
generator.generate$(SomeConfiguration, { on$ }).subscribe({
generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({
complete: () => {
complete = true;
},
@@ -232,15 +243,15 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const on$ = new BehaviorSubject<GenerateRequest>({ source: "some source" });
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ }));
const generated = new ObservableTracker(
generator.generate$(SomeConfiguration, { on$, account$ }),
);
const result = await generated.expectEmission();
@@ -252,49 +263,21 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const on$ = new BehaviorSubject({ website: "some website" });
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ }));
const generated = new ObservableTracker(
generator.generate$(SomeConfiguration, { on$, account$ }),
);
const result = await generated.expectEmission();
expect(result.website).toEqual("some website");
});
it("uses the active user's settings", async () => {
const someSettings = { foo: "some value" };
const anotherSettings = { foo: "another value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const on$ = new BehaviorSubject({});
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ }));
await accountService.switchAccount(AnotherUser);
on$.next({});
await generated.pauseUntilReceived(2);
generated.unsubscribe();
expect(generated.emissions).toEqual([
new GeneratedCredential("some value", SomeAlgorithm, SomeTime),
new GeneratedCredential("another value", SomeAlgorithm, SomeTime),
]);
});
// FIXME: test these when the fake state provider can create the required emissions
it.todo("errors when the settings error");
it.todo("completes when the settings complete");
@@ -304,17 +287,15 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable();
const on$ = new Subject<GenerateRequest>();
const generated = new ObservableTracker(
generator.generate$(SomeConfiguration, { on$, userId$ }),
generator.generate$(SomeConfiguration, { on$, account$ }),
);
on$.next({});
@@ -327,23 +308,21 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const on$ = new Subject<GenerateRequest>();
const userId$ = new BehaviorSubject(SomeUser);
const account$ = new BehaviorSubject(accounts[SomeUser]);
let error = null;
generator.generate$(SomeConfiguration, { on$, userId$ }).subscribe({
generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
userId$.error({ some: "error" });
account$.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
@@ -353,23 +332,21 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const on$ = new Subject<GenerateRequest>();
const userId$ = new BehaviorSubject(SomeUser);
const account$ = new BehaviorSubject(accounts[SomeUser]);
let completed = false;
generator.generate$(SomeConfiguration, { on$, userId$ }).subscribe({
generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({
complete: () => {
completed = true;
},
});
userId$.complete();
account$.complete();
await awaitAsync();
expect(completed).toBeTruthy();
@@ -380,19 +357,17 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const on$ = new Subject<GenerateRequest>();
const results: any[] = [];
// confirm no emission during subscription
const sub = generator
.generate$(SomeConfiguration, { on$ })
.generate$(SomeConfiguration, { on$, account$ })
.subscribe((result) => results.push(result));
await awaitAsync();
expect(results.length).toEqual(0);
@@ -422,18 +397,16 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const on$ = new Subject<GenerateRequest>();
let error: any = null;
// confirm no emission during subscription
generator.generate$(SomeConfiguration, { on$ }).subscribe({
generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({
error: (e: unknown) => {
error = e;
},
@@ -452,12 +425,10 @@ describe("CredentialGeneratorService", () => {
it("outputs password generation metadata", () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = generator.algorithms("password");
@@ -473,12 +444,10 @@ describe("CredentialGeneratorService", () => {
it("outputs username generation metadata", () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = generator.algorithms("username");
@@ -493,12 +462,10 @@ describe("CredentialGeneratorService", () => {
it("outputs email generation metadata", () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = generator.algorithms("email");
@@ -514,12 +481,10 @@ describe("CredentialGeneratorService", () => {
it("combines metadata across categories", () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = generator.algorithms(["username", "email"]);
@@ -539,15 +504,13 @@ describe("CredentialGeneratorService", () => {
it("returns password metadata", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = await firstValueFrom(generator.algorithms$("password"));
const result = await firstValueFrom(generator.algorithms$("password", { account$ }));
expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy();
@@ -556,15 +519,13 @@ describe("CredentialGeneratorService", () => {
it("returns username metadata", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = await firstValueFrom(generator.algorithms$("username"));
const result = await firstValueFrom(generator.algorithms$("username", { account$ }));
expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
});
@@ -572,15 +533,13 @@ describe("CredentialGeneratorService", () => {
it("returns email metadata", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = await firstValueFrom(generator.algorithms$("email"));
const result = await firstValueFrom(generator.algorithms$("email", { account$ }));
expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
@@ -589,15 +548,15 @@ describe("CredentialGeneratorService", () => {
it("returns username and email metadata", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = await firstValueFrom(generator.algorithms$(["username", "email"]));
const result = await firstValueFrom(
generator.algorithms$(["username", "email"], { account$ }),
);
expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
@@ -611,15 +570,13 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValue(policy$);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = await firstValueFrom(generator.algorithms$(["password"]));
const result = await firstValueFrom(generator.algorithms$(["password"], { account$ }));
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
@@ -627,26 +584,20 @@ describe("CredentialGeneratorService", () => {
});
it("follows changes to the active user", async () => {
// 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);
await accountService.switchAccount(SomeUser);
const account$ = new BehaviorSubject(accounts[SomeUser]);
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const results: any = [];
const sub = generator.algorithms$("password").subscribe((r) => results.push(r));
const sub = generator.algorithms$("password", { account$ }).subscribe((r) => results.push(r));
await accountService.switchAccount(AnotherUser);
account$.next(accounts[AnotherUser]);
await awaitAsync();
sub.unsubscribe();
@@ -673,16 +624,14 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable();
const result = await firstValueFrom(generator.algorithms$("password", { userId$ }));
const result = await firstValueFrom(generator.algorithms$("password", { account$ }));
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser);
expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy();
@@ -694,19 +643,17 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
const results: any = [];
const sub = generator.algorithms$("password", { userId$ }).subscribe((r) => results.push(r));
const sub = generator.algorithms$("password", { account$ }).subscribe((r) => results.push(r));
userId.next(AnotherUser);
account.next(accounts[AnotherUser]);
await awaitAsync();
sub.unsubscribe();
@@ -724,23 +671,21 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
let error = null;
generator.algorithms$("password", { userId$ }).subscribe({
generator.algorithms$("password", { account$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
userId.error({ some: "error" });
account.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
@@ -750,23 +695,21 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
let completed = false;
generator.algorithms$("password", { userId$ }).subscribe({
generator.algorithms$("password", { account$ }).subscribe({
complete: () => {
completed = true;
},
});
userId.complete();
account.complete();
await awaitAsync();
expect(completed).toBeTruthy();
@@ -776,26 +719,24 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
let count = 0;
const sub = generator.algorithms$("password", { userId$ }).subscribe({
const sub = generator.algorithms$("password", { account$ }).subscribe({
next: () => {
count++;
},
});
await awaitAsync();
userId.next(SomeUser);
account.next(accounts[SomeUser]);
await awaitAsync();
userId.next(SomeUser);
account.next(accounts[SomeUser]);
await awaitAsync();
sub.unsubscribe();
@@ -808,15 +749,13 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ }));
expect(result).toEqual(SomeConfiguration.settings.initial);
});
@@ -826,15 +765,13 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ }));
expect(result).toEqual(settings);
});
@@ -846,121 +783,54 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValue(policy$);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ }));
expect(result).toEqual({ foo: "adjusted(value)" });
});
it("follows changes to the active user", async () => {
// 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);
await accountService.switchAccount(SomeUser);
const someSettings = { foo: "value" };
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const results: any = [];
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
await accountService.switchAccount(AnotherUser);
await awaitAsync();
sub.unsubscribe();
const [someResult, anotherResult] = results;
expect(someResult).toEqual(someSettings);
expect(anotherResult).toEqual(anotherSettings);
});
it("reads an arbitrary user's settings", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable();
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ }));
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ }));
expect(result).toEqual(anotherSettings);
});
it("follows changes to the arbitrary user", async () => {
const someSettings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const results: any = [];
const sub = generator
.settings$(SomeConfiguration, { userId$ })
.subscribe((r) => results.push(r));
userId.next(AnotherUser);
await awaitAsync();
sub.unsubscribe();
const [someResult, anotherResult] = results;
expect(someResult).toEqual(someSettings);
expect(anotherResult).toEqual(anotherSettings);
});
it("errors when the arbitrary user's stream errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
let error = null;
generator.settings$(SomeConfiguration, { userId$ }).subscribe({
generator.settings$(SomeConfiguration, { account$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
userId.error({ some: "error" });
account.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
@@ -970,72 +840,37 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
let completed = false;
generator.settings$(SomeConfiguration, { userId$ }).subscribe({
generator.settings$(SomeConfiguration, { account$ }).subscribe({
complete: () => {
completed = true;
},
});
userId.complete();
account.complete();
await awaitAsync();
expect(completed).toBeTruthy();
});
it("ignores repeated arbitrary user emissions", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
let count = 0;
const sub = generator.settings$(SomeConfiguration, { userId$ }).subscribe({
next: () => {
count++;
},
});
await awaitAsync();
userId.next(SomeUser);
await awaitAsync();
userId.next(SomeUser);
await awaitAsync();
sub.unsubscribe();
expect(count).toEqual(1);
});
});
describe("settings", () => {
it("writes to the user's state", async () => {
const singleUserId$ = new BehaviorSubject(SomeUser).asObservable();
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
const subject = generator.settings(SomeConfiguration, { account$ });
subject.next({ foo: "next value" });
await awaitAsync();
@@ -1043,52 +878,22 @@ describe("CredentialGeneratorService", () => {
expect(result).toEqual({
foo: "next value",
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
});
});
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,
apiService,
i18nService,
encryptorProvider,
accountService,
);
let completed = false;
const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => {
completed = true;
return settings;
});
await awaitAsync();
expect(completed).toBeFalsy();
singleUserId.next(SomeUser);
const result = await promise;
expect(result.userId).toEqual(SomeUser);
});
});
describe("policy$", () => {
it("creates constraints without policy in effect when there is no policy", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { account$ }));
expect(result.constraints.policyInEffect).toBeFalsy();
});
@@ -1096,18 +901,16 @@ describe("CredentialGeneratorService", () => {
it("creates constraints with policy in effect when there is a policy", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$);
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { account$ }));
expect(result.constraints.policyInEffect).toBeTruthy();
});
@@ -1115,20 +918,18 @@ describe("CredentialGeneratorService", () => {
it("follows policy emissions", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
const somePolicySubject = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable());
const emissions: GeneratorConstraints<SomeSettings>[] = [];
const sub = generator
.policy$(SomeConfiguration, { userId$ })
.policy$(SomeConfiguration, { account$ })
.subscribe((policy) => emissions.push(policy));
// swap the active policy for an inactive policy
@@ -1144,25 +945,23 @@ describe("CredentialGeneratorService", () => {
it("follows user emissions", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
const anotherPolicy$ = new BehaviorSubject([]).asObservable();
policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$);
const emissions: GeneratorConstraints<SomeSettings>[] = [];
const sub = generator
.policy$(SomeConfiguration, { userId$ })
.policy$(SomeConfiguration, { account$ })
.subscribe((policy) => emissions.push(policy));
// swapping the user invokes the return for `anotherPolicy$`
userId.next(AnotherUser);
account.next(accounts[AnotherUser]);
await awaitAsync();
sub.unsubscribe();
const [someResult, anotherResult] = emissions;
@@ -1174,24 +973,22 @@ describe("CredentialGeneratorService", () => {
it("errors when the user errors", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
const expectedError = { some: "error" };
let actualError: any = null;
generator.policy$(SomeConfiguration, { userId$ }).subscribe({
generator.policy$(SomeConfiguration, { account$ }).subscribe({
error: (e: unknown) => {
actualError = e;
},
});
userId.error(expectedError);
account.error(expectedError);
await awaitAsync();
expect(actualError).toEqual(expectedError);
@@ -1200,23 +997,21 @@ describe("CredentialGeneratorService", () => {
it("completes when the user completes", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
providers,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const account = new BehaviorSubject(accounts[SomeUser]);
const account$ = account.asObservable();
let completed = false;
generator.policy$(SomeConfiguration, { userId$ }).subscribe({
generator.policy$(SomeConfiguration, { account$ }).subscribe({
complete: () => {
completed = true;
},
});
userId.complete();
account.complete();
await awaitAsync();
expect(completed).toBeTruthy();

View File

@@ -1,39 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
BehaviorSubject,
concatMap,
distinctUntilChanged,
endWith,
filter,
firstValueFrom,
ignoreElements,
map,
Observable,
ReplaySubject,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs";
import { concatMap, distinctUntilChanged, map, Observable, switchMap, takeUntil } from "rxjs";
import { Simplify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
import {
OnDependency,
SingleUserDependency,
UserDependency,
} from "@bitwarden/common/tools/dependencies";
import { BoundDependency, OnDependency } from "@bitwarden/common/tools/dependencies";
import { IntegrationMetadata } from "@bitwarden/common/tools/integration";
import { RestClient } from "@bitwarden/common/tools/integration/rpc";
import { anyComplete, withLatestReady } from "@bitwarden/common/tools/rx";
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { UserId } from "@bitwarden/common/types/guid";
import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
import { Randomizer } from "../abstractions";
import {
@@ -63,23 +43,17 @@ import { GeneratorConstraints } from "../types/generator-constraints";
import { PREFERENCES } from "./credential-preferences";
type Policy$Dependencies = UserDependency;
type Settings$Dependencies = Partial<UserDependency>;
type Generate$Dependencies = Simplify<OnDependency<GenerateRequest> & Partial<UserDependency>>;
type Algorithms$Dependencies = Partial<UserDependency>;
const OPTIONS_FRAME_SIZE = 512;
type Generate$Dependencies = Simplify<
OnDependency<GenerateRequest> & BoundDependency<"account", Account>
>;
export class CredentialGeneratorService {
constructor(
private readonly randomizer: Randomizer,
private readonly stateProvider: StateProvider,
private readonly policyService: PolicyService,
private readonly apiService: ApiService,
private readonly i18nService: I18nService,
private readonly encryptorProvider: LegacyEncryptorProvider,
private readonly accountService: AccountService,
private readonly providers: UserStateSubjectDependencyProvider,
) {}
private getDependencyProvider(): GeneratorDependencyProvider {
@@ -116,41 +90,34 @@ export class CredentialGeneratorService {
/** Emits metadata concerning the provided generation algorithms
* @param category the category or categories of interest
* @param dependences.userId$ when provided, the algorithms are filter to only
* those matching the provided user's policy. Otherwise, emits the algorithms
* available to the active user.
* @param dependences.account$ algorithms are filtered to only
* those matching the provided account's policy.
* @returns An observable that emits algorithm metadata.
*/
algorithms$(
category: CredentialCategory,
dependencies?: Algorithms$Dependencies,
dependencies: BoundDependency<"account", Account>,
): Observable<AlgorithmInfo[]>;
algorithms$(
category: CredentialCategory[],
dependencies?: Algorithms$Dependencies,
dependencies: BoundDependency<"account", Account>,
): Observable<AlgorithmInfo[]>;
algorithms$(
category: CredentialCategory | CredentialCategory[],
dependencies?: Algorithms$Dependencies,
dependencies: BoundDependency<"account", Account>,
) {
// any cast required here because TypeScript fails to bind `category`
// to the union-typed overload of `algorithms`.
const algorithms = this.algorithms(category as any);
// fall back to default bindings
const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$;
// monitor completion
const completion$ = userId$.pipe(ignoreElements(), endWith(true));
// apply policy
const algorithms$ = userId$.pipe(
const algorithms$ = dependencies.account$.pipe(
distinctUntilChanged(),
switchMap((userId) => {
// complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely
const policies$ = this.policyService.getAll$(PolicyType.PasswordGenerator, userId).pipe(
switchMap((account) => {
const policies$ = this.policyService.getAll$(PolicyType.PasswordGenerator, account.id).pipe(
map((p) => new Set(availableAlgorithms(p))),
takeUntil(completion$),
// complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely
takeUntil(anyComplete(dependencies.account$)),
);
return policies$;
}),
@@ -234,140 +201,89 @@ export class CredentialGeneratorService {
/** Get the settings for the provided configuration
* @param configuration determines which generator's settings are loaded
* @param dependencies.userId$ identifies the user to which the settings are bound.
* If this parameter is not provided, the observable follows the active user and
* may not complete.
* @param dependencies.account$ identifies the account to which the settings are bound.
* @returns an observable that emits settings
* @remarks the observable enforces policies on the settings
*/
settings$<Settings extends object, Policy>(
configuration: Configuration<Settings, Policy>,
dependencies?: Settings$Dependencies,
dependencies: BoundDependency<"account", Account>,
) {
const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$;
const constraints$ = this.policy$(configuration, { userId$ });
const constraints$ = this.policy$(configuration, dependencies);
const settings$ = userId$.pipe(
filter((userId) => !!userId),
distinctUntilChanged(),
switchMap((userId) => {
const singleUserId$ = new BehaviorSubject(userId);
const singleUserEncryptor$ = this.encryptorProvider.userEncryptor$(OPTIONS_FRAME_SIZE, {
singleUserId$,
});
const settings = new UserStateSubject(configuration.settings.account, this.providers, {
constraints$,
account$: dependencies.account$,
});
const state$ = new UserStateSubject(
configuration.settings.account,
(key) => this.stateProvider.getUser(userId, key),
{ constraints$, singleUserEncryptor$ },
);
return state$;
}),
const settings$ = settings.pipe(
map((settings) => settings ?? structuredClone(configuration.settings.initial)),
takeUntil(anyComplete(userId$)),
);
return settings$;
}
/** Get a subject bound to credential generator preferences.
* @param dependencies.singleUserId$ identifies the user to which the preferences are bound
* @returns a promise that resolves with the subject once `dependencies.singleUserId$`
* becomes available.
* @param dependencies.account$ identifies the account to which the preferences are bound
* @returns a subject bound to the user's preferences
* @remarks Preferences determine which algorithms are used when generating a
* credential from a credential category (e.g. `PassX` or `Username`). Preferences
* should not be used to hold navigation history. Use @bitwarden/generator-navigation
* instead.
*/
async preferences(
dependencies: SingleUserDependency,
): Promise<UserStateSubject<CredentialPreference>> {
const singleUserId$ = new ReplaySubject<UserId>(1);
dependencies.singleUserId$
.pipe(
filter((userId) => !!userId),
distinctUntilChanged(),
)
.subscribe(singleUserId$);
const singleUserEncryptor$ = this.encryptorProvider.userEncryptor$(OPTIONS_FRAME_SIZE, {
singleUserId$,
});
const userId = await firstValueFrom(singleUserId$);
preferences(
dependencies: BoundDependency<"account", Account>,
): UserStateSubject<CredentialPreference> {
// FIXME: enforce policy
const subject = new UserStateSubject(
PREFERENCES,
(key) => this.stateProvider.getUser(userId, key),
{ singleUserEncryptor$ },
);
const subject = new UserStateSubject(PREFERENCES, this.providers, dependencies);
return subject;
}
/** Get a subject bound to a specific user's settings
* @param configuration determines which generator's settings are loaded
* @param dependencies.singleUserId$ identifies the user to which the settings are bound
* @returns a promise that resolves with the subject once
* `dependencies.singleUserId$` becomes available.
* @param dependencies.account$ identifies the account to which the settings are bound
* @returns a subject bound to the requested user's generator settings
* @remarks the subject enforces policy for the settings
*/
async settings<Settings extends object, Policy>(
settings<Settings extends object, Policy>(
configuration: Readonly<Configuration<Settings, Policy>>,
dependencies: SingleUserDependency,
dependencies: BoundDependency<"account", Account>,
) {
const singleUserId$ = new ReplaySubject<UserId>(1);
dependencies.singleUserId$
.pipe(
filter((userId) => !!userId),
distinctUntilChanged(),
)
.subscribe(singleUserId$);
const singleUserEncryptor$ = this.encryptorProvider.userEncryptor$(OPTIONS_FRAME_SIZE, {
singleUserId$,
const constraints$ = this.policy$(configuration, dependencies);
const subject = new UserStateSubject(configuration.settings.account, this.providers, {
constraints$,
account$: dependencies.account$,
});
const userId = await firstValueFrom(singleUserId$);
const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ });
const subject = new UserStateSubject(
configuration.settings.account,
(key) => this.stateProvider.getUser(userId, key),
{ constraints$, singleUserEncryptor$ },
);
return subject;
}
/** Get the policy constraints for the provided configuration
* @param dependencies.userId$ determines which user's policy is loaded
* @returns an observable that emits the policy once `dependencies.userId$`
* @param dependencies.account$ determines which user's policy is loaded
* @returns an observable that emits the policy once `dependencies.account$`
* and the policy become available.
*/
policy$<Settings, Policy>(
configuration: Configuration<Settings, Policy>,
dependencies: Policy$Dependencies,
dependencies: BoundDependency<"account", Account>,
): Observable<GeneratorConstraints<Settings>> {
const email$ = dependencies.userId$.pipe(
distinctUntilChanged(),
withLatestFrom(this.accountService.accounts$),
filter((accounts) => !!accounts),
map(([userId, accounts]) => {
if (userId in accounts) {
return { userId, email: accounts[userId].email };
const constraints$ = dependencies.account$.pipe(
map((account) => {
if (account.emailVerified) {
return { userId: account.id, email: account.email };
}
return { userId, email: null };
return { userId: account.id, email: null };
}),
);
const constraints$ = email$.pipe(
switchMap(({ userId, email }) => {
// complete policy emissions otherwise `switchMap` holds `policies$` open indefinitely
const policies$ = this.policyService
.getAll$(configuration.policy.type, userId)
.pipe(
mapPolicyToConstraints(configuration.policy, email),
takeUntil(anyComplete(email$)),
takeUntil(anyComplete(dependencies.account$)),
);
return policies$;
}),