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

[PM-9008] factor generator-extensions into separate libraries (#9724)

This commit is contained in:
✨ Audrey ✨
2024-06-20 10:49:23 -04:00
committed by GitHub
parent 8bd2118d77
commit 639debe91b
62 changed files with 178 additions and 58 deletions

View File

@@ -0,0 +1,48 @@
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 "@bitwarden/generator-history";
import { DefaultGeneratorNavigationService } from "@bitwarden/generator-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,
);
}

View File

@@ -0,0 +1,109 @@
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 "@bitwarden/generator-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,
);
}

View File

@@ -0,0 +1,6 @@
export * from "./password-generation.service.abstraction";
export * from "./create-legacy-password-generation-service";
export * from "./password-generator-options";
export * from "./username-generation.service.abstraction";
export * from "./create-legacy-username-generation-service";
export * from "./username-generation-options";

View File

@@ -0,0 +1,566 @@
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 {
GeneratedCredential,
GeneratorHistoryService,
GeneratedPasswordHistory,
} from "@bitwarden/generator-history";
import {
GeneratorNavigationService,
DefaultGeneratorNavigation,
GeneratorNavigation,
GeneratorNavigationEvaluator,
GeneratorNavigationPolicy,
} from "@bitwarden/generator-navigation";
import { mockAccountServiceWith } from "../../../../../common/spec";
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");
});
});
});

View File

@@ -0,0 +1,384 @@
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 "@bitwarden/generator-history";
import {
GeneratorNavigationService,
GeneratorNavigation,
GeneratorNavigationPolicy,
} from "@bitwarden/generator-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());
}

View File

@@ -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 {
GeneratorNavigationPolicy,
GeneratorNavigationEvaluator,
DefaultGeneratorNavigation,
GeneratorNavigation,
GeneratorNavigationService,
} from "@bitwarden/generator-navigation";
import { mockAccountServiceWith } from "../../../../../common/spec";
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,
});
});
});
});

View File

@@ -0,0 +1,285 @@
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 "@bitwarden/generator-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;
}
}

View File

@@ -0,0 +1,21 @@
import { Observable } from "rxjs";
import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options";
import { GeneratedPasswordHistory } from "@bitwarden/generator-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[]>;
}

View File

@@ -0,0 +1,9 @@
import { PassphraseGenerationOptions, PasswordGenerationOptions } from "@bitwarden/generator-core";
import { GeneratorNavigation } from "@bitwarden/generator-navigation";
/** Request format for credential generation.
* This type includes all properties suitable for reactive data binding.
*/
export type PasswordGeneratorOptions = PasswordGenerationOptions &
PassphraseGenerationOptions &
GeneratorNavigation & { policyUpdated?: boolean };

View File

@@ -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;
};

View File

@@ -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>;
}