mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-7289] implement generator libraries (#9549)
This is a copy of the files. The source in `@bitwarden/common` will be deleted once all of the applications have been ported to the library.
This commit is contained in:
@@ -8,6 +8,6 @@ module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "../../../shared/test.environment.ts",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/../../../",
|
||||
prefix: "<rootDir>/../../",
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { GeneratorCategory, GeneratedCredential } from "./";
|
||||
|
||||
describe("GeneratedCredential", () => {
|
||||
describe("constructor", () => {
|
||||
it("assigns credential", () => {
|
||||
const result = new GeneratedCredential("example", "passphrase", new Date(100));
|
||||
|
||||
expect(result.credential).toEqual("example");
|
||||
});
|
||||
|
||||
it("assigns category", () => {
|
||||
const result = new GeneratedCredential("example", "passphrase", new Date(100));
|
||||
|
||||
expect(result.category).toEqual("passphrase");
|
||||
});
|
||||
|
||||
it("passes through date parameters", () => {
|
||||
const result = new GeneratedCredential("example", "password", new Date(100));
|
||||
|
||||
expect(result.generationDate).toEqual(new Date(100));
|
||||
});
|
||||
|
||||
it("converts numeric dates to Dates", () => {
|
||||
const result = new GeneratedCredential("example", "password", 100);
|
||||
|
||||
expect(result.generationDate).toEqual(new Date(100));
|
||||
});
|
||||
});
|
||||
|
||||
it("toJSON converts from a credential into a JSON object", () => {
|
||||
const credential = new GeneratedCredential("example", "password", new Date(100));
|
||||
|
||||
const result = credential.toJSON();
|
||||
|
||||
expect(result).toEqual({
|
||||
credential: "example",
|
||||
category: "password" as GeneratorCategory,
|
||||
generationDate: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("fromJSON converts Json objects into credentials", () => {
|
||||
const jsonValue = {
|
||||
credential: "example",
|
||||
category: "password" as GeneratorCategory,
|
||||
generationDate: 100,
|
||||
};
|
||||
|
||||
const result = GeneratedCredential.fromJSON(jsonValue);
|
||||
|
||||
expect(result).toBeInstanceOf(GeneratedCredential);
|
||||
expect(result).toEqual({
|
||||
credential: "example",
|
||||
category: "password",
|
||||
generationDate: new Date(100),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { GeneratorCategory } from "./options";
|
||||
|
||||
/** A credential generation result */
|
||||
export class GeneratedCredential {
|
||||
/**
|
||||
* Instantiates a generated credential
|
||||
* @param credential The value of the generated credential (e.g. a password)
|
||||
* @param category The kind of credential
|
||||
* @param generationDate The date that the credential was generated.
|
||||
* Numeric values should are interpreted using {@link Date.valueOf}
|
||||
* semantics.
|
||||
*/
|
||||
constructor(
|
||||
readonly credential: string,
|
||||
readonly category: GeneratorCategory,
|
||||
generationDate: Date | number,
|
||||
) {
|
||||
if (typeof generationDate === "number") {
|
||||
this.generationDate = new Date(generationDate);
|
||||
} else {
|
||||
this.generationDate = generationDate;
|
||||
}
|
||||
}
|
||||
|
||||
/** The date that the credential was generated */
|
||||
generationDate: Date;
|
||||
|
||||
/** Constructs a credential from its `toJSON` representation */
|
||||
static fromJSON(jsonValue: Jsonify<GeneratedCredential>) {
|
||||
return new GeneratedCredential(
|
||||
jsonValue.credential,
|
||||
jsonValue.category,
|
||||
jsonValue.generationDate,
|
||||
);
|
||||
}
|
||||
|
||||
/** Serializes a credential to a JSON-compatible object */
|
||||
toJSON() {
|
||||
return {
|
||||
credential: this.credential,
|
||||
category: this.category,
|
||||
generationDate: this.generationDate.valueOf(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class GeneratedPasswordHistory {
|
||||
password: string;
|
||||
date: number;
|
||||
|
||||
constructor(password: string, date: number) {
|
||||
this.password = password;
|
||||
this.date = date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { GeneratedCredential } from "./generated-credential";
|
||||
import { GeneratorCategory } from "./options";
|
||||
|
||||
/** Tracks the history of password generations.
|
||||
* Each user gets their own store.
|
||||
*/
|
||||
export abstract class GeneratorHistoryService {
|
||||
/** Tracks a new credential. When an item with the same `credential` value
|
||||
* is found, this method does nothing. When the total number of items exceeds
|
||||
* {@link HistoryServiceOptions.maxTotal}, then the oldest items exceeding the total
|
||||
* are deleted.
|
||||
* @param userId identifies the user storing the credential.
|
||||
* @param credential stored by the history service.
|
||||
* @param date when the credential was generated. If this is omitted, then the generator
|
||||
* uses the date the credential was added to the store instead.
|
||||
* @returns a promise that completes with the added credential. If the credential
|
||||
* wasn't added, then the promise completes with `null`.
|
||||
* @remarks this service is not suitable for use with vault items/ciphers. It models only
|
||||
* a history of an individually generated credential, while a vault item's history
|
||||
* may contain several credentials that are better modelled as atomic versions of the
|
||||
* vault item itself.
|
||||
*/
|
||||
track: (
|
||||
userId: UserId,
|
||||
credential: string,
|
||||
category: GeneratorCategory,
|
||||
date?: Date,
|
||||
) => Promise<GeneratedCredential | null>;
|
||||
|
||||
/** Removes a matching credential from the history service.
|
||||
* @param userId identifies the user taking the credential.
|
||||
* @param credential to match in the history service.
|
||||
* @returns A promise that completes with the credential read. If the credential wasn't found,
|
||||
* the promise completes with null.
|
||||
* @remarks this can be used to extract an entry when a credential is stored in the vault.
|
||||
*/
|
||||
take: (userId: UserId, credential: string) => Promise<GeneratedCredential | null>;
|
||||
|
||||
/** Deletes a user's credential history.
|
||||
* @param userId identifies the user taking the credential.
|
||||
* @returns A promise that completes when the history is cleared.
|
||||
*/
|
||||
clear: (userId: UserId) => Promise<GeneratedCredential[]>;
|
||||
|
||||
/** Lists all credentials for a user.
|
||||
* @param userId identifies the user listing the credential.
|
||||
* @remarks This field is eventually consistent with `track` and `take` operations.
|
||||
* It is not guaranteed to immediately reflect those changes.
|
||||
*/
|
||||
credentials$: (userId: UserId) => Observable<GeneratedCredential[]>;
|
||||
}
|
||||
5
libs/tools/generator/extensions/src/history/index.ts
Normal file
5
libs/tools/generator/extensions/src/history/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { GeneratedCredential } from "./generated-credential";
|
||||
export { GeneratedPasswordHistory } from "./generated-password-history";
|
||||
export { GeneratorHistoryService } from "./generator-history.abstraction";
|
||||
export { LocalGeneratorHistoryService } from "./local-generator-history.service";
|
||||
export { GeneratorCategory } from "./options";
|
||||
@@ -0,0 +1,65 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { GeneratedCredential } from "./generated-credential";
|
||||
import { GeneratedPasswordHistory } from "./generated-password-history";
|
||||
import { GENERATOR_HISTORY_BUFFER } from "./key-definitions";
|
||||
import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor";
|
||||
|
||||
describe("Key definitions", () => {
|
||||
describe("GENERATOR_HISTORY_BUFFER", () => {
|
||||
describe("options.deserializer", () => {
|
||||
it("should deserialize generated password history", () => {
|
||||
const value: any = [{ password: "foo", date: 1 }];
|
||||
|
||||
const [result] = GENERATOR_HISTORY_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toEqual(value[0]);
|
||||
expect(result).toBeInstanceOf(GeneratedPasswordHistory);
|
||||
});
|
||||
|
||||
it.each([[undefined], [null]])("should ignore nullish (= %p) history", (value: any) => {
|
||||
const result = GENERATOR_HISTORY_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("should map generated password history to generated credentials", async () => {
|
||||
const value: any = [new GeneratedPasswordHistory("foo", 1)];
|
||||
const decryptor = mock<LegacyPasswordHistoryDecryptor>({
|
||||
decrypt(value) {
|
||||
return Promise.resolve(value);
|
||||
},
|
||||
});
|
||||
|
||||
const [result] = await GENERATOR_HISTORY_BUFFER.map(value, decryptor);
|
||||
|
||||
expect(result).toEqual({
|
||||
credential: "foo",
|
||||
category: "password",
|
||||
generationDate: new Date(1),
|
||||
});
|
||||
expect(result).toBeInstanceOf(GeneratedCredential);
|
||||
});
|
||||
|
||||
describe("isValid", () => {
|
||||
it("should accept histories with at least one entry", async () => {
|
||||
const value: any = [new GeneratedPasswordHistory("foo", 1)];
|
||||
const decryptor = {} as any;
|
||||
|
||||
const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor);
|
||||
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it("should reject histories with no entries", async () => {
|
||||
const value: any = [];
|
||||
const decryptor = {} as any;
|
||||
|
||||
const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor);
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
|
||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||
import { SecretClassifier } from "@bitwarden/common/tools/state/secret-classifier";
|
||||
import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition";
|
||||
|
||||
import { GeneratedCredential } from "./generated-credential";
|
||||
import { GeneratedPasswordHistory } from "./generated-password-history";
|
||||
import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor";
|
||||
|
||||
/** encrypted password generation history */
|
||||
export const GENERATOR_HISTORY = SecretKeyDefinition.array(
|
||||
GENERATOR_DISK,
|
||||
"localGeneratorHistory",
|
||||
SecretClassifier.allSecret<GeneratedCredential>(),
|
||||
{
|
||||
deserializer: GeneratedCredential.fromJSON,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/** encrypted password generation history subject to migration */
|
||||
export const GENERATOR_HISTORY_BUFFER = new BufferedKeyDefinition<
|
||||
GeneratedPasswordHistory[],
|
||||
GeneratedCredential[],
|
||||
LegacyPasswordHistoryDecryptor
|
||||
>(GENERATOR_DISK, "localGeneratorHistoryBuffer", {
|
||||
deserializer(history) {
|
||||
const items = history as Jsonify<GeneratedPasswordHistory>[];
|
||||
return items?.map((h) => new GeneratedPasswordHistory(h.password, h.date));
|
||||
},
|
||||
async isValid(history) {
|
||||
return history.length ? true : false;
|
||||
},
|
||||
async map(history, decryptor) {
|
||||
const credentials = await decryptor.decrypt(history);
|
||||
const mapped = credentials.map((c) => new GeneratedCredential(c.password, "password", c.date));
|
||||
return mapped;
|
||||
},
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { GeneratedPasswordHistory } from "./generated-password-history";
|
||||
|
||||
/** Strategy that decrypts a password history */
|
||||
export class LegacyPasswordHistoryDecryptor {
|
||||
constructor(
|
||||
private userId: UserId,
|
||||
private cryptoService: CryptoService,
|
||||
private encryptService: EncryptService,
|
||||
) {}
|
||||
|
||||
/** Decrypts a password history. */
|
||||
async decrypt(history: GeneratedPasswordHistory[]): Promise<GeneratedPasswordHistory[]> {
|
||||
const key = await this.cryptoService.getUserKey(this.userId);
|
||||
|
||||
const promises = (history ?? []).map(async (item) => {
|
||||
const encrypted = new EncString(item.password);
|
||||
const decrypted = await this.encryptService.decryptToUtf8(encrypted, key);
|
||||
return new GeneratedPasswordHistory(decrypted, item.date);
|
||||
});
|
||||
|
||||
const decrypted = await Promise.all(promises);
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../../common/spec";
|
||||
|
||||
import { LocalGeneratorHistoryService } from "./local-generator-history.service";
|
||||
|
||||
const SomeUser = "SomeUser" as UserId;
|
||||
const AnotherUser = "AnotherUser" as UserId;
|
||||
|
||||
describe("LocalGeneratorHistoryService", () => {
|
||||
const encryptService = mock<EncryptService>();
|
||||
const keyService = mock<CryptoService>();
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
|
||||
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString));
|
||||
keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey));
|
||||
keyService.getInMemoryUserKeyFor$.mockImplementation(() => of(true as unknown as UserKey));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("credential$", () => {
|
||||
it("returns an empty list when no credentials are stored", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
|
||||
const result = await firstValueFrom(history.credentials$(SomeUser));
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("track", () => {
|
||||
it("stores a password", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
|
||||
await history.track(SomeUser, "example", "password");
|
||||
await awaitAsync();
|
||||
const [result] = await firstValueFrom(history.credentials$(SomeUser));
|
||||
|
||||
expect(result).toMatchObject({ credential: "example", category: "password" });
|
||||
});
|
||||
|
||||
it("stores a passphrase", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
|
||||
await history.track(SomeUser, "example", "passphrase");
|
||||
await awaitAsync();
|
||||
const [result] = await firstValueFrom(history.credentials$(SomeUser));
|
||||
|
||||
expect(result).toMatchObject({ credential: "example", category: "passphrase" });
|
||||
});
|
||||
|
||||
it("stores a specific date when one is provided", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
|
||||
await history.track(SomeUser, "example", "password", new Date(100));
|
||||
await awaitAsync();
|
||||
const [result] = await firstValueFrom(history.credentials$(SomeUser));
|
||||
|
||||
expect(result).toEqual({
|
||||
credential: "example",
|
||||
category: "password",
|
||||
generationDate: new Date(100),
|
||||
});
|
||||
});
|
||||
|
||||
it("skips storing a credential when it's already stored (ignores category)", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
|
||||
await history.track(SomeUser, "example", "password");
|
||||
await history.track(SomeUser, "example", "password");
|
||||
await history.track(SomeUser, "example", "passphrase");
|
||||
await awaitAsync();
|
||||
const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser));
|
||||
|
||||
expect(firstResult).toMatchObject({ credential: "example", category: "password" });
|
||||
expect(secondResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores multiple credentials when the credential value is different", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
|
||||
await history.track(SomeUser, "secondResult", "password");
|
||||
await history.track(SomeUser, "firstResult", "password");
|
||||
await awaitAsync();
|
||||
const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser));
|
||||
|
||||
expect(firstResult).toMatchObject({ credential: "firstResult", category: "password" });
|
||||
expect(secondResult).toMatchObject({ credential: "secondResult", category: "password" });
|
||||
});
|
||||
|
||||
it("removes history items exceeding maxTotal configuration", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, {
|
||||
maxTotal: 1,
|
||||
});
|
||||
|
||||
await history.track(SomeUser, "removed result", "password");
|
||||
await history.track(SomeUser, "example", "password");
|
||||
await awaitAsync();
|
||||
const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser));
|
||||
|
||||
expect(firstResult).toMatchObject({ credential: "example", category: "password" });
|
||||
expect(secondResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores history items in per-user collections", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, {
|
||||
maxTotal: 1,
|
||||
});
|
||||
|
||||
await history.track(SomeUser, "some user example", "password");
|
||||
await history.track(AnotherUser, "another user example", "password");
|
||||
await awaitAsync();
|
||||
const [someFirstResult, someSecondResult] = await firstValueFrom(
|
||||
history.credentials$(SomeUser),
|
||||
);
|
||||
const [anotherFirstResult, anotherSecondResult] = await firstValueFrom(
|
||||
history.credentials$(AnotherUser),
|
||||
);
|
||||
|
||||
expect(someFirstResult).toMatchObject({
|
||||
credential: "some user example",
|
||||
category: "password",
|
||||
});
|
||||
expect(someSecondResult).toBeUndefined();
|
||||
expect(anotherFirstResult).toMatchObject({
|
||||
credential: "another user example",
|
||||
category: "password",
|
||||
});
|
||||
expect(anotherSecondResult).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("take", () => {
|
||||
it("returns null when there are no credentials stored", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
|
||||
const result = await history.take(SomeUser, "example");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the credential wasn't found", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
await history.track(SomeUser, "example", "password");
|
||||
|
||||
const result = await history.take(SomeUser, "not found");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a matching credential", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
await history.track(SomeUser, "example", "password");
|
||||
|
||||
const result = await history.take(SomeUser, "example");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
credential: "example",
|
||||
category: "password",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a matching credential", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
|
||||
await history.track(SomeUser, "example", "password");
|
||||
|
||||
await history.take(SomeUser, "example");
|
||||
await awaitAsync();
|
||||
const results = await firstValueFrom(history.credentials$(SomeUser));
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
|
||||
import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer";
|
||||
import { SecretState } from "@bitwarden/common/tools/state/secret-state";
|
||||
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { GeneratedCredential } from "./generated-credential";
|
||||
import { GeneratorHistoryService } from "./generator-history.abstraction";
|
||||
import { GENERATOR_HISTORY, GENERATOR_HISTORY_BUFFER } from "./key-definitions";
|
||||
import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor";
|
||||
import { GeneratorCategory, HistoryServiceOptions } from "./options";
|
||||
|
||||
const OPTIONS_FRAME_SIZE = 2048;
|
||||
|
||||
/** Tracks the history of password generations local to a device.
|
||||
* {@link GeneratorHistoryService}
|
||||
*/
|
||||
export class LocalGeneratorHistoryService extends GeneratorHistoryService {
|
||||
constructor(
|
||||
private readonly encryptService: EncryptService,
|
||||
private readonly keyService: CryptoService,
|
||||
private readonly stateProvider: StateProvider,
|
||||
private readonly options: HistoryServiceOptions = { maxTotal: 100 },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private _credentialStates = new Map<UserId, SingleUserState<GeneratedCredential[]>>();
|
||||
|
||||
/** {@link GeneratorHistoryService.track} */
|
||||
track = async (userId: UserId, credential: string, category: GeneratorCategory, date?: Date) => {
|
||||
const state = this.getCredentialState(userId);
|
||||
let result: GeneratedCredential = null;
|
||||
|
||||
await state.update(
|
||||
(credentials) => {
|
||||
credentials = credentials ?? [];
|
||||
|
||||
// add the result
|
||||
result = new GeneratedCredential(credential, category, date ?? Date.now());
|
||||
credentials.unshift(result);
|
||||
|
||||
// trim history
|
||||
const removeAt = Math.max(0, this.options.maxTotal);
|
||||
credentials.splice(removeAt, Infinity);
|
||||
|
||||
return credentials;
|
||||
},
|
||||
{
|
||||
shouldUpdate: (credentials) =>
|
||||
!(credentials?.some((f) => f.credential === credential) ?? false),
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/** {@link GeneratorHistoryService.take} */
|
||||
take = async (userId: UserId, credential: string) => {
|
||||
const state = this.getCredentialState(userId);
|
||||
let credentialIndex: number;
|
||||
let result: GeneratedCredential = null;
|
||||
|
||||
await state.update(
|
||||
(credentials) => {
|
||||
credentials = credentials ?? [];
|
||||
|
||||
[result] = credentials.splice(credentialIndex, 1);
|
||||
return credentials;
|
||||
},
|
||||
{
|
||||
shouldUpdate: (credentials) => {
|
||||
credentialIndex = credentials?.findIndex((f) => f.credential === credential) ?? -1;
|
||||
return credentialIndex >= 0;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/** {@link GeneratorHistoryService.take} */
|
||||
clear = async (userId: UserId) => {
|
||||
const state = this.getCredentialState(userId);
|
||||
const result = (await state.update(() => null)) ?? [];
|
||||
return result;
|
||||
};
|
||||
|
||||
/** {@link GeneratorHistoryService.credentials$} */
|
||||
credentials$ = (userId: UserId) => {
|
||||
return this.getCredentialState(userId).state$.pipe(map((credentials) => credentials ?? []));
|
||||
};
|
||||
|
||||
private getCredentialState(userId: UserId) {
|
||||
let state = this._credentialStates.get(userId);
|
||||
|
||||
if (!state) {
|
||||
state = this.createSecretState(userId);
|
||||
this._credentialStates.set(userId, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private createSecretState(userId: UserId): SingleUserState<GeneratedCredential[]> {
|
||||
// construct the encryptor
|
||||
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
|
||||
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
|
||||
|
||||
// construct the durable state
|
||||
const state = SecretState.from<
|
||||
GeneratedCredential[],
|
||||
number,
|
||||
GeneratedCredential,
|
||||
Record<keyof GeneratedCredential, never>,
|
||||
GeneratedCredential
|
||||
>(userId, GENERATOR_HISTORY, this.stateProvider, encryptor);
|
||||
|
||||
// decryptor is just an algorithm, but it can't run until the key is available;
|
||||
// providing it via an observable makes running it early impossible
|
||||
const decryptor = new LegacyPasswordHistoryDecryptor(
|
||||
userId,
|
||||
this.keyService,
|
||||
this.encryptService,
|
||||
);
|
||||
const decryptor$ = this.keyService
|
||||
.getInMemoryUserKeyFor$(userId)
|
||||
.pipe(map((key) => key && decryptor));
|
||||
|
||||
// move data from the old password history once decryptor is available
|
||||
const buffer = new BufferedState(
|
||||
this.stateProvider,
|
||||
GENERATOR_HISTORY_BUFFER,
|
||||
state,
|
||||
decryptor$,
|
||||
);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
10
libs/tools/generator/extensions/src/history/options.ts
Normal file
10
libs/tools/generator/extensions/src/history/options.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Kinds of credentials that can be stored by the history service */
|
||||
export type GeneratorCategory = "password" | "passphrase";
|
||||
|
||||
/** Configuration options for the history service */
|
||||
export type HistoryServiceOptions = {
|
||||
/** Total number of records retained across all types.
|
||||
* @remarks Setting this to 0 or less disables history completely.
|
||||
* */
|
||||
maxTotal: number;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * as history from "./history";
|
||||
export * as legacyPassword from "./legacy-password";
|
||||
export * as legacyUsername from "./legacy-username";
|
||||
export * as navigation from "./navigation";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { engine, services, strategies } from "@bitwarden/generator-core";
|
||||
|
||||
import { LocalGeneratorHistoryService } from "../history";
|
||||
import { DefaultGeneratorNavigationService } from "../navigation";
|
||||
|
||||
import { LegacyPasswordGenerationService } from "./legacy-password-generation.service";
|
||||
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
|
||||
|
||||
const PassphraseGeneratorStrategy = strategies.PassphraseGeneratorStrategy;
|
||||
const PasswordGeneratorStrategy = strategies.PasswordGeneratorStrategy;
|
||||
const CryptoServiceRandomizer = engine.CryptoServiceRandomizer;
|
||||
const DefaultGeneratorService = services.DefaultGeneratorService;
|
||||
|
||||
export function legacyPasswordGenerationServiceFactory(
|
||||
encryptService: EncryptService,
|
||||
cryptoService: CryptoService,
|
||||
policyService: PolicyService,
|
||||
accountService: AccountService,
|
||||
stateProvider: StateProvider,
|
||||
): PasswordGenerationServiceAbstraction {
|
||||
const randomizer = new CryptoServiceRandomizer(cryptoService);
|
||||
|
||||
const passwords = new DefaultGeneratorService(
|
||||
new PasswordGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const passphrases = new DefaultGeneratorService(
|
||||
new PassphraseGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService);
|
||||
|
||||
const history = new LocalGeneratorHistoryService(encryptService, cryptoService, stateProvider);
|
||||
|
||||
return new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
passwords,
|
||||
passphrases,
|
||||
history,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./password-generation.service.abstraction";
|
||||
export * from "./factory";
|
||||
export * from "./password-generator-options";
|
||||
@@ -0,0 +1,562 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
GeneratorService,
|
||||
DefaultPassphraseGenerationOptions,
|
||||
DefaultPasswordGenerationOptions,
|
||||
DisabledPassphraseGeneratorPolicy,
|
||||
DisabledPasswordGeneratorPolicy,
|
||||
PassphraseGenerationOptions,
|
||||
PassphraseGeneratorPolicy,
|
||||
PasswordGenerationOptions,
|
||||
PasswordGeneratorPolicy,
|
||||
policies,
|
||||
} from "@bitwarden/generator-core";
|
||||
|
||||
import { mockAccountServiceWith } from "../../../../../common/spec";
|
||||
import { GeneratedCredential, GeneratorHistoryService, GeneratedPasswordHistory } from "../history";
|
||||
import {
|
||||
GeneratorNavigationService,
|
||||
DefaultGeneratorNavigation,
|
||||
GeneratorNavigation,
|
||||
GeneratorNavigationEvaluator,
|
||||
GeneratorNavigationPolicy,
|
||||
} from "../navigation";
|
||||
|
||||
import { LegacyPasswordGenerationService } from "./legacy-password-generation.service";
|
||||
import { PasswordGeneratorOptions } from "./password-generator-options";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const PassphraseGeneratorOptionsEvaluator = policies.PassphraseGeneratorOptionsEvaluator;
|
||||
const PasswordGeneratorOptionsEvaluator = policies.PasswordGeneratorOptionsEvaluator;
|
||||
|
||||
function createPassphraseGenerator(
|
||||
options: PassphraseGenerationOptions = {},
|
||||
policy: PassphraseGeneratorPolicy = DisabledPassphraseGeneratorPolicy,
|
||||
) {
|
||||
let savedOptions = options;
|
||||
const generator = mock<GeneratorService<PassphraseGenerationOptions, PassphraseGeneratorPolicy>>({
|
||||
evaluator$(id: UserId) {
|
||||
const evaluator = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
return of(evaluator);
|
||||
},
|
||||
options$(id: UserId) {
|
||||
return of(savedOptions);
|
||||
},
|
||||
defaults$(id: UserId) {
|
||||
return of(DefaultPassphraseGenerationOptions);
|
||||
},
|
||||
saveOptions(userId, options) {
|
||||
savedOptions = options;
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
return generator;
|
||||
}
|
||||
|
||||
function createPasswordGenerator(
|
||||
options: PasswordGenerationOptions = {},
|
||||
policy: PasswordGeneratorPolicy = DisabledPasswordGeneratorPolicy,
|
||||
) {
|
||||
let savedOptions = options;
|
||||
const generator = mock<GeneratorService<PasswordGenerationOptions, PasswordGeneratorPolicy>>({
|
||||
evaluator$(id: UserId) {
|
||||
const evaluator = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
return of(evaluator);
|
||||
},
|
||||
options$(id: UserId) {
|
||||
return of(savedOptions);
|
||||
},
|
||||
defaults$(id: UserId) {
|
||||
return of(DefaultPasswordGenerationOptions);
|
||||
},
|
||||
saveOptions(userId, options) {
|
||||
savedOptions = options;
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
return generator;
|
||||
}
|
||||
|
||||
function createNavigationGenerator(
|
||||
options: GeneratorNavigation = {},
|
||||
policy: GeneratorNavigationPolicy = {},
|
||||
) {
|
||||
let savedOptions = options;
|
||||
const generator = mock<GeneratorNavigationService>({
|
||||
evaluator$(id: UserId) {
|
||||
const evaluator = new GeneratorNavigationEvaluator(policy);
|
||||
return of(evaluator);
|
||||
},
|
||||
options$(id: UserId) {
|
||||
return of(savedOptions);
|
||||
},
|
||||
defaults$(id: UserId) {
|
||||
return of(DefaultGeneratorNavigation);
|
||||
},
|
||||
saveOptions: jest.fn((userId, options) => {
|
||||
savedOptions = options;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
});
|
||||
|
||||
return generator;
|
||||
}
|
||||
|
||||
describe("LegacyPasswordGenerationService", () => {
|
||||
// NOTE: in all tests, `null` constructor arguments are not used by the test.
|
||||
// They're set to `null` to avoid setting up unnecessary mocks.
|
||||
|
||||
describe("generatePassword", () => {
|
||||
it("invokes the inner password generator to generate passwords", async () => {
|
||||
const innerPassword = createPasswordGenerator();
|
||||
const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null, null);
|
||||
const options = { type: "password" } as PasswordGeneratorOptions;
|
||||
|
||||
await generator.generatePassword(options);
|
||||
|
||||
expect(innerPassword.generate).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
it("invokes the inner passphrase generator to generate passphrases", async () => {
|
||||
const innerPassphrase = createPassphraseGenerator();
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
const options = { type: "passphrase" } as PasswordGeneratorOptions;
|
||||
|
||||
await generator.generatePassword(options);
|
||||
|
||||
expect(innerPassphrase.generate).toHaveBeenCalledWith(options);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePassphrase", () => {
|
||||
it("invokes the inner passphrase generator", async () => {
|
||||
const innerPassphrase = createPassphraseGenerator();
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
const options = {} as PasswordGeneratorOptions;
|
||||
|
||||
await generator.generatePassphrase(options);
|
||||
|
||||
expect(innerPassphrase.generate).toHaveBeenCalledWith(options);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOptions", () => {
|
||||
it("combines options from its inner services", async () => {
|
||||
const innerPassword = createPasswordGenerator({
|
||||
length: 29,
|
||||
minLength: 20,
|
||||
ambiguous: false,
|
||||
uppercase: true,
|
||||
minUppercase: 1,
|
||||
lowercase: false,
|
||||
minLowercase: 2,
|
||||
number: true,
|
||||
minNumber: 3,
|
||||
special: false,
|
||||
minSpecial: 0,
|
||||
});
|
||||
const innerPassphrase = createPassphraseGenerator({
|
||||
numWords: 10,
|
||||
wordSeparator: "-",
|
||||
capitalize: true,
|
||||
includeNumber: false,
|
||||
});
|
||||
const navigation = createNavigationGenerator({
|
||||
type: "passphrase",
|
||||
username: "word",
|
||||
forwarder: "simplelogin",
|
||||
});
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
innerPassword,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
|
||||
const [result] = await generator.getOptions();
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "passphrase",
|
||||
length: 29,
|
||||
minLength: 5,
|
||||
ambiguous: false,
|
||||
uppercase: true,
|
||||
minUppercase: 1,
|
||||
lowercase: false,
|
||||
minLowercase: 0,
|
||||
number: true,
|
||||
minNumber: 3,
|
||||
special: false,
|
||||
minSpecial: 0,
|
||||
numWords: 10,
|
||||
wordSeparator: "-",
|
||||
capitalize: true,
|
||||
includeNumber: false,
|
||||
policyUpdated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets default options when an inner service lacks a value", async () => {
|
||||
const innerPassword = createPasswordGenerator(null);
|
||||
const innerPassphrase = createPassphraseGenerator(null);
|
||||
const navigation = createNavigationGenerator(null);
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
innerPassword,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
|
||||
const [result] = await generator.getOptions();
|
||||
|
||||
expect(result).toEqual({
|
||||
type: DefaultGeneratorNavigation.type,
|
||||
...DefaultPassphraseGenerationOptions,
|
||||
...DefaultPasswordGenerationOptions,
|
||||
minLowercase: 1,
|
||||
minUppercase: 1,
|
||||
policyUpdated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("combines policies from its inner services", async () => {
|
||||
const innerPassword = createPasswordGenerator(
|
||||
{},
|
||||
{
|
||||
minLength: 20,
|
||||
numberCount: 10,
|
||||
specialCount: 11,
|
||||
useUppercase: true,
|
||||
useLowercase: false,
|
||||
useNumbers: true,
|
||||
useSpecial: false,
|
||||
},
|
||||
);
|
||||
const innerPassphrase = createPassphraseGenerator(
|
||||
{},
|
||||
{
|
||||
minNumberWords: 5,
|
||||
capitalize: true,
|
||||
includeNumber: false,
|
||||
},
|
||||
);
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const navigation = createNavigationGenerator(
|
||||
{},
|
||||
{
|
||||
defaultType: "password",
|
||||
},
|
||||
);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
innerPassword,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
|
||||
const [, policy] = await generator.getOptions();
|
||||
|
||||
expect(policy).toEqual({
|
||||
defaultType: "password",
|
||||
minLength: 20,
|
||||
numberCount: 10,
|
||||
specialCount: 11,
|
||||
useUppercase: true,
|
||||
useLowercase: false,
|
||||
useNumbers: true,
|
||||
useSpecial: false,
|
||||
minNumberWords: 5,
|
||||
capitalize: true,
|
||||
includeNumber: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforcePasswordGeneratorPoliciesOnOptions", () => {
|
||||
it("returns its options parameter with password policy applied", async () => {
|
||||
const innerPassword = createPasswordGenerator(
|
||||
{},
|
||||
{
|
||||
minLength: 15,
|
||||
numberCount: 5,
|
||||
specialCount: 5,
|
||||
useUppercase: true,
|
||||
useLowercase: true,
|
||||
useNumbers: true,
|
||||
useSpecial: true,
|
||||
},
|
||||
);
|
||||
const innerPassphrase = createPassphraseGenerator();
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const navigation = createNavigationGenerator();
|
||||
const options = {
|
||||
type: "password" as const,
|
||||
};
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
innerPassword,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
|
||||
const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options);
|
||||
|
||||
expect(result).toBe(options);
|
||||
expect(result).toMatchObject({
|
||||
length: 15,
|
||||
minLength: 15,
|
||||
minLowercase: 1,
|
||||
minNumber: 5,
|
||||
minUppercase: 1,
|
||||
minSpecial: 5,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns its options parameter with passphrase policy applied", async () => {
|
||||
const innerPassword = createPasswordGenerator();
|
||||
const innerPassphrase = createPassphraseGenerator(
|
||||
{},
|
||||
{
|
||||
minNumberWords: 5,
|
||||
capitalize: true,
|
||||
includeNumber: true,
|
||||
},
|
||||
);
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const navigation = createNavigationGenerator();
|
||||
const options = {
|
||||
type: "passphrase" as const,
|
||||
};
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
innerPassword,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
|
||||
const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options);
|
||||
|
||||
expect(result).toBe(options);
|
||||
expect(result).toMatchObject({
|
||||
numWords: 5,
|
||||
capitalize: true,
|
||||
includeNumber: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the applied policy", async () => {
|
||||
const innerPassword = createPasswordGenerator(
|
||||
{},
|
||||
{
|
||||
minLength: 20,
|
||||
numberCount: 10,
|
||||
specialCount: 11,
|
||||
useUppercase: true,
|
||||
useLowercase: false,
|
||||
useNumbers: true,
|
||||
useSpecial: false,
|
||||
},
|
||||
);
|
||||
const innerPassphrase = createPassphraseGenerator(
|
||||
{},
|
||||
{
|
||||
minNumberWords: 5,
|
||||
capitalize: true,
|
||||
includeNumber: false,
|
||||
},
|
||||
);
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const navigation = createNavigationGenerator(
|
||||
{},
|
||||
{
|
||||
defaultType: "password",
|
||||
},
|
||||
);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
innerPassword,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
|
||||
const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({});
|
||||
|
||||
expect(policy).toEqual({
|
||||
defaultType: "password",
|
||||
minLength: 20,
|
||||
numberCount: 10,
|
||||
specialCount: 11,
|
||||
useUppercase: true,
|
||||
useLowercase: false,
|
||||
useNumbers: true,
|
||||
useSpecial: false,
|
||||
minNumberWords: 5,
|
||||
capitalize: true,
|
||||
includeNumber: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOptions", () => {
|
||||
it("loads saved password options", async () => {
|
||||
const innerPassword = createPasswordGenerator();
|
||||
const innerPassphrase = createPassphraseGenerator();
|
||||
const navigation = createNavigationGenerator();
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
innerPassword,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
const options = {
|
||||
type: "password" as const,
|
||||
length: 29,
|
||||
minLength: 5,
|
||||
ambiguous: false,
|
||||
uppercase: true,
|
||||
minUppercase: 1,
|
||||
lowercase: false,
|
||||
minLowercase: 0,
|
||||
number: true,
|
||||
minNumber: 3,
|
||||
special: false,
|
||||
minSpecial: 0,
|
||||
};
|
||||
await generator.saveOptions(options);
|
||||
|
||||
const [result] = await generator.getOptions();
|
||||
|
||||
expect(result).toMatchObject(options);
|
||||
});
|
||||
|
||||
it("loads saved passphrase options", async () => {
|
||||
const innerPassword = createPasswordGenerator();
|
||||
const innerPassphrase = createPassphraseGenerator();
|
||||
const navigation = createNavigationGenerator();
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
innerPassword,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
const options = {
|
||||
type: "passphrase" as const,
|
||||
numWords: 10,
|
||||
wordSeparator: "-",
|
||||
capitalize: true,
|
||||
includeNumber: false,
|
||||
};
|
||||
await generator.saveOptions(options);
|
||||
|
||||
const [result] = await generator.getOptions();
|
||||
|
||||
expect(result).toMatchObject(options);
|
||||
});
|
||||
|
||||
it("preserves saved navigation options", async () => {
|
||||
const innerPassword = createPasswordGenerator();
|
||||
const innerPassphrase = createPassphraseGenerator();
|
||||
const navigation = createNavigationGenerator({
|
||||
type: "password",
|
||||
username: "forwarded",
|
||||
forwarder: "firefoxrelay",
|
||||
});
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
innerPassword,
|
||||
innerPassphrase,
|
||||
null,
|
||||
);
|
||||
const options = {
|
||||
type: "passphrase" as const,
|
||||
numWords: 10,
|
||||
wordSeparator: "-",
|
||||
capitalize: true,
|
||||
includeNumber: false,
|
||||
};
|
||||
|
||||
await generator.saveOptions(options);
|
||||
|
||||
expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
type: "passphrase",
|
||||
username: "forwarded",
|
||||
forwarder: "firefoxrelay",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHistory", () => {
|
||||
it("gets the active user's history from the history service", async () => {
|
||||
const history = mock<GeneratorHistoryService>();
|
||||
history.credentials$.mockReturnValue(
|
||||
of([new GeneratedCredential("foo", "password", new Date(100))]),
|
||||
);
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
history,
|
||||
);
|
||||
|
||||
const result = await generator.getHistory();
|
||||
|
||||
expect(history.credentials$).toHaveBeenCalledWith(SomeUser);
|
||||
expect(result).toEqual([new GeneratedPasswordHistory("foo", 100)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addHistory", () => {
|
||||
it("adds a history item as a password credential", async () => {
|
||||
const history = mock<GeneratorHistoryService>();
|
||||
const accountService = mockAccountServiceWith(SomeUser);
|
||||
const generator = new LegacyPasswordGenerationService(
|
||||
accountService,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
history,
|
||||
);
|
||||
|
||||
await generator.addHistory("foo");
|
||||
|
||||
expect(history.track).toHaveBeenCalledWith(SomeUser, "foo", "password");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
concatMap,
|
||||
zip,
|
||||
map,
|
||||
firstValueFrom,
|
||||
combineLatest,
|
||||
pairwise,
|
||||
of,
|
||||
concat,
|
||||
Observable,
|
||||
filter,
|
||||
timeout,
|
||||
} from "rxjs";
|
||||
|
||||
import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
GeneratorService,
|
||||
PassphraseGenerationOptions,
|
||||
PassphraseGeneratorPolicy,
|
||||
PasswordGenerationOptions,
|
||||
PasswordGeneratorPolicy,
|
||||
PolicyEvaluator,
|
||||
} from "@bitwarden/generator-core";
|
||||
|
||||
import { GeneratedCredential, GeneratorHistoryService, GeneratedPasswordHistory } from "../history";
|
||||
import {
|
||||
GeneratorNavigationService,
|
||||
GeneratorNavigation,
|
||||
GeneratorNavigationPolicy,
|
||||
} from "../navigation";
|
||||
|
||||
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
|
||||
import { PasswordGeneratorOptions } from "./password-generator-options";
|
||||
|
||||
type MappedOptions = {
|
||||
generator: GeneratorNavigation;
|
||||
password: PasswordGenerationOptions;
|
||||
passphrase: PassphraseGenerationOptions;
|
||||
policyUpdated: boolean;
|
||||
};
|
||||
|
||||
/** Adapts the generator 2.0 design to 1.0 angular services. */
|
||||
export class LegacyPasswordGenerationService implements PasswordGenerationServiceAbstraction {
|
||||
constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly navigation: GeneratorNavigationService,
|
||||
private readonly passwords: GeneratorService<
|
||||
PasswordGenerationOptions,
|
||||
PasswordGeneratorPolicy
|
||||
>,
|
||||
private readonly passphrases: GeneratorService<
|
||||
PassphraseGenerationOptions,
|
||||
PassphraseGeneratorPolicy
|
||||
>,
|
||||
private readonly history: GeneratorHistoryService,
|
||||
) {}
|
||||
|
||||
generatePassword(options: PasswordGeneratorOptions) {
|
||||
if (options.type === "password") {
|
||||
return this.passwords.generate(options);
|
||||
} else {
|
||||
return this.passphrases.generate(options);
|
||||
}
|
||||
}
|
||||
|
||||
generatePassphrase(options: PasswordGeneratorOptions) {
|
||||
return this.passphrases.generate(options);
|
||||
}
|
||||
|
||||
private getRawOptions$() {
|
||||
// give the typechecker a nudge to avoid "implicit any" errors
|
||||
type RawOptionsIntermediateType = [
|
||||
PasswordGenerationOptions,
|
||||
PasswordGenerationOptions,
|
||||
[PolicyEvaluator<PasswordGeneratorPolicy, PasswordGenerationOptions>, number],
|
||||
PassphraseGenerationOptions,
|
||||
PassphraseGenerationOptions,
|
||||
[PolicyEvaluator<PassphraseGeneratorPolicy, PassphraseGenerationOptions>, number],
|
||||
GeneratorNavigation,
|
||||
GeneratorNavigation,
|
||||
[PolicyEvaluator<GeneratorNavigationPolicy, GeneratorNavigation>, number],
|
||||
];
|
||||
|
||||
function withSequenceNumber<T>(observable$: Observable<T>) {
|
||||
return observable$.pipe(map((evaluator, i) => [evaluator, i] as const));
|
||||
}
|
||||
|
||||
// initial array ensures that destructuring never fails; sequence numbers
|
||||
// set to `-1` so that the first update reflects that the policy changed from
|
||||
// "unknown" to "whatever was provided by the service". This needs to be called
|
||||
// each time the active user changes or the `concat` will block.
|
||||
function initial$() {
|
||||
const initial: RawOptionsIntermediateType = [
|
||||
null,
|
||||
null,
|
||||
[null, -1],
|
||||
null,
|
||||
null,
|
||||
[null, -1],
|
||||
null,
|
||||
null,
|
||||
[null, -1],
|
||||
];
|
||||
|
||||
return of(initial);
|
||||
}
|
||||
|
||||
function intermediatePairsToRawOptions([previous, current]: [
|
||||
RawOptionsIntermediateType,
|
||||
RawOptionsIntermediateType,
|
||||
]) {
|
||||
const [, , [, passwordPrevious], , , [, passphrasePrevious], , , [, generatorPrevious]] =
|
||||
previous;
|
||||
const [
|
||||
passwordOptions,
|
||||
passwordDefaults,
|
||||
[passwordEvaluator, passwordCurrent],
|
||||
passphraseOptions,
|
||||
passphraseDefaults,
|
||||
[passphraseEvaluator, passphraseCurrent],
|
||||
generatorOptions,
|
||||
generatorDefaults,
|
||||
[generatorEvaluator, generatorCurrent],
|
||||
] = current;
|
||||
|
||||
// when any of the sequence numbers change, the emission occurs as the result of
|
||||
// a policy update
|
||||
const policyEmitted =
|
||||
passwordPrevious < passwordCurrent ||
|
||||
passphrasePrevious < passphraseCurrent ||
|
||||
generatorPrevious < generatorCurrent;
|
||||
|
||||
const result = [
|
||||
passwordOptions,
|
||||
passwordDefaults,
|
||||
passwordEvaluator,
|
||||
passphraseOptions,
|
||||
passphraseDefaults,
|
||||
passphraseEvaluator,
|
||||
generatorOptions,
|
||||
generatorDefaults,
|
||||
generatorEvaluator,
|
||||
policyEmitted,
|
||||
] as const;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// look upon my works, ye mighty, and despair!
|
||||
const rawOptions$ = this.accountService.activeAccount$.pipe(
|
||||
concatMap((activeUser) =>
|
||||
concat(
|
||||
initial$(),
|
||||
combineLatest([
|
||||
this.passwords.options$(activeUser.id),
|
||||
this.passwords.defaults$(activeUser.id),
|
||||
withSequenceNumber(this.passwords.evaluator$(activeUser.id)),
|
||||
this.passphrases.options$(activeUser.id),
|
||||
this.passphrases.defaults$(activeUser.id),
|
||||
withSequenceNumber(this.passphrases.evaluator$(activeUser.id)),
|
||||
this.navigation.options$(activeUser.id),
|
||||
this.navigation.defaults$(activeUser.id),
|
||||
withSequenceNumber(this.navigation.evaluator$(activeUser.id)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
pairwise(),
|
||||
map(intermediatePairsToRawOptions),
|
||||
);
|
||||
|
||||
return rawOptions$;
|
||||
}
|
||||
|
||||
getOptions$() {
|
||||
const options$ = this.getRawOptions$().pipe(
|
||||
map(
|
||||
([
|
||||
passwordOptions,
|
||||
passwordDefaults,
|
||||
passwordEvaluator,
|
||||
passphraseOptions,
|
||||
passphraseDefaults,
|
||||
passphraseEvaluator,
|
||||
generatorOptions,
|
||||
generatorDefaults,
|
||||
generatorEvaluator,
|
||||
policyUpdated,
|
||||
]) => {
|
||||
const passwordOptionsWithPolicy = passwordEvaluator.applyPolicy(
|
||||
passwordOptions ?? passwordDefaults,
|
||||
);
|
||||
const passphraseOptionsWithPolicy = passphraseEvaluator.applyPolicy(
|
||||
passphraseOptions ?? passphraseDefaults,
|
||||
);
|
||||
const generatorOptionsWithPolicy = generatorEvaluator.applyPolicy(
|
||||
generatorOptions ?? generatorDefaults,
|
||||
);
|
||||
|
||||
const options = this.toPasswordGeneratorOptions({
|
||||
password: passwordEvaluator.sanitize(passwordOptionsWithPolicy),
|
||||
passphrase: passphraseEvaluator.sanitize(passphraseOptionsWithPolicy),
|
||||
generator: generatorEvaluator.sanitize(generatorOptionsWithPolicy),
|
||||
policyUpdated,
|
||||
});
|
||||
|
||||
const policy = Object.assign(
|
||||
new PasswordGeneratorPolicyOptions(),
|
||||
passwordEvaluator.policy,
|
||||
passphraseEvaluator.policy,
|
||||
generatorEvaluator.policy,
|
||||
);
|
||||
|
||||
return [options, policy] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return options$;
|
||||
}
|
||||
|
||||
async getOptions() {
|
||||
return await firstValueFrom(this.getOptions$());
|
||||
}
|
||||
|
||||
async enforcePasswordGeneratorPoliciesOnOptions(options: PasswordGeneratorOptions) {
|
||||
const options$ = this.accountService.activeAccount$.pipe(
|
||||
concatMap((activeUser) =>
|
||||
zip(
|
||||
this.passwords.evaluator$(activeUser.id),
|
||||
this.passphrases.evaluator$(activeUser.id),
|
||||
this.navigation.evaluator$(activeUser.id),
|
||||
),
|
||||
),
|
||||
map(([passwordEvaluator, passphraseEvaluator, navigationEvaluator]) => {
|
||||
const policy = Object.assign(
|
||||
new PasswordGeneratorPolicyOptions(),
|
||||
passwordEvaluator.policy,
|
||||
passphraseEvaluator.policy,
|
||||
navigationEvaluator.policy,
|
||||
);
|
||||
|
||||
const navigationApplied = navigationEvaluator.applyPolicy(options);
|
||||
const navigationSanitized = {
|
||||
...options,
|
||||
...navigationEvaluator.sanitize(navigationApplied),
|
||||
};
|
||||
if (options.type === "password") {
|
||||
const applied = passwordEvaluator.applyPolicy(navigationSanitized);
|
||||
const sanitized = passwordEvaluator.sanitize(applied);
|
||||
return [sanitized, policy];
|
||||
} else {
|
||||
const applied = passphraseEvaluator.applyPolicy(navigationSanitized);
|
||||
const sanitized = passphraseEvaluator.sanitize(applied);
|
||||
return [sanitized, policy];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const [sanitized, policy] = await firstValueFrom(options$);
|
||||
return [
|
||||
// callers assume this function updates the options parameter
|
||||
Object.assign(options, sanitized),
|
||||
policy,
|
||||
] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions];
|
||||
}
|
||||
|
||||
async saveOptions(options: PasswordGeneratorOptions) {
|
||||
const stored = this.toStoredOptions(options);
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
// generator settings needs to preserve whether password or passphrase is selected,
|
||||
// so `navigationOptions` is mutated.
|
||||
const navigationOptions$ = zip(
|
||||
this.navigation.options$(activeAccount.id),
|
||||
this.navigation.defaults$(activeAccount.id),
|
||||
).pipe(map(([options, defaults]) => options ?? defaults));
|
||||
let navigationOptions = await firstValueFrom(navigationOptions$);
|
||||
navigationOptions = Object.assign(navigationOptions, stored.generator);
|
||||
await this.navigation.saveOptions(activeAccount.id, navigationOptions);
|
||||
|
||||
// overwrite all other settings with latest values
|
||||
await this.passwords.saveOptions(activeAccount.id, stored.password);
|
||||
await this.passphrases.saveOptions(activeAccount.id, stored.passphrase);
|
||||
}
|
||||
|
||||
private toStoredOptions(options: PasswordGeneratorOptions): MappedOptions {
|
||||
return {
|
||||
generator: {
|
||||
type: options.type,
|
||||
},
|
||||
password: {
|
||||
length: options.length,
|
||||
minLength: options.minLength,
|
||||
ambiguous: options.ambiguous,
|
||||
uppercase: options.uppercase,
|
||||
minUppercase: options.minUppercase,
|
||||
lowercase: options.lowercase,
|
||||
minLowercase: options.minLowercase,
|
||||
number: options.number,
|
||||
minNumber: options.minNumber,
|
||||
special: options.special,
|
||||
minSpecial: options.minSpecial,
|
||||
},
|
||||
passphrase: {
|
||||
numWords: options.numWords,
|
||||
wordSeparator: options.wordSeparator,
|
||||
capitalize: options.capitalize,
|
||||
includeNumber: options.includeNumber,
|
||||
},
|
||||
policyUpdated: false,
|
||||
};
|
||||
}
|
||||
|
||||
private toPasswordGeneratorOptions(options: MappedOptions): PasswordGeneratorOptions {
|
||||
return {
|
||||
type: options.generator.type,
|
||||
length: options.password.length,
|
||||
minLength: options.password.minLength,
|
||||
ambiguous: options.password.ambiguous,
|
||||
uppercase: options.password.uppercase,
|
||||
minUppercase: options.password.minUppercase,
|
||||
lowercase: options.password.lowercase,
|
||||
minLowercase: options.password.minLowercase,
|
||||
number: options.password.number,
|
||||
minNumber: options.password.minNumber,
|
||||
special: options.password.special,
|
||||
minSpecial: options.password.minSpecial,
|
||||
numWords: options.passphrase.numWords,
|
||||
wordSeparator: options.passphrase.wordSeparator,
|
||||
capitalize: options.passphrase.capitalize,
|
||||
includeNumber: options.passphrase.includeNumber,
|
||||
policyUpdated: options.policyUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
getHistory() {
|
||||
const history = this.accountService.activeAccount$.pipe(
|
||||
concatMap((account) => this.history.credentials$(account.id)),
|
||||
timeout({
|
||||
// timeout after 1 second
|
||||
each: 1000,
|
||||
with() {
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
map((history) => history.map(toGeneratedPasswordHistory)),
|
||||
);
|
||||
|
||||
return firstValueFrom(history);
|
||||
}
|
||||
|
||||
async addHistory(password: string) {
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (account?.id) {
|
||||
// legacy service doesn't distinguish credential types
|
||||
await this.history.track(account.id, password, "password");
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
const history$ = this.accountService.activeAccount$.pipe(
|
||||
filter((account) => !!account?.id),
|
||||
concatMap((account) => this.history.clear(account.id)),
|
||||
timeout({
|
||||
// timeout after 1 second
|
||||
each: 1000,
|
||||
with() {
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
map((history) => history.map(toGeneratedPasswordHistory)),
|
||||
);
|
||||
|
||||
return firstValueFrom(history$);
|
||||
}
|
||||
}
|
||||
|
||||
function toGeneratedPasswordHistory(value: GeneratedCredential) {
|
||||
return new GeneratedPasswordHistory(value.credential, value.generationDate.valueOf());
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options";
|
||||
|
||||
import { GeneratedPasswordHistory } from "../history";
|
||||
|
||||
import { PasswordGeneratorOptions } from "./password-generator-options";
|
||||
|
||||
/** @deprecated Use {@link GeneratorService} with a password or passphrase {@link GeneratorStrategy} instead. */
|
||||
export abstract class PasswordGenerationServiceAbstraction {
|
||||
generatePassword: (options: PasswordGeneratorOptions) => Promise<string>;
|
||||
generatePassphrase: (options: PasswordGeneratorOptions) => Promise<string>;
|
||||
getOptions: () => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>;
|
||||
getOptions$: () => Observable<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>;
|
||||
enforcePasswordGeneratorPoliciesOnOptions: (
|
||||
options: PasswordGeneratorOptions,
|
||||
) => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>;
|
||||
saveOptions: (options: PasswordGeneratorOptions) => Promise<void>;
|
||||
getHistory: () => Promise<GeneratedPasswordHistory[]>;
|
||||
addHistory: (password: string) => Promise<void>;
|
||||
clear: (userId?: string) => Promise<GeneratedPasswordHistory[]>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PassphraseGenerationOptions, PasswordGenerationOptions } from "@bitwarden/generator-core";
|
||||
|
||||
import { GeneratorNavigation } from "../navigation";
|
||||
|
||||
/** Request format for credential generation.
|
||||
* This type includes all properties suitable for reactive data binding.
|
||||
*/
|
||||
export type PasswordGeneratorOptions = PasswordGenerationOptions &
|
||||
PassphraseGenerationOptions &
|
||||
GeneratorNavigation & { policyUpdated?: boolean };
|
||||
110
libs/tools/generator/extensions/src/legacy-username/factory.ts
Normal file
110
libs/tools/generator/extensions/src/legacy-username/factory.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { engine, services, strategies } from "@bitwarden/generator-core";
|
||||
|
||||
import { DefaultGeneratorNavigationService } from "../navigation";
|
||||
|
||||
import { LegacyUsernameGenerationService } from "./legacy-username-generation.service";
|
||||
import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction";
|
||||
|
||||
const DefaultGeneratorService = services.DefaultGeneratorService;
|
||||
const CryptoServiceRandomizer = engine.CryptoServiceRandomizer;
|
||||
const CatchallGeneratorStrategy = strategies.CatchallGeneratorStrategy;
|
||||
const SubaddressGeneratorStrategy = strategies.SubaddressGeneratorStrategy;
|
||||
const EffUsernameGeneratorStrategy = strategies.EffUsernameGeneratorStrategy;
|
||||
const AddyIoForwarder = strategies.AddyIoForwarder;
|
||||
const DuckDuckGoForwarder = strategies.DuckDuckGoForwarder;
|
||||
const FastmailForwarder = strategies.FastmailForwarder;
|
||||
const FirefoxRelayForwarder = strategies.FirefoxRelayForwarder;
|
||||
const ForwardEmailForwarder = strategies.ForwardEmailForwarder;
|
||||
const SimpleLoginForwarder = strategies.SimpleLoginForwarder;
|
||||
|
||||
export function legacyUsernameGenerationServiceFactory(
|
||||
apiService: ApiService,
|
||||
i18nService: I18nService,
|
||||
cryptoService: CryptoService,
|
||||
encryptService: EncryptService,
|
||||
policyService: PolicyService,
|
||||
accountService: AccountService,
|
||||
stateProvider: StateProvider,
|
||||
): UsernameGenerationServiceAbstraction {
|
||||
const randomizer = new CryptoServiceRandomizer(cryptoService);
|
||||
|
||||
const effUsername = new DefaultGeneratorService(
|
||||
new EffUsernameGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const subaddress = new DefaultGeneratorService(
|
||||
new SubaddressGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const catchall = new DefaultGeneratorService(
|
||||
new CatchallGeneratorStrategy(randomizer, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const addyIo = new DefaultGeneratorService(
|
||||
new AddyIoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const duckDuckGo = new DefaultGeneratorService(
|
||||
new DuckDuckGoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const fastmail = new DefaultGeneratorService(
|
||||
new FastmailForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const firefoxRelay = new DefaultGeneratorService(
|
||||
new FirefoxRelayForwarder(
|
||||
apiService,
|
||||
i18nService,
|
||||
encryptService,
|
||||
cryptoService,
|
||||
stateProvider,
|
||||
),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const forwardEmail = new DefaultGeneratorService(
|
||||
new ForwardEmailForwarder(
|
||||
apiService,
|
||||
i18nService,
|
||||
encryptService,
|
||||
cryptoService,
|
||||
stateProvider,
|
||||
),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const simpleLogin = new DefaultGeneratorService(
|
||||
new SimpleLoginForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
|
||||
policyService,
|
||||
);
|
||||
|
||||
const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService);
|
||||
|
||||
return new LegacyUsernameGenerationService(
|
||||
accountService,
|
||||
navigation,
|
||||
catchall,
|
||||
effUsername,
|
||||
subaddress,
|
||||
addyIo,
|
||||
duckDuckGo,
|
||||
fastmail,
|
||||
firefoxRelay,
|
||||
forwardEmail,
|
||||
simpleLogin,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./username-generation.service.abstraction";
|
||||
export * from "./factory";
|
||||
export * from "./username-generation-options";
|
||||
@@ -0,0 +1,749 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
ApiOptions,
|
||||
EmailDomainOptions,
|
||||
EmailPrefixOptions,
|
||||
SelfHostedApiOptions,
|
||||
GeneratorService,
|
||||
NoPolicy,
|
||||
CatchallGenerationOptions,
|
||||
DefaultCatchallOptions,
|
||||
DefaultEffUsernameOptions,
|
||||
EffUsernameGenerationOptions,
|
||||
DefaultAddyIoOptions,
|
||||
DefaultDuckDuckGoOptions,
|
||||
DefaultFastmailOptions,
|
||||
DefaultFirefoxRelayOptions,
|
||||
DefaultForwardEmailOptions,
|
||||
DefaultSimpleLoginOptions,
|
||||
Forwarders,
|
||||
DefaultSubaddressOptions,
|
||||
SubaddressGenerationOptions,
|
||||
policies,
|
||||
} from "@bitwarden/generator-core";
|
||||
|
||||
import { mockAccountServiceWith } from "../../../../../common/spec";
|
||||
import {
|
||||
GeneratorNavigationPolicy,
|
||||
GeneratorNavigationEvaluator,
|
||||
DefaultGeneratorNavigation,
|
||||
GeneratorNavigation,
|
||||
GeneratorNavigationService,
|
||||
} from "../navigation";
|
||||
|
||||
import { LegacyUsernameGenerationService } from "./legacy-username-generation.service";
|
||||
import { UsernameGeneratorOptions } from "./username-generation-options";
|
||||
|
||||
const DefaultPolicyEvaluator = policies.DefaultPolicyEvaluator;
|
||||
|
||||
const SomeUser = "userId" as UserId;
|
||||
|
||||
function createGenerator<Options>(options: Options, defaults: Options) {
|
||||
let savedOptions = options;
|
||||
const generator = mock<GeneratorService<Options, NoPolicy>>({
|
||||
evaluator$(id: UserId) {
|
||||
const evaluator = new DefaultPolicyEvaluator<Options>();
|
||||
return of(evaluator);
|
||||
},
|
||||
options$(id: UserId) {
|
||||
return of(savedOptions);
|
||||
},
|
||||
defaults$(id: UserId) {
|
||||
return of(defaults);
|
||||
},
|
||||
saveOptions: jest.fn((userId, options) => {
|
||||
savedOptions = options;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
});
|
||||
|
||||
return generator;
|
||||
}
|
||||
|
||||
function createNavigationGenerator(
|
||||
options: GeneratorNavigation = {},
|
||||
policy: GeneratorNavigationPolicy = {},
|
||||
) {
|
||||
let savedOptions = options;
|
||||
const generator = mock<GeneratorNavigationService>({
|
||||
evaluator$(id: UserId) {
|
||||
const evaluator = new GeneratorNavigationEvaluator(policy);
|
||||
return of(evaluator);
|
||||
},
|
||||
options$(id: UserId) {
|
||||
return of(savedOptions);
|
||||
},
|
||||
defaults$(id: UserId) {
|
||||
return of(DefaultGeneratorNavigation);
|
||||
},
|
||||
saveOptions: jest.fn((userId, options) => {
|
||||
savedOptions = options;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
});
|
||||
|
||||
return generator;
|
||||
}
|
||||
|
||||
describe("LegacyUsernameGenerationService", () => {
|
||||
// NOTE: in all tests, `null` constructor arguments are not used by the test.
|
||||
// They're set to `null` to avoid setting up unnecessary mocks.
|
||||
describe("generateUserName", () => {
|
||||
it("should generate a catchall username", async () => {
|
||||
const options = { type: "catchall" } as UsernameGeneratorOptions;
|
||||
const catchall = createGenerator<CatchallGenerationOptions>(null, null);
|
||||
catchall.generate.mockResolvedValue("catchall@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
catchall,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateUsername(options);
|
||||
|
||||
expect(catchall.generate).toHaveBeenCalledWith(options);
|
||||
expect(result).toBe("catchall@example.com");
|
||||
});
|
||||
|
||||
it("should generate an EFF word username", async () => {
|
||||
const options = { type: "word" } as UsernameGeneratorOptions;
|
||||
const effWord = createGenerator<EffUsernameGenerationOptions>(null, null);
|
||||
effWord.generate.mockResolvedValue("eff word");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
effWord,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateUsername(options);
|
||||
|
||||
expect(effWord.generate).toHaveBeenCalledWith(options);
|
||||
expect(result).toBe("eff word");
|
||||
});
|
||||
|
||||
it("should generate a subaddress username", async () => {
|
||||
const options = { type: "subaddress" } as UsernameGeneratorOptions;
|
||||
const subaddress = createGenerator<SubaddressGenerationOptions>(null, null);
|
||||
subaddress.generate.mockResolvedValue("subaddress@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
subaddress,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateUsername(options);
|
||||
|
||||
expect(subaddress.generate).toHaveBeenCalledWith(options);
|
||||
expect(result).toBe("subaddress@example.com");
|
||||
});
|
||||
|
||||
it("should generate a forwarder username", async () => {
|
||||
// set up an arbitrary forwarder for the username test; all forwarders tested in their own tests
|
||||
const options = {
|
||||
type: "forwarded",
|
||||
forwardedService: Forwarders.AddyIo.id,
|
||||
} as UsernameGeneratorOptions;
|
||||
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(null, null);
|
||||
addyIo.generate.mockResolvedValue("addyio@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
addyIo,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateUsername(options);
|
||||
|
||||
expect(addyIo.generate).toHaveBeenCalledWith({});
|
||||
expect(result).toBe("addyio@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateCatchall", () => {
|
||||
it("should generate a catchall username", async () => {
|
||||
const options = { type: "catchall" } as UsernameGeneratorOptions;
|
||||
const catchall = createGenerator<CatchallGenerationOptions>(null, null);
|
||||
catchall.generate.mockResolvedValue("catchall@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
catchall,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateCatchall(options);
|
||||
|
||||
expect(catchall.generate).toHaveBeenCalledWith(options);
|
||||
expect(result).toBe("catchall@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSubaddress", () => {
|
||||
it("should generate a subaddress username", async () => {
|
||||
const options = { type: "subaddress" } as UsernameGeneratorOptions;
|
||||
const subaddress = createGenerator<SubaddressGenerationOptions>(null, null);
|
||||
subaddress.generate.mockResolvedValue("subaddress@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
subaddress,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateSubaddress(options);
|
||||
|
||||
expect(subaddress.generate).toHaveBeenCalledWith(options);
|
||||
expect(result).toBe("subaddress@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateForwarded", () => {
|
||||
it("should generate a AddyIo username", async () => {
|
||||
const options = {
|
||||
forwardedService: Forwarders.AddyIo.id,
|
||||
forwardedAnonAddyApiToken: "token",
|
||||
forwardedAnonAddyBaseUrl: "https://example.com",
|
||||
forwardedAnonAddyDomain: "example.com",
|
||||
website: "example.com",
|
||||
} as UsernameGeneratorOptions;
|
||||
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(null, null);
|
||||
addyIo.generate.mockResolvedValue("addyio@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
addyIo,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateForwarded(options);
|
||||
|
||||
expect(addyIo.generate).toHaveBeenCalledWith({
|
||||
token: "token",
|
||||
baseUrl: "https://example.com",
|
||||
domain: "example.com",
|
||||
website: "example.com",
|
||||
});
|
||||
expect(result).toBe("addyio@example.com");
|
||||
});
|
||||
|
||||
it("should generate a DuckDuckGo username", async () => {
|
||||
const options = {
|
||||
forwardedService: Forwarders.DuckDuckGo.id,
|
||||
forwardedDuckDuckGoToken: "token",
|
||||
website: "example.com",
|
||||
} as UsernameGeneratorOptions;
|
||||
const duckDuckGo = createGenerator<ApiOptions>(null, null);
|
||||
duckDuckGo.generate.mockResolvedValue("ddg@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
duckDuckGo,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateForwarded(options);
|
||||
|
||||
expect(duckDuckGo.generate).toHaveBeenCalledWith({
|
||||
token: "token",
|
||||
website: "example.com",
|
||||
});
|
||||
expect(result).toBe("ddg@example.com");
|
||||
});
|
||||
|
||||
it("should generate a Fastmail username", async () => {
|
||||
const options = {
|
||||
forwardedService: Forwarders.Fastmail.id,
|
||||
forwardedFastmailApiToken: "token",
|
||||
website: "example.com",
|
||||
} as UsernameGeneratorOptions;
|
||||
const fastmail = createGenerator<ApiOptions & EmailPrefixOptions>(null, null);
|
||||
fastmail.generate.mockResolvedValue("fastmail@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
fastmail,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateForwarded(options);
|
||||
|
||||
expect(fastmail.generate).toHaveBeenCalledWith({
|
||||
token: "token",
|
||||
website: "example.com",
|
||||
});
|
||||
expect(result).toBe("fastmail@example.com");
|
||||
});
|
||||
|
||||
it("should generate a FirefoxRelay username", async () => {
|
||||
const options = {
|
||||
forwardedService: Forwarders.FirefoxRelay.id,
|
||||
forwardedFirefoxApiToken: "token",
|
||||
website: "example.com",
|
||||
} as UsernameGeneratorOptions;
|
||||
const firefoxRelay = createGenerator<ApiOptions>(null, null);
|
||||
firefoxRelay.generate.mockResolvedValue("firefoxrelay@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
firefoxRelay,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateForwarded(options);
|
||||
|
||||
expect(firefoxRelay.generate).toHaveBeenCalledWith({
|
||||
token: "token",
|
||||
website: "example.com",
|
||||
});
|
||||
expect(result).toBe("firefoxrelay@example.com");
|
||||
});
|
||||
|
||||
it("should generate a ForwardEmail username", async () => {
|
||||
const options = {
|
||||
forwardedService: Forwarders.ForwardEmail.id,
|
||||
forwardedForwardEmailApiToken: "token",
|
||||
forwardedForwardEmailDomain: "example.com",
|
||||
website: "example.com",
|
||||
} as UsernameGeneratorOptions;
|
||||
const forwardEmail = createGenerator<ApiOptions & EmailDomainOptions>(null, null);
|
||||
forwardEmail.generate.mockResolvedValue("forwardemail@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
forwardEmail,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await generator.generateForwarded(options);
|
||||
|
||||
expect(forwardEmail.generate).toHaveBeenCalledWith({
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
website: "example.com",
|
||||
});
|
||||
expect(result).toBe("forwardemail@example.com");
|
||||
});
|
||||
|
||||
it("should generate a SimpleLogin username", async () => {
|
||||
const options = {
|
||||
forwardedService: Forwarders.SimpleLogin.id,
|
||||
forwardedSimpleLoginApiKey: "token",
|
||||
forwardedSimpleLoginBaseUrl: "https://example.com",
|
||||
website: "example.com",
|
||||
} as UsernameGeneratorOptions;
|
||||
const simpleLogin = createGenerator<SelfHostedApiOptions>(null, null);
|
||||
simpleLogin.generate.mockResolvedValue("simplelogin@example.com");
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
simpleLogin,
|
||||
);
|
||||
|
||||
const result = await generator.generateForwarded(options);
|
||||
|
||||
expect(simpleLogin.generate).toHaveBeenCalledWith({
|
||||
token: "token",
|
||||
baseUrl: "https://example.com",
|
||||
website: "example.com",
|
||||
});
|
||||
expect(result).toBe("simplelogin@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOptions", () => {
|
||||
it("combines options from its inner generators", async () => {
|
||||
const account = mockAccountServiceWith(SomeUser);
|
||||
|
||||
const navigation = createNavigationGenerator({
|
||||
type: "username",
|
||||
username: "catchall",
|
||||
forwarder: Forwarders.AddyIo.id,
|
||||
});
|
||||
|
||||
const catchall = createGenerator<CatchallGenerationOptions>(
|
||||
{
|
||||
catchallDomain: "example.com",
|
||||
catchallType: "random",
|
||||
website: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const effUsername = createGenerator<EffUsernameGenerationOptions>(
|
||||
{
|
||||
wordCapitalize: true,
|
||||
wordIncludeNumber: false,
|
||||
website: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const subaddress = createGenerator<SubaddressGenerationOptions>(
|
||||
{
|
||||
subaddressType: "random",
|
||||
subaddressEmail: "foo@example.com",
|
||||
website: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(
|
||||
{
|
||||
token: "addyIoToken",
|
||||
domain: "addyio.example.com",
|
||||
baseUrl: "https://addyio.api.example.com",
|
||||
website: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const duckDuckGo = createGenerator<ApiOptions>(
|
||||
{
|
||||
token: "ddgToken",
|
||||
website: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const fastmail = createGenerator<ApiOptions & EmailPrefixOptions>(
|
||||
{
|
||||
token: "fastmailToken",
|
||||
domain: "fastmail.example.com",
|
||||
prefix: "foo",
|
||||
website: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const firefoxRelay = createGenerator<ApiOptions>(
|
||||
{
|
||||
token: "firefoxToken",
|
||||
website: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const forwardEmail = createGenerator<ApiOptions & EmailDomainOptions>(
|
||||
{
|
||||
token: "forwardEmailToken",
|
||||
domain: "example.com",
|
||||
website: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const simpleLogin = createGenerator<SelfHostedApiOptions>(
|
||||
{
|
||||
token: "simpleLoginToken",
|
||||
baseUrl: "https://simplelogin.api.example.com",
|
||||
website: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
account,
|
||||
navigation,
|
||||
catchall,
|
||||
effUsername,
|
||||
subaddress,
|
||||
addyIo,
|
||||
duckDuckGo,
|
||||
fastmail,
|
||||
firefoxRelay,
|
||||
forwardEmail,
|
||||
simpleLogin,
|
||||
);
|
||||
|
||||
const result = await generator.getOptions();
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "catchall",
|
||||
wordCapitalize: true,
|
||||
wordIncludeNumber: false,
|
||||
subaddressType: "random",
|
||||
subaddressEmail: "foo@example.com",
|
||||
catchallType: "random",
|
||||
catchallDomain: "example.com",
|
||||
forwardedService: Forwarders.AddyIo.id,
|
||||
forwardedAnonAddyApiToken: "addyIoToken",
|
||||
forwardedAnonAddyDomain: "addyio.example.com",
|
||||
forwardedAnonAddyBaseUrl: "https://addyio.api.example.com",
|
||||
forwardedDuckDuckGoToken: "ddgToken",
|
||||
forwardedFirefoxApiToken: "firefoxToken",
|
||||
forwardedFastmailApiToken: "fastmailToken",
|
||||
forwardedForwardEmailApiToken: "forwardEmailToken",
|
||||
forwardedForwardEmailDomain: "example.com",
|
||||
forwardedSimpleLoginApiKey: "simpleLoginToken",
|
||||
forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets default options when an inner service lacks a value", async () => {
|
||||
const account = mockAccountServiceWith(SomeUser);
|
||||
const navigation = createNavigationGenerator(null);
|
||||
const catchall = createGenerator<CatchallGenerationOptions>(null, DefaultCatchallOptions);
|
||||
const effUsername = createGenerator<EffUsernameGenerationOptions>(
|
||||
null,
|
||||
DefaultEffUsernameOptions,
|
||||
);
|
||||
const subaddress = createGenerator<SubaddressGenerationOptions>(
|
||||
null,
|
||||
DefaultSubaddressOptions,
|
||||
);
|
||||
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(
|
||||
null,
|
||||
DefaultAddyIoOptions,
|
||||
);
|
||||
const duckDuckGo = createGenerator<ApiOptions>(null, DefaultDuckDuckGoOptions);
|
||||
const fastmail = createGenerator<ApiOptions & EmailPrefixOptions>(
|
||||
null,
|
||||
DefaultFastmailOptions,
|
||||
);
|
||||
const firefoxRelay = createGenerator<ApiOptions>(null, DefaultFirefoxRelayOptions);
|
||||
const forwardEmail = createGenerator<ApiOptions & EmailDomainOptions>(
|
||||
null,
|
||||
DefaultForwardEmailOptions,
|
||||
);
|
||||
const simpleLogin = createGenerator<SelfHostedApiOptions>(null, DefaultSimpleLoginOptions);
|
||||
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
account,
|
||||
navigation,
|
||||
catchall,
|
||||
effUsername,
|
||||
subaddress,
|
||||
addyIo,
|
||||
duckDuckGo,
|
||||
fastmail,
|
||||
firefoxRelay,
|
||||
forwardEmail,
|
||||
simpleLogin,
|
||||
);
|
||||
|
||||
const result = await generator.getOptions();
|
||||
|
||||
expect(result).toEqual({
|
||||
type: DefaultGeneratorNavigation.username,
|
||||
catchallType: DefaultCatchallOptions.catchallType,
|
||||
catchallDomain: DefaultCatchallOptions.catchallDomain,
|
||||
wordCapitalize: DefaultEffUsernameOptions.wordCapitalize,
|
||||
wordIncludeNumber: DefaultEffUsernameOptions.wordIncludeNumber,
|
||||
subaddressType: DefaultSubaddressOptions.subaddressType,
|
||||
subaddressEmail: DefaultSubaddressOptions.subaddressEmail,
|
||||
forwardedService: DefaultGeneratorNavigation.forwarder,
|
||||
forwardedAnonAddyApiToken: DefaultAddyIoOptions.token,
|
||||
forwardedAnonAddyDomain: DefaultAddyIoOptions.domain,
|
||||
forwardedAnonAddyBaseUrl: DefaultAddyIoOptions.baseUrl,
|
||||
forwardedDuckDuckGoToken: DefaultDuckDuckGoOptions.token,
|
||||
forwardedFastmailApiToken: DefaultFastmailOptions.token,
|
||||
forwardedFirefoxApiToken: DefaultFirefoxRelayOptions.token,
|
||||
forwardedForwardEmailApiToken: DefaultForwardEmailOptions.token,
|
||||
forwardedForwardEmailDomain: DefaultForwardEmailOptions.domain,
|
||||
forwardedSimpleLoginApiKey: DefaultSimpleLoginOptions.token,
|
||||
forwardedSimpleLoginBaseUrl: DefaultSimpleLoginOptions.baseUrl,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOptions", () => {
|
||||
it("saves option sets to its inner generators", async () => {
|
||||
const account = mockAccountServiceWith(SomeUser);
|
||||
const navigation = createNavigationGenerator({ type: "password" });
|
||||
const catchall = createGenerator<CatchallGenerationOptions>(null, null);
|
||||
const effUsername = createGenerator<EffUsernameGenerationOptions>(null, null);
|
||||
const subaddress = createGenerator<SubaddressGenerationOptions>(null, null);
|
||||
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(null, null);
|
||||
const duckDuckGo = createGenerator<ApiOptions>(null, null);
|
||||
const fastmail = createGenerator<ApiOptions & EmailPrefixOptions>(null, null);
|
||||
const firefoxRelay = createGenerator<ApiOptions>(null, null);
|
||||
const forwardEmail = createGenerator<ApiOptions & EmailDomainOptions>(null, null);
|
||||
const simpleLogin = createGenerator<SelfHostedApiOptions>(null, null);
|
||||
|
||||
const generator = new LegacyUsernameGenerationService(
|
||||
account,
|
||||
navigation,
|
||||
catchall,
|
||||
effUsername,
|
||||
subaddress,
|
||||
addyIo,
|
||||
duckDuckGo,
|
||||
fastmail,
|
||||
firefoxRelay,
|
||||
forwardEmail,
|
||||
simpleLogin,
|
||||
);
|
||||
|
||||
await generator.saveOptions({
|
||||
type: "catchall",
|
||||
wordCapitalize: true,
|
||||
wordIncludeNumber: false,
|
||||
subaddressType: "random",
|
||||
subaddressEmail: "foo@example.com",
|
||||
catchallType: "random",
|
||||
catchallDomain: "example.com",
|
||||
forwardedService: Forwarders.AddyIo.id,
|
||||
forwardedAnonAddyApiToken: "addyIoToken",
|
||||
forwardedAnonAddyDomain: "addyio.example.com",
|
||||
forwardedAnonAddyBaseUrl: "https://addyio.api.example.com",
|
||||
forwardedDuckDuckGoToken: "ddgToken",
|
||||
forwardedFirefoxApiToken: "firefoxToken",
|
||||
forwardedFastmailApiToken: "fastmailToken",
|
||||
forwardedForwardEmailApiToken: "forwardEmailToken",
|
||||
forwardedForwardEmailDomain: "example.com",
|
||||
forwardedSimpleLoginApiKey: "simpleLoginToken",
|
||||
forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com",
|
||||
website: null,
|
||||
});
|
||||
|
||||
expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
type: "password",
|
||||
username: "catchall",
|
||||
forwarder: Forwarders.AddyIo.id,
|
||||
});
|
||||
|
||||
expect(catchall.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
catchallDomain: "example.com",
|
||||
catchallType: "random",
|
||||
website: null,
|
||||
});
|
||||
|
||||
expect(effUsername.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
wordCapitalize: true,
|
||||
wordIncludeNumber: false,
|
||||
website: null,
|
||||
});
|
||||
|
||||
expect(subaddress.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
subaddressType: "random",
|
||||
subaddressEmail: "foo@example.com",
|
||||
website: null,
|
||||
});
|
||||
|
||||
expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
token: "addyIoToken",
|
||||
domain: "addyio.example.com",
|
||||
baseUrl: "https://addyio.api.example.com",
|
||||
website: null,
|
||||
});
|
||||
|
||||
expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
token: "ddgToken",
|
||||
website: null,
|
||||
});
|
||||
|
||||
expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
token: "fastmailToken",
|
||||
website: null,
|
||||
});
|
||||
|
||||
expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
token: "firefoxToken",
|
||||
website: null,
|
||||
});
|
||||
|
||||
expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
token: "forwardEmailToken",
|
||||
domain: "example.com",
|
||||
website: null,
|
||||
});
|
||||
|
||||
expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, {
|
||||
token: "simpleLoginToken",
|
||||
baseUrl: "https://simplelogin.api.example.com",
|
||||
website: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
ApiOptions,
|
||||
EmailDomainOptions,
|
||||
EmailPrefixOptions,
|
||||
RequestOptions,
|
||||
SelfHostedApiOptions,
|
||||
NoPolicy,
|
||||
GeneratorService,
|
||||
CatchallGenerationOptions,
|
||||
EffUsernameGenerationOptions,
|
||||
Forwarders,
|
||||
SubaddressGenerationOptions,
|
||||
} from "@bitwarden/generator-core";
|
||||
|
||||
import { GeneratorNavigationService, GeneratorNavigation } from "../navigation";
|
||||
|
||||
import { UsernameGeneratorOptions } from "./username-generation-options";
|
||||
import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction";
|
||||
|
||||
type MappedOptions = {
|
||||
generator: GeneratorNavigation;
|
||||
algorithms: {
|
||||
catchall: CatchallGenerationOptions;
|
||||
effUsername: EffUsernameGenerationOptions;
|
||||
subaddress: SubaddressGenerationOptions;
|
||||
};
|
||||
forwarders: {
|
||||
addyIo: SelfHostedApiOptions & EmailDomainOptions & RequestOptions;
|
||||
duckDuckGo: ApiOptions & RequestOptions;
|
||||
fastmail: ApiOptions & EmailPrefixOptions & RequestOptions;
|
||||
firefoxRelay: ApiOptions & RequestOptions;
|
||||
forwardEmail: ApiOptions & EmailDomainOptions & RequestOptions;
|
||||
simpleLogin: SelfHostedApiOptions & RequestOptions;
|
||||
};
|
||||
};
|
||||
|
||||
/** Adapts the generator 2.0 design to 1.0 angular services. */
|
||||
export class LegacyUsernameGenerationService implements UsernameGenerationServiceAbstraction {
|
||||
constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly navigation: GeneratorNavigationService,
|
||||
private readonly catchall: GeneratorService<CatchallGenerationOptions, NoPolicy>,
|
||||
private readonly effUsername: GeneratorService<EffUsernameGenerationOptions, NoPolicy>,
|
||||
private readonly subaddress: GeneratorService<SubaddressGenerationOptions, NoPolicy>,
|
||||
private readonly addyIo: GeneratorService<SelfHostedApiOptions & EmailDomainOptions, NoPolicy>,
|
||||
private readonly duckDuckGo: GeneratorService<ApiOptions, NoPolicy>,
|
||||
private readonly fastmail: GeneratorService<ApiOptions & EmailPrefixOptions, NoPolicy>,
|
||||
private readonly firefoxRelay: GeneratorService<ApiOptions, NoPolicy>,
|
||||
private readonly forwardEmail: GeneratorService<ApiOptions & EmailDomainOptions, NoPolicy>,
|
||||
private readonly simpleLogin: GeneratorService<SelfHostedApiOptions, NoPolicy>,
|
||||
) {}
|
||||
|
||||
generateUsername(options: UsernameGeneratorOptions) {
|
||||
if (options.type === "catchall") {
|
||||
return this.generateCatchall(options);
|
||||
} else if (options.type === "subaddress") {
|
||||
return this.generateSubaddress(options);
|
||||
} else if (options.type === "forwarded") {
|
||||
return this.generateForwarded(options);
|
||||
} else {
|
||||
return this.generateWord(options);
|
||||
}
|
||||
}
|
||||
|
||||
generateWord(options: UsernameGeneratorOptions) {
|
||||
return this.effUsername.generate(options);
|
||||
}
|
||||
|
||||
generateSubaddress(options: UsernameGeneratorOptions) {
|
||||
return this.subaddress.generate(options);
|
||||
}
|
||||
|
||||
generateCatchall(options: UsernameGeneratorOptions) {
|
||||
return this.catchall.generate(options);
|
||||
}
|
||||
|
||||
generateForwarded(options: UsernameGeneratorOptions) {
|
||||
if (!options.forwardedService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stored = this.toStoredOptions(options);
|
||||
switch (options.forwardedService) {
|
||||
case Forwarders.AddyIo.id:
|
||||
return this.addyIo.generate(stored.forwarders.addyIo);
|
||||
case Forwarders.DuckDuckGo.id:
|
||||
return this.duckDuckGo.generate(stored.forwarders.duckDuckGo);
|
||||
case Forwarders.Fastmail.id:
|
||||
return this.fastmail.generate(stored.forwarders.fastmail);
|
||||
case Forwarders.FirefoxRelay.id:
|
||||
return this.firefoxRelay.generate(stored.forwarders.firefoxRelay);
|
||||
case Forwarders.ForwardEmail.id:
|
||||
return this.forwardEmail.generate(stored.forwarders.forwardEmail);
|
||||
case Forwarders.SimpleLogin.id:
|
||||
return this.simpleLogin.generate(stored.forwarders.simpleLogin);
|
||||
}
|
||||
}
|
||||
|
||||
getOptions$() {
|
||||
// look upon my works, ye mighty, and despair!
|
||||
const options$ = this.accountService.activeAccount$.pipe(
|
||||
concatMap((account) =>
|
||||
combineLatest([
|
||||
this.navigation.options$(account.id),
|
||||
this.navigation.defaults$(account.id),
|
||||
this.catchall.options$(account.id),
|
||||
this.catchall.defaults$(account.id),
|
||||
this.effUsername.options$(account.id),
|
||||
this.effUsername.defaults$(account.id),
|
||||
this.subaddress.options$(account.id),
|
||||
this.subaddress.defaults$(account.id),
|
||||
this.addyIo.options$(account.id),
|
||||
this.addyIo.defaults$(account.id),
|
||||
this.duckDuckGo.options$(account.id),
|
||||
this.duckDuckGo.defaults$(account.id),
|
||||
this.fastmail.options$(account.id),
|
||||
this.fastmail.defaults$(account.id),
|
||||
this.firefoxRelay.options$(account.id),
|
||||
this.firefoxRelay.defaults$(account.id),
|
||||
this.forwardEmail.options$(account.id),
|
||||
this.forwardEmail.defaults$(account.id),
|
||||
this.simpleLogin.options$(account.id),
|
||||
this.simpleLogin.defaults$(account.id),
|
||||
]),
|
||||
),
|
||||
map(
|
||||
([
|
||||
generatorOptions,
|
||||
generatorDefaults,
|
||||
catchallOptions,
|
||||
catchallDefaults,
|
||||
effUsernameOptions,
|
||||
effUsernameDefaults,
|
||||
subaddressOptions,
|
||||
subaddressDefaults,
|
||||
addyIoOptions,
|
||||
addyIoDefaults,
|
||||
duckDuckGoOptions,
|
||||
duckDuckGoDefaults,
|
||||
fastmailOptions,
|
||||
fastmailDefaults,
|
||||
firefoxRelayOptions,
|
||||
firefoxRelayDefaults,
|
||||
forwardEmailOptions,
|
||||
forwardEmailDefaults,
|
||||
simpleLoginOptions,
|
||||
simpleLoginDefaults,
|
||||
]) =>
|
||||
this.toUsernameOptions({
|
||||
generator: generatorOptions ?? generatorDefaults,
|
||||
algorithms: {
|
||||
catchall: catchallOptions ?? catchallDefaults,
|
||||
effUsername: effUsernameOptions ?? effUsernameDefaults,
|
||||
subaddress: subaddressOptions ?? subaddressDefaults,
|
||||
},
|
||||
forwarders: {
|
||||
addyIo: addyIoOptions ?? addyIoDefaults,
|
||||
duckDuckGo: duckDuckGoOptions ?? duckDuckGoDefaults,
|
||||
fastmail: fastmailOptions ?? fastmailDefaults,
|
||||
firefoxRelay: firefoxRelayOptions ?? firefoxRelayDefaults,
|
||||
forwardEmail: forwardEmailOptions ?? forwardEmailDefaults,
|
||||
simpleLogin: simpleLoginOptions ?? simpleLoginDefaults,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return options$;
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
return firstValueFrom(this.getOptions$());
|
||||
}
|
||||
|
||||
async saveOptions(options: UsernameGeneratorOptions) {
|
||||
const stored = this.toStoredOptions(options);
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
// generator settings needs to preserve whether password or passphrase is selected,
|
||||
// so `navigationOptions` is mutated.
|
||||
const navigationOptions$ = zip(
|
||||
this.navigation.options$(activeAccount.id),
|
||||
this.navigation.defaults$(activeAccount.id),
|
||||
).pipe(map(([options, defaults]) => options ?? defaults));
|
||||
let navigationOptions = await firstValueFrom(navigationOptions$);
|
||||
navigationOptions = Object.assign(navigationOptions, stored.generator);
|
||||
await this.navigation.saveOptions(activeAccount.id, navigationOptions);
|
||||
|
||||
// overwrite all other settings with latest values
|
||||
await Promise.all([
|
||||
this.catchall.saveOptions(activeAccount.id, stored.algorithms.catchall),
|
||||
this.effUsername.saveOptions(activeAccount.id, stored.algorithms.effUsername),
|
||||
this.subaddress.saveOptions(activeAccount.id, stored.algorithms.subaddress),
|
||||
this.addyIo.saveOptions(activeAccount.id, stored.forwarders.addyIo),
|
||||
this.duckDuckGo.saveOptions(activeAccount.id, stored.forwarders.duckDuckGo),
|
||||
this.fastmail.saveOptions(activeAccount.id, stored.forwarders.fastmail),
|
||||
this.firefoxRelay.saveOptions(activeAccount.id, stored.forwarders.firefoxRelay),
|
||||
this.forwardEmail.saveOptions(activeAccount.id, stored.forwarders.forwardEmail),
|
||||
this.simpleLogin.saveOptions(activeAccount.id, stored.forwarders.simpleLogin),
|
||||
]);
|
||||
}
|
||||
|
||||
private toStoredOptions(options: UsernameGeneratorOptions) {
|
||||
const forwarders = {
|
||||
addyIo: {
|
||||
baseUrl: options.forwardedAnonAddyBaseUrl,
|
||||
token: options.forwardedAnonAddyApiToken,
|
||||
domain: options.forwardedAnonAddyDomain,
|
||||
website: options.website,
|
||||
},
|
||||
duckDuckGo: {
|
||||
token: options.forwardedDuckDuckGoToken,
|
||||
website: options.website,
|
||||
},
|
||||
fastmail: {
|
||||
token: options.forwardedFastmailApiToken,
|
||||
website: options.website,
|
||||
},
|
||||
firefoxRelay: {
|
||||
token: options.forwardedFirefoxApiToken,
|
||||
website: options.website,
|
||||
},
|
||||
forwardEmail: {
|
||||
token: options.forwardedForwardEmailApiToken,
|
||||
domain: options.forwardedForwardEmailDomain,
|
||||
website: options.website,
|
||||
},
|
||||
simpleLogin: {
|
||||
token: options.forwardedSimpleLoginApiKey,
|
||||
baseUrl: options.forwardedSimpleLoginBaseUrl,
|
||||
website: options.website,
|
||||
},
|
||||
};
|
||||
|
||||
const generator = {
|
||||
username: options.type,
|
||||
forwarder: options.forwardedService,
|
||||
};
|
||||
|
||||
const algorithms = {
|
||||
effUsername: {
|
||||
wordCapitalize: options.wordCapitalize,
|
||||
wordIncludeNumber: options.wordIncludeNumber,
|
||||
website: options.website,
|
||||
},
|
||||
subaddress: {
|
||||
subaddressType: options.subaddressType,
|
||||
subaddressEmail: options.subaddressEmail,
|
||||
website: options.website,
|
||||
},
|
||||
catchall: {
|
||||
catchallType: options.catchallType,
|
||||
catchallDomain: options.catchallDomain,
|
||||
website: options.website,
|
||||
},
|
||||
};
|
||||
|
||||
return { generator, algorithms, forwarders } as MappedOptions;
|
||||
}
|
||||
|
||||
private toUsernameOptions(options: MappedOptions) {
|
||||
return {
|
||||
type: options.generator.username,
|
||||
wordCapitalize: options.algorithms.effUsername.wordCapitalize,
|
||||
wordIncludeNumber: options.algorithms.effUsername.wordIncludeNumber,
|
||||
subaddressType: options.algorithms.subaddress.subaddressType,
|
||||
subaddressEmail: options.algorithms.subaddress.subaddressEmail,
|
||||
catchallType: options.algorithms.catchall.catchallType,
|
||||
catchallDomain: options.algorithms.catchall.catchallDomain,
|
||||
forwardedService: options.generator.forwarder,
|
||||
forwardedAnonAddyApiToken: options.forwarders.addyIo.token,
|
||||
forwardedAnonAddyDomain: options.forwarders.addyIo.domain,
|
||||
forwardedAnonAddyBaseUrl: options.forwarders.addyIo.baseUrl,
|
||||
forwardedDuckDuckGoToken: options.forwarders.duckDuckGo.token,
|
||||
forwardedFirefoxApiToken: options.forwarders.firefoxRelay.token,
|
||||
forwardedFastmailApiToken: options.forwarders.fastmail.token,
|
||||
forwardedForwardEmailApiToken: options.forwarders.forwardEmail.token,
|
||||
forwardedForwardEmailDomain: options.forwarders.forwardEmail.domain,
|
||||
forwardedSimpleLoginApiKey: options.forwarders.simpleLogin.token,
|
||||
forwardedSimpleLoginBaseUrl: options.forwarders.simpleLogin.baseUrl,
|
||||
} as UsernameGeneratorOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
ForwarderId,
|
||||
RequestOptions,
|
||||
CatchallGenerationOptions,
|
||||
EffUsernameGenerationOptions,
|
||||
SubaddressGenerationOptions,
|
||||
UsernameGeneratorType,
|
||||
} from "@bitwarden/generator-core";
|
||||
|
||||
export type UsernameGeneratorOptions = EffUsernameGenerationOptions &
|
||||
SubaddressGenerationOptions &
|
||||
CatchallGenerationOptions &
|
||||
RequestOptions & {
|
||||
type?: UsernameGeneratorType;
|
||||
forwardedService?: ForwarderId | "";
|
||||
forwardedAnonAddyApiToken?: string;
|
||||
forwardedAnonAddyDomain?: string;
|
||||
forwardedAnonAddyBaseUrl?: string;
|
||||
forwardedDuckDuckGoToken?: string;
|
||||
forwardedFirefoxApiToken?: string;
|
||||
forwardedFastmailApiToken?: string;
|
||||
forwardedForwardEmailApiToken?: string;
|
||||
forwardedForwardEmailDomain?: string;
|
||||
forwardedSimpleLoginApiKey?: string;
|
||||
forwardedSimpleLoginBaseUrl?: string;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UsernameGeneratorOptions } from "./username-generation-options";
|
||||
|
||||
/** @deprecated Use {@link GeneratorService} with a username {@link GeneratorStrategy} instead. */
|
||||
export abstract class UsernameGenerationServiceAbstraction {
|
||||
generateUsername: (options: UsernameGeneratorOptions) => Promise<string>;
|
||||
generateWord: (options: UsernameGeneratorOptions) => Promise<string>;
|
||||
generateSubaddress: (options: UsernameGeneratorOptions) => Promise<string>;
|
||||
generateCatchall: (options: UsernameGeneratorOptions) => Promise<string>;
|
||||
generateForwarded: (options: UsernameGeneratorOptions) => Promise<string>;
|
||||
getOptions: () => Promise<UsernameGeneratorOptions>;
|
||||
getOptions$: () => Observable<UsernameGeneratorOptions>;
|
||||
saveOptions: (options: UsernameGeneratorOptions) => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec";
|
||||
|
||||
import { GENERATOR_SETTINGS } from "./key-definitions";
|
||||
|
||||
import {
|
||||
GeneratorNavigationEvaluator,
|
||||
DefaultGeneratorNavigationService,
|
||||
DefaultGeneratorNavigation,
|
||||
} from "./";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
describe("DefaultGeneratorNavigationService", () => {
|
||||
describe("options$", () => {
|
||||
it("emits options", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const settings = { type: "password" as const };
|
||||
await stateProvider.setUserState(GENERATOR_SETTINGS, settings, SomeUser);
|
||||
const navigation = new DefaultGeneratorNavigationService(stateProvider, null);
|
||||
|
||||
const result = await firstValueFrom(navigation.options$(SomeUser));
|
||||
|
||||
expect(result).toEqual(settings);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("emits default options", async () => {
|
||||
const navigation = new DefaultGeneratorNavigationService(null, null);
|
||||
|
||||
const result = await firstValueFrom(navigation.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultGeneratorNavigation);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluator$", () => {
|
||||
it("emits a GeneratorNavigationEvaluator", async () => {
|
||||
const policyService = mock<PolicyService>({
|
||||
getAll$() {
|
||||
return of([]);
|
||||
},
|
||||
});
|
||||
const navigation = new DefaultGeneratorNavigationService(null, policyService);
|
||||
|
||||
const result = await firstValueFrom(navigation.evaluator$(SomeUser));
|
||||
|
||||
expect(result).toBeInstanceOf(GeneratorNavigationEvaluator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforcePolicy", () => {
|
||||
it("applies policy", async () => {
|
||||
const policyService = mock<PolicyService>({
|
||||
getAll$(_type: PolicyType, _user: UserId) {
|
||||
return of([
|
||||
new Policy({
|
||||
id: "" as any,
|
||||
organizationId: "" as any,
|
||||
enabled: true,
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: { defaultType: "password" },
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const navigation = new DefaultGeneratorNavigationService(null, policyService);
|
||||
const options = {};
|
||||
|
||||
const result = await navigation.enforcePolicy(SomeUser, options);
|
||||
|
||||
expect(result).toMatchObject({ type: "password" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOptions", () => {
|
||||
it("updates options$", async () => {
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
const navigation = new DefaultGeneratorNavigationService(stateProvider, null);
|
||||
const settings = { type: "password" as const };
|
||||
|
||||
await navigation.saveOptions(SomeUser, settings);
|
||||
const result = await firstValueFrom(navigation.options$(SomeUser));
|
||||
|
||||
expect(result).toEqual(settings);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { distinctIfShallowMatch, reduceCollection } from "@bitwarden/common/tools/rx";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DefaultGeneratorNavigation } from "./default-generator-navigation";
|
||||
import { GeneratorNavigation } from "./generator-navigation";
|
||||
import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
|
||||
import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy";
|
||||
import { GeneratorNavigationService } from "./generator-navigation.service.abstraction";
|
||||
import { GENERATOR_SETTINGS } from "./key-definitions";
|
||||
|
||||
export class DefaultGeneratorNavigationService implements GeneratorNavigationService {
|
||||
/** instantiates the password generator strategy.
|
||||
* @param stateProvider provides durable state
|
||||
* @param policy provides the policy to enforce
|
||||
*/
|
||||
constructor(
|
||||
private readonly stateProvider: StateProvider,
|
||||
private readonly policy: PolicyService,
|
||||
) {}
|
||||
|
||||
/** An observable monitoring the options saved to disk.
|
||||
* The observable updates when the options are saved.
|
||||
* @param userId: Identifies the user making the request
|
||||
*/
|
||||
options$(userId: UserId): Observable<GeneratorNavigation> {
|
||||
return this.stateProvider.getUserState$(GENERATOR_SETTINGS, userId);
|
||||
}
|
||||
|
||||
/** Gets the default options. */
|
||||
defaults$(userId: UserId): Observable<GeneratorNavigation> {
|
||||
return new BehaviorSubject({ ...DefaultGeneratorNavigation });
|
||||
}
|
||||
|
||||
/** An observable monitoring the options used to enforce policy.
|
||||
* The observable updates when the policy changes.
|
||||
* @param userId: Identifies the user making the request
|
||||
*/
|
||||
evaluator$(userId: UserId) {
|
||||
const evaluator$ = this.policy.getAll$(PolicyType.PasswordGenerator, userId).pipe(
|
||||
reduceCollection(preferPassword, DisabledGeneratorNavigationPolicy),
|
||||
distinctIfShallowMatch(),
|
||||
map((policy) => new GeneratorNavigationEvaluator(policy)),
|
||||
);
|
||||
|
||||
return evaluator$;
|
||||
}
|
||||
|
||||
/** Enforces the policy on the given options
|
||||
* @param userId: Identifies the user making the request
|
||||
* @param options the options to enforce the policy on
|
||||
* @returns a new instance of the options with the policy enforced
|
||||
*/
|
||||
async enforcePolicy(userId: UserId, options: GeneratorNavigation) {
|
||||
const evaluator = await firstValueFrom(this.evaluator$(userId));
|
||||
const applied = evaluator.applyPolicy(options);
|
||||
const sanitized = evaluator.sanitize(applied);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/** Saves the navigation options to disk.
|
||||
* @param userId: Identifies the user making the request
|
||||
* @param options the options to save
|
||||
* @returns a promise that resolves when the options are saved
|
||||
*/
|
||||
async saveOptions(userId: UserId, options: GeneratorNavigation): Promise<void> {
|
||||
await this.stateProvider.setUserState(GENERATOR_SETTINGS, options, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { GeneratorNavigation } from "./generator-navigation";
|
||||
|
||||
/** The default options for password generation. */
|
||||
export const DefaultGeneratorNavigation: Partial<GeneratorNavigation> = Object.freeze({
|
||||
type: "password",
|
||||
username: "word",
|
||||
forwarder: "",
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { DefaultGeneratorNavigation } from "./default-generator-navigation";
|
||||
import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
|
||||
|
||||
describe("GeneratorNavigationEvaluator", () => {
|
||||
describe("policyInEffect", () => {
|
||||
it.each([["passphrase"], ["password"]] as const)(
|
||||
"returns true if the policy has a defaultType (= %p)",
|
||||
(defaultType) => {
|
||||
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
|
||||
|
||||
expect(evaluator.policyInEffect).toEqual(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([[undefined], [null], ["" as any]])(
|
||||
"returns false if the policy has a falsy defaultType (= %p)",
|
||||
(defaultType) => {
|
||||
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
|
||||
|
||||
expect(evaluator.policyInEffect).toEqual(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("applyPolicy", () => {
|
||||
it("returns the input options", () => {
|
||||
const evaluator = new GeneratorNavigationEvaluator(null);
|
||||
const options = { type: "password" as const };
|
||||
|
||||
const result = evaluator.applyPolicy(options);
|
||||
|
||||
expect(result).toEqual(options);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitize", () => {
|
||||
it.each([["passphrase"], ["password"]] as const)(
|
||||
"defaults options to the policy's default type (= %p) when a policy is in effect",
|
||||
(defaultType) => {
|
||||
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
|
||||
|
||||
const result = evaluator.sanitize({});
|
||||
|
||||
expect(result).toEqual({ type: defaultType });
|
||||
},
|
||||
);
|
||||
|
||||
it("defaults options to the default generator navigation type when a policy is not in effect", () => {
|
||||
const evaluator = new GeneratorNavigationEvaluator(null);
|
||||
|
||||
const result = evaluator.sanitize({});
|
||||
|
||||
expect(result.type).toEqual(DefaultGeneratorNavigation.type);
|
||||
});
|
||||
|
||||
it("retains the options type when it is set", () => {
|
||||
const evaluator = new GeneratorNavigationEvaluator({ defaultType: "passphrase" });
|
||||
|
||||
const result = evaluator.sanitize({ type: "password" });
|
||||
|
||||
expect(result).toEqual({ type: "password" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { PolicyEvaluator } from "@bitwarden/generator-core";
|
||||
|
||||
import { DefaultGeneratorNavigation } from "./default-generator-navigation";
|
||||
import { GeneratorNavigation } from "./generator-navigation";
|
||||
import { GeneratorNavigationPolicy } from "./generator-navigation-policy";
|
||||
|
||||
/** Enforces policy for generator navigation options.
|
||||
*/
|
||||
export class GeneratorNavigationEvaluator
|
||||
implements PolicyEvaluator<GeneratorNavigationPolicy, GeneratorNavigation>
|
||||
{
|
||||
/** Instantiates the evaluator.
|
||||
* @param policy The policy applied by the evaluator. When this conflicts with
|
||||
* the defaults, the policy takes precedence.
|
||||
*/
|
||||
constructor(readonly policy: GeneratorNavigationPolicy) {}
|
||||
|
||||
/** {@link PolicyEvaluator.policyInEffect} */
|
||||
get policyInEffect(): boolean {
|
||||
return this.policy?.defaultType ? true : false;
|
||||
}
|
||||
|
||||
/** Apply policy to the input options.
|
||||
* @param options The options to build from. These options are not altered.
|
||||
* @returns A new password generation request with policy applied.
|
||||
*/
|
||||
applyPolicy(options: GeneratorNavigation): GeneratorNavigation {
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Ensures internal options consistency.
|
||||
* @param options The options to cascade. These options are not altered.
|
||||
* @returns A passphrase generation request with cascade applied.
|
||||
*/
|
||||
sanitize(options: GeneratorNavigation): GeneratorNavigation {
|
||||
const defaultType = this.policyInEffect
|
||||
? this.policy.defaultType
|
||||
: DefaultGeneratorNavigation.type;
|
||||
return {
|
||||
...options,
|
||||
type: options.type ?? defaultType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { PolicyId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy";
|
||||
|
||||
function createPolicy(
|
||||
data: any,
|
||||
type: PolicyType = PolicyType.PasswordGenerator,
|
||||
enabled: boolean = true,
|
||||
) {
|
||||
return new Policy({
|
||||
id: "id" as PolicyId,
|
||||
organizationId: "organizationId",
|
||||
data,
|
||||
enabled,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
describe("leastPrivilege", () => {
|
||||
it("should return the accumulator when the policy type does not apply", () => {
|
||||
const policy = createPolicy({}, PolicyType.RequireSso);
|
||||
|
||||
const result = preferPassword(DisabledGeneratorNavigationPolicy, policy);
|
||||
|
||||
expect(result).toEqual(DisabledGeneratorNavigationPolicy);
|
||||
});
|
||||
|
||||
it("should return the accumulator when the policy is not enabled", () => {
|
||||
const policy = createPolicy({}, PolicyType.PasswordGenerator, false);
|
||||
|
||||
const result = preferPassword(DisabledGeneratorNavigationPolicy, policy);
|
||||
|
||||
expect(result).toEqual(DisabledGeneratorNavigationPolicy);
|
||||
});
|
||||
|
||||
it("should take the %p from the policy", () => {
|
||||
const policy = createPolicy({ defaultType: "passphrase" });
|
||||
|
||||
const result = preferPassword({ ...DisabledGeneratorNavigationPolicy }, policy);
|
||||
|
||||
expect(result).toEqual({ defaultType: "passphrase" });
|
||||
});
|
||||
|
||||
it("should override passphrase with password", () => {
|
||||
const policy = createPolicy({ defaultType: "password" });
|
||||
|
||||
const result = preferPassword({ defaultType: "passphrase" }, policy);
|
||||
|
||||
expect(result).toEqual({ defaultType: "password" });
|
||||
});
|
||||
|
||||
it("should not override password", () => {
|
||||
const policy = createPolicy({ defaultType: "passphrase" });
|
||||
|
||||
const result = preferPassword({ defaultType: "password" }, policy);
|
||||
|
||||
expect(result).toEqual({ defaultType: "password" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { GeneratorType } from "@bitwarden/generator-core";
|
||||
|
||||
/** Policy settings affecting password generator navigation */
|
||||
export type GeneratorNavigationPolicy = {
|
||||
/** The type of generator that should be shown by default when opening
|
||||
* the password generator.
|
||||
*/
|
||||
defaultType?: GeneratorType;
|
||||
};
|
||||
|
||||
/** Reduces a policy into an accumulator by preferring the password generator
|
||||
* type to other generator types.
|
||||
* @param acc the accumulator
|
||||
* @param policy the policy to reduce
|
||||
* @returns the resulting `GeneratorNavigationPolicy`
|
||||
*/
|
||||
export function preferPassword(
|
||||
acc: GeneratorNavigationPolicy,
|
||||
policy: Policy,
|
||||
): GeneratorNavigationPolicy {
|
||||
const isEnabled = policy.type === PolicyType.PasswordGenerator && policy.enabled;
|
||||
if (!isEnabled) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const isOverridable = acc.defaultType !== "password" && policy.data.defaultType;
|
||||
const result = isOverridable ? { ...acc, defaultType: policy.data.defaultType } : acc;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The default options for password generation policy. */
|
||||
export const DisabledGeneratorNavigationPolicy: GeneratorNavigationPolicy = Object.freeze({
|
||||
defaultType: undefined,
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { PolicyEvaluator } from "@bitwarden/generator-core";
|
||||
|
||||
import { GeneratorNavigation } from "./generator-navigation";
|
||||
import { GeneratorNavigationPolicy } from "./generator-navigation-policy";
|
||||
|
||||
/** Loads and stores generator navigational data
|
||||
*/
|
||||
export abstract class GeneratorNavigationService {
|
||||
/** An observable monitoring the options saved to disk.
|
||||
* The observable updates when the options are saved.
|
||||
* @param userId: Identifies the user making the request
|
||||
*/
|
||||
options$: (userId: UserId) => Observable<GeneratorNavigation>;
|
||||
|
||||
/** Gets the default options. */
|
||||
defaults$: (userId: UserId) => Observable<GeneratorNavigation>;
|
||||
|
||||
/** An observable monitoring the options used to enforce policy.
|
||||
* The observable updates when the policy changes.
|
||||
* @param userId: Identifies the user making the request
|
||||
*/
|
||||
evaluator$: (
|
||||
userId: UserId,
|
||||
) => Observable<PolicyEvaluator<GeneratorNavigationPolicy, GeneratorNavigation>>;
|
||||
|
||||
/** Enforces the policy on the given options
|
||||
* @param userId: Identifies the user making the request
|
||||
* @param options the options to enforce the policy on
|
||||
* @returns a new instance of the options with the policy enforced
|
||||
*/
|
||||
enforcePolicy: (userId: UserId, options: GeneratorNavigation) => Promise<GeneratorNavigation>;
|
||||
|
||||
/** Saves the navigation options to disk.
|
||||
* @param userId: Identifies the user making the request
|
||||
* @param options the options to save
|
||||
* @returns a promise that resolves when the options are saved
|
||||
*/
|
||||
saveOptions: (userId: UserId, options: GeneratorNavigation) => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { GeneratorType, ForwarderId, UsernameGeneratorType } from "@bitwarden/generator-core";
|
||||
|
||||
/** Stores credential generator UI state. */
|
||||
export type GeneratorNavigation = {
|
||||
/** The kind of credential being generated.
|
||||
* @remarks The legacy generator only supports "password" and "passphrase".
|
||||
* The componentized generator supports all values.
|
||||
*/
|
||||
type?: GeneratorType;
|
||||
|
||||
/** When `type === "username"`, this stores the username algorithm. */
|
||||
username?: UsernameGeneratorType;
|
||||
|
||||
/** When `username === "forwarded"`, this stores the forwarder implementation. */
|
||||
forwarder?: ForwarderId | "";
|
||||
};
|
||||
6
libs/tools/generator/extensions/src/navigation/index.ts
Normal file
6
libs/tools/generator/extensions/src/navigation/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { DefaultGeneratorNavigation } from "./default-generator-navigation";
|
||||
export { DefaultGeneratorNavigationService } from "./default-generator-navigation.service";
|
||||
export { GeneratorNavigation } from "./generator-navigation";
|
||||
export { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
|
||||
export { GeneratorNavigationService } from "./generator-navigation.service.abstraction";
|
||||
export { GeneratorNavigationPolicy } from "./generator-navigation-policy";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { GENERATOR_SETTINGS } from "./key-definitions";
|
||||
|
||||
describe("Key definitions", () => {
|
||||
describe("GENERATOR_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = {};
|
||||
const result = GENERATOR_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { GeneratorNavigation } from "./generator-navigation";
|
||||
|
||||
/** plaintext password generation options */
|
||||
export const GENERATOR_SETTINGS = new UserKeyDefinition<GeneratorNavigation>(
|
||||
GENERATOR_DISK,
|
||||
"generatorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user