[PM-8280] email forwarders (#11563)

* forwarder lookup and generation support
* localize algorithm names and descriptions in the credential generator service
* add encryption support to UserStateSubject
* move generic rx utilities to common
* move icon button labels to generator configurations
This commit is contained in:
✨ Audrey ✨ 2024-10-23 12:11:42 -04:00 committed by GitHub
parent e67577cc39
commit eff9a423da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 3403 additions and 1005 deletions

View File

@ -1395,6 +1395,10 @@
"baseUrl": { "baseUrl": {
"message": "Server URL" "message": "Server URL"
}, },
"selfHostBaseUrl": {
"message": "Self-host server URL",
"description": "Label for field requesting a self-hosted integration service URL"
},
"apiUrl": { "apiUrl": {
"message": "API server URL" "message": "API server URL"
}, },
@ -2833,6 +2837,9 @@
"generateUsername": { "generateUsername": {
"message": "Generate username" "message": "Generate username"
}, },
"generateEmail": {
"message": "Generate email"
},
"usernameType": { "usernameType": {
"message": "Username type" "message": "Username type"
}, },
@ -2873,6 +2880,14 @@
"forwardedEmailDesc": { "forwardedEmailDesc": {
"message": "Generate an email alias with an external forwarding service." "message": "Generate an email alias with an external forwarding service."
}, },
"forwarderDomainName": {
"message": "Email domain",
"description": "Labels the domain name email forwarder service option"
},
"forwarderDomainNameHint": {
"message": "Choose a domain that is supported by the selected service",
"description": "Guidance provided for email forwarding services that support multiple email domains."
},
"forwarderError": { "forwarderError": {
"message": "$SERVICENAME$ error: $ERRORMESSAGE$", "message": "$SERVICENAME$ error: $ERRORMESSAGE$",
"description": "Reports an error returned by a forwarding service to the user.", "description": "Reports an error returned by a forwarding service to the user.",

View File

@ -835,6 +835,10 @@
"baseUrl": { "baseUrl": {
"message": "Server URL" "message": "Server URL"
}, },
"selfHostBaseUrl": {
"message": "Self-host server URL",
"description": "Label for field requesting a self-hosted integration service URL"
},
"apiUrl": { "apiUrl": {
"message": "API server URL" "message": "API server URL"
}, },
@ -1225,6 +1229,9 @@
"message": "Copy number", "message": "Copy number",
"description": "Copy credit card number" "description": "Copy credit card number"
}, },
"copyEmail": {
"message": "Copy email"
},
"copySecurityCode": { "copySecurityCode": {
"message": "Copy security code", "message": "Copy security code",
"description": "Copy credit card security code (CVV)" "description": "Copy credit card security code (CVV)"
@ -2359,6 +2366,9 @@
"generateUsername": { "generateUsername": {
"message": "Generate username" "message": "Generate username"
}, },
"generateEmail": {
"message": "Generate email"
},
"usernameType": { "usernameType": {
"message": "Username type" "message": "Username type"
}, },
@ -2402,6 +2412,14 @@
"forwardedEmailDesc": { "forwardedEmailDesc": {
"message": "Generate an email alias with an external forwarding service." "message": "Generate an email alias with an external forwarding service."
}, },
"forwarderDomainName": {
"message": "Email domain",
"description": "Labels the domain name email forwarder service option"
},
"forwarderDomainNameHint": {
"message": "Choose a domain that is supported by the selected service",
"description": "Guidance provided for email forwarding services that support multiple email domains."
},
"forwarderError": { "forwarderError": {
"message": "$SERVICENAME$ error: $ERRORMESSAGE$", "message": "$SERVICENAME$ error: $ERRORMESSAGE$",
"description": "Reports an error returned by a forwarding service to the user.", "description": "Reports an error returned by a forwarding service to the user.",

View File

@ -6361,6 +6361,9 @@
"generateUsername": { "generateUsername": {
"message": "Generate username" "message": "Generate username"
}, },
"generateEmail": {
"message": "Generate email"
},
"usernameType": { "usernameType": {
"message": "Username type" "message": "Username type"
}, },
@ -6466,6 +6469,14 @@
"forwardedEmailDesc": { "forwardedEmailDesc": {
"message": "Generate an email alias with an external forwarding service." "message": "Generate an email alias with an external forwarding service."
}, },
"forwarderDomainName": {
"message": "Email domain",
"description": "Labels the domain name email forwarder service option"
},
"forwarderDomainNameHint": {
"message": "Choose a domain that is supported by the selected service",
"description": "Guidance provided for email forwarding services that support multiple email domains."
},
"forwarderError": { "forwarderError": {
"message": "$SERVICENAME$ error: $ERRORMESSAGE$", "message": "$SERVICENAME$ error: $ERRORMESSAGE$",
"description": "Reports an error returned by a forwarding service to the user.", "description": "Reports an error returned by a forwarding service to the user.",
@ -8265,6 +8276,10 @@
"baseUrl": { "baseUrl": {
"message": "Server URL" "message": "Server URL"
}, },
"selfHostBaseUrl": {
"message": "Self-host server URL",
"description": "Label for field requesting a self-hosted integration service URL"
},
"aliasDomain": { "aliasDomain": {
"message": "Alias domain" "message": "Alias domain"
}, },

View File

@ -3,6 +3,8 @@ import { Observable } from "rxjs";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { UserEncryptor } from "./state/user-encryptor.abstraction";
/** error emitted when the `SingleUserDependency` changes Ids */ /** error emitted when the `SingleUserDependency` changes Ids */
export type UserChangedError = { export type UserChangedError = {
/** the userId pinned by the single user dependency */ /** the userId pinned by the single user dependency */
@ -45,7 +47,35 @@ export type UserDependency = {
userId$: Observable<UserId>; userId$: Observable<UserId>;
}; };
/** A pattern for types that depend upon a fixed userid and return /** Decorates a type to indicate the user, if any, that the type is usable only by
* a specific user.
*/
export type UserBound<K extends keyof any, T> = { [P in K]: T } & {
/** The user to which T is bound. */
userId: UserId;
};
/** A pattern for types that depend upon a fixed-key encryptor and return
* an observable.
*
* Consumers of this dependency should emit a `UserChangedError` if
* the bound UserId changes or if the encryptor changes. If
* `singleUserEncryptor$` completes, the consumer should complete
* once all events received prior to the completion event are
* finished processing. The consumer should, where possible,
* prioritize these events in order to complete as soon as possible.
* If `singleUserEncryptor$` emits an unrecoverable error, the consumer
* should also emit the error.
*/
export type SingleUserEncryptorDependency = {
/** A stream that emits an encryptor when subscribed and the user key
* is available, and completes when the user key is no longer available.
* The stream should not emit null or undefined.
*/
singleUserEncryptor$: Observable<UserBound<"encryptor", UserEncryptor>>;
};
/** A pattern for types that depend upon a fixed-value userid and return
* an observable. * an observable.
* *
* Consumers of this dependency should emit a `UserChangedError` if * Consumers of this dependency should emit a `UserChangedError` if

View File

@ -1,7 +1,13 @@
import { Opaque } from "type-fest"; import { Opaque } from "type-fest";
export const IntegrationIds = [
"anonaddy",
"duckduckgo",
"fastmail",
"firefoxrelay",
"forwardemail",
"simplelogin",
] as const;
/** Identifies a vendor integrated into bitwarden */ /** Identifies a vendor integrated into bitwarden */
export type IntegrationId = Opaque< export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">;
"anonaddy" | "duckduckgo" | "fastmail" | "firefoxrelay" | "forwardemail" | "simplelogin",
"IntegrationId"
>;

View File

@ -0,0 +1,31 @@
import { Jsonify } from "type-fest";
import { Classifier } from "@bitwarden/common/tools/state/classifier";
export class PrivateClassifier<Data> implements Classifier<Data, Record<string, never>, Data> {
constructor(private keys: (keyof Jsonify<Data>)[] = undefined) {}
classify(value: Data): { disclosed: Jsonify<Record<string, never>>; secret: Jsonify<Data> } {
const pickMe = JSON.parse(JSON.stringify(value));
const keys: (keyof Jsonify<Data>)[] = this.keys ?? (Object.keys(pickMe) as any);
const picked: Partial<Jsonify<Data>> = {};
for (const key of keys) {
picked[key] = pickMe[key];
}
const secret = picked as Jsonify<Data>;
return { disclosed: null, secret };
}
declassify(_disclosed: Jsonify<Record<keyof Data, never>>, secret: Jsonify<Data>) {
const result: Partial<Jsonify<Data>> = {};
const keys: (keyof Jsonify<Data>)[] = this.keys ?? (Object.keys(secret) as any);
for (const key of keys) {
result[key] = secret[key];
}
return result as Jsonify<Data>;
}
}

View File

@ -0,0 +1,29 @@
import { Jsonify } from "type-fest";
import { Classifier } from "@bitwarden/common/tools/state/classifier";
export class PublicClassifier<Data> implements Classifier<Data, Data, Record<string, never>> {
constructor(private keys: (keyof Jsonify<Data>)[]) {}
classify(value: Data): { disclosed: Jsonify<Data>; secret: Jsonify<Record<string, never>> } {
const pickMe = JSON.parse(JSON.stringify(value));
const picked: Partial<Jsonify<Data>> = {};
for (const key of this.keys) {
picked[key] = pickMe[key];
}
const disclosed = picked as Jsonify<Data>;
return { disclosed, secret: null };
}
declassify(disclosed: Jsonify<Data>, _secret: Jsonify<Record<keyof Data, never>>) {
const result: Partial<Jsonify<Data>> = {};
for (const key of this.keys) {
result[key] = disclosed[key];
}
return result as Jsonify<Data>;
}
}

View File

@ -2,11 +2,18 @@
* include structuredClone in test environment. * include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts * @jest-environment ../../../../shared/test.environment.ts
*/ */
import { of, firstValueFrom } from "rxjs"; import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs";
import { awaitAsync, trackEmissions } from "../../spec"; import { awaitAsync, trackEmissions } from "../../spec";
import { distinctIfShallowMatch, reduceCollection } from "./rx"; import {
anyComplete,
distinctIfShallowMatch,
on,
ready,
reduceCollection,
withLatestReady,
} from "./rx";
describe("reduceCollection", () => { describe("reduceCollection", () => {
it.each([[null], [undefined], [[]]])( it.each([[null], [undefined], [[]]])(
@ -84,3 +91,488 @@ describe("distinctIfShallowMatch", () => {
expect(result).toEqual([{ foo: true, bar: true }]); expect(result).toEqual([{ foo: true, bar: true }]);
}); });
}); });
describe("anyComplete", () => {
it("emits true when its input completes", () => {
const input$ = new Subject<void>();
const emissions: boolean[] = [];
anyComplete(input$).subscribe((e) => emissions.push(e));
input$.complete();
expect(emissions).toEqual([true]);
});
it("completes when its input is already complete", () => {
const input = new Subject<void>();
input.complete();
let completed = false;
anyComplete(input).subscribe({ complete: () => (completed = true) });
expect(completed).toBe(true);
});
it("completes when any input completes", () => {
const input$ = new Subject<void>();
const completing$ = new Subject<void>();
let completed = false;
anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) });
completing$.complete();
expect(completed).toBe(true);
});
it("ignores emissions", () => {
const input$ = new Subject<number>();
const emissions: boolean[] = [];
anyComplete(input$).subscribe((e) => emissions.push(e));
input$.next(1);
input$.next(2);
input$.complete();
expect(emissions).toEqual([true]);
});
it("forwards errors", () => {
const input$ = new Subject<void>();
const expected = { some: "error" };
let error = null;
anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) });
input$.error(expected);
expect(error).toEqual(expected);
});
});
describe("ready", () => {
it("connects when subscribed", () => {
const watch$ = new Subject<void>();
let connected = false;
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
// precondition: ready$ should be cold
const ready$ = source$.pipe(ready(watch$));
expect(connected).toBe(false);
ready$.subscribe();
expect(connected).toBe(true);
});
it("suppresses source emissions until its watch emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
// precondition: no emissions
source$.next(1);
expect(results).toEqual([]);
watch$.next();
expect(results).toEqual([1]);
});
it("suppresses source emissions until all watches emit", () => {
const watchA$ = new Subject<void>();
const watchB$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready([watchA$, watchB$]));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
// preconditions: no emissions
source$.next(1);
expect(results).toEqual([]);
watchA$.next();
expect(results).toEqual([]);
watchB$.next();
expect(results).toEqual([1]);
});
it("emits the last source emission when its watch emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
// precondition: no emissions
source$.next(1);
expect(results).toEqual([]);
source$.next(2);
watch$.next();
expect(results).toEqual([2]);
});
it("emits all source emissions after its watch emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
watch$.next();
source$.next(1);
source$.next(2);
expect(results).toEqual([1, 2]);
});
it("ignores repeated watch emissions", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
watch$.next();
source$.next(1);
watch$.next();
source$.next(2);
watch$.next();
expect(results).toEqual([1, 2]);
});
it("completes when its source completes", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
let completed = false;
ready$.subscribe({ complete: () => (completed = true) });
source$.complete();
expect(completed).toBeTruthy();
});
it("errors when its source errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const expected = { some: "error" };
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
source$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const expected = { some: "error" };
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
watch$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch completes before emitting", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
watch$.complete();
expect(error).toBeInstanceOf(EmptyError);
});
});
describe("withLatestReady", () => {
it("connects when subscribed", () => {
const watch$ = new Subject<string>();
let connected = false;
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
// precondition: ready$ should be cold
const ready$ = source$.pipe(withLatestReady(watch$));
expect(connected).toBe(false);
ready$.subscribe();
expect(connected).toBe(true);
});
it("suppresses source emissions until its watch emits", () => {
const watch$ = new Subject<string>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(withLatestReady(watch$));
const results: [number, string][] = [];
ready$.subscribe((n) => results.push(n));
// precondition: no emissions
source$.next(1);
expect(results).toEqual([]);
watch$.next("watch");
expect(results).toEqual([[1, "watch"]]);
});
it("emits the last source emission when its watch emits", () => {
const watch$ = new Subject<string>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(withLatestReady(watch$));
const results: [number, string][] = [];
ready$.subscribe((n) => results.push(n));
// precondition: no emissions
source$.next(1);
expect(results).toEqual([]);
source$.next(2);
watch$.next("watch");
expect(results).toEqual([[2, "watch"]]);
});
it("emits all source emissions after its watch emits", () => {
const watch$ = new Subject<string>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(withLatestReady(watch$));
const results: [number, string][] = [];
ready$.subscribe((n) => results.push(n));
watch$.next("watch");
source$.next(1);
source$.next(2);
expect(results).toEqual([
[1, "watch"],
[2, "watch"],
]);
});
it("appends the latest watch emission", () => {
const watch$ = new Subject<string>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(withLatestReady(watch$));
const results: [number, string][] = [];
ready$.subscribe((n) => results.push(n));
watch$.next("ignored");
watch$.next("watch");
source$.next(1);
watch$.next("ignored");
watch$.next("watch");
source$.next(2);
expect(results).toEqual([
[1, "watch"],
[2, "watch"],
]);
});
it("completes when its source completes", () => {
const watch$ = new Subject<string>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(withLatestReady(watch$));
let completed = false;
ready$.subscribe({ complete: () => (completed = true) });
source$.complete();
expect(completed).toBeTruthy();
});
it("errors when its source errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(withLatestReady(watch$));
const expected = { some: "error" };
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
source$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch errors", () => {
const watch$ = new Subject<string>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(withLatestReady(watch$));
const expected = { some: "error" };
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
watch$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch completes before emitting", () => {
const watch$ = new Subject<string>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(withLatestReady(watch$));
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
watch$.complete();
expect(error).toBeInstanceOf(EmptyError);
});
});
describe("on", () => {
it("connects when subscribed", () => {
const watch$ = new Subject<void>();
let connected = false;
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
// precondition: on$ should be cold
const on$ = source$.pipe(on(watch$));
expect(connected).toBeFalsy();
on$.subscribe();
expect(connected).toBeTruthy();
});
it("suppresses source emissions until `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
// precondition: on$ should be cold
source$.next(1);
expect(results).toEqual([]);
watch$.next();
expect(results).toEqual([1]);
});
it("repeats source emissions when `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
source$.next(1);
watch$.next();
watch$.next();
expect(results).toEqual([1, 1]);
});
it("updates source emissions when `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
source$.next(1);
watch$.next();
source$.next(2);
watch$.next();
expect(results).toEqual([1, 2]);
});
it("emits a value when `on` emits before the source is ready", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
watch$.next();
source$.next(1);
expect(results).toEqual([1]);
});
it("ignores repeated `on` emissions before the source is ready", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
watch$.next();
watch$.next();
source$.next(1);
expect(results).toEqual([1]);
});
it("emits only the latest source emission when `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
source$.next(1);
watch$.next();
source$.next(2);
source$.next(3);
watch$.next();
expect(results).toEqual([1, 3]);
});
it("completes when its source completes", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
let complete: boolean = false;
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
source$.complete();
expect(complete).toBeTruthy();
});
it("completes when its watch completes", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
let complete: boolean = false;
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
watch$.complete();
expect(complete).toBeTruthy();
});
it("errors when its source errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const expected = { some: "error" };
let error = null;
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
source$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const expected = { some: "error" };
let error = null;
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
watch$.error(expected);
expect(error).toEqual(expected);
});
});

View File

@ -1,4 +1,21 @@
import { map, distinctUntilChanged, OperatorFunction } from "rxjs"; import {
map,
distinctUntilChanged,
OperatorFunction,
Observable,
ignoreElements,
endWith,
race,
pipe,
connect,
ReplaySubject,
concat,
zip,
first,
takeUntil,
withLatestFrom,
concatMap,
} from "rxjs";
/** /**
* An observable operator that reduces an emitted collection to a single object, * An observable operator that reduces an emitted collection to a single object,
@ -36,3 +53,109 @@ export function distinctIfShallowMatch<Item>(): OperatorFunction<Item, Item> {
return isDistinct; return isDistinct;
}); });
} }
/** Create an observable that, once subscribed, emits `true` then completes when
* any input completes. If an input is already complete when the subscription
* occurs, it emits immediately.
* @param watch$ the observable(s) to watch for completion; if an array is passed,
* null and undefined members are ignored. If `watch$` is empty, `anyComplete`
* will never complete.
* @returns An observable that emits `true` when any of its inputs
* complete. The observable forwards the first error from its input.
* @remarks This method is particularly useful in combination with `takeUntil` and
* streams that are not guaranteed to complete on their own.
*/
export function anyComplete(watch$: Observable<any> | Observable<any>[]): Observable<any> {
if (Array.isArray(watch$)) {
const completes$ = watch$
.filter((w$) => !!w$)
.map((w$) => w$.pipe(ignoreElements(), endWith(true)));
const completed$ = race(completes$);
return completed$;
} else {
return watch$.pipe(ignoreElements(), endWith(true));
}
}
/**
* Create an observable that delays the input stream until all watches have
* emitted a value. The watched values are not included in the source stream.
* The last emission from the source is output when all the watches have
* emitted at least once.
* @param watch$ the observable(s) to watch for readiness. If `watch$` is empty,
* `ready` will never emit.
* @returns An observable that emits when the source stream emits. The observable
* errors if one of its watches completes before emitting. It also errors if one
* of its watches errors.
*/
export function ready<T>(watch$: Observable<any> | Observable<any>[]) {
const watching$ = Array.isArray(watch$) ? watch$ : [watch$];
return pipe(
connect<T, Observable<T>>((source$) => {
// this subscription is safe because `source$` connects only after there
// is an external subscriber.
const source = new ReplaySubject<T>(1);
source$.subscribe(source);
// `concat` is subscribed immediately after it's returned, at which point
// `zip` blocks until all items in `watching$` are ready. If that occurs
// after `source$` is hot, then the replay subject sends the last-captured
// emission through immediately. Otherwise, `ready` waits for the next
// emission
return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe(
takeUntil(anyComplete(source)),
);
}),
);
}
export function withLatestReady<Source, Watch>(
watch$: Observable<Watch>,
): OperatorFunction<Source, [Source, Watch]> {
return connect((source$) => {
// these subscriptions are safe because `source$` connects only after there
// is an external subscriber.
const source = new ReplaySubject<Source>(1);
source$.subscribe(source);
const watch = new ReplaySubject<Watch>(1);
watch$.subscribe(watch);
// `concat` is subscribed immediately after it's returned, at which point
// `zip` blocks until all items in `watching$` are ready. If that occurs
// after `source$` is hot, then the replay subject sends the last-captured
// emission through immediately. Otherwise, `ready` waits for the next
// emission
return concat(zip(watch).pipe(first(), ignoreElements()), source).pipe(
withLatestFrom(watch),
takeUntil(anyComplete(source)),
);
});
}
/**
* Create an observable that emits the latest value of the source stream
* when `watch$` emits. If `watch$` emits before the stream emits, then
* an emission occurs as soon as a value becomes ready.
* @param watch$ the observable that triggers emissions
* @returns An observable that emits when `watch$` emits. The observable
* errors if its source stream errors. It also errors if `on` errors. It
* completes if its watch completes.
*
* @remarks This works like `audit`, but it repeats emissions when
* watch$ fires.
*/
export function on<T>(watch$: Observable<any>) {
return pipe(
connect<T, Observable<T>>((source$) => {
const source = new ReplaySubject<T>(1);
source$.subscribe(source);
return watch$
.pipe(
ready(source),
concatMap(() => source.pipe(first())),
)
.pipe(takeUntil(anyComplete(source)));
}),
);
}

View File

@ -17,3 +17,9 @@ export type ClassifiedFormat<Id, Disclosed> = {
*/ */
readonly disclosed: Jsonify<Disclosed>; readonly disclosed: Jsonify<Disclosed>;
}; };
export function isClassifiedFormat<Id, Disclosed>(
value: any,
): value is ClassifiedFormat<Id, Disclosed> {
return "id" in value && "secret" in value && "disclosed" in value;
}

View File

@ -1,4 +1,11 @@
import { Constraints, StateConstraints } from "../types"; import { BehaviorSubject, Observable } from "rxjs";
import {
Constraints,
DynamicStateConstraints,
StateConstraints,
SubjectConstraints,
} from "../types";
// The constraints type shares the properties of the state, // The constraints type shares the properties of the state,
// but never has any members // but never has any members
@ -9,16 +16,31 @@ const EMPTY_CONSTRAINTS = new Proxy<any>(Object.freeze({}), {
}); });
/** A constraint that does nothing. */ /** A constraint that does nothing. */
export class IdentityConstraint<State extends object> implements StateConstraints<State> { export class IdentityConstraint<State extends object>
implements StateConstraints<State>, DynamicStateConstraints<State>
{
/** Instantiate the identity constraint */ /** Instantiate the identity constraint */
constructor() {} constructor() {}
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS; readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
calibrate() {
return this;
}
adjust(state: State) { adjust(state: State) {
return state; return state;
} }
fix(state: State) { fix(state: State) {
return state; return state;
} }
} }
/** Emits a constraint that does not alter the input state. */
export function unconstrained$<State extends object>(): Observable<SubjectConstraints<State>> {
const identity = new IdentityConstraint<State>();
const constraints$ = new BehaviorSubject(identity);
return constraints$;
}

View File

@ -0,0 +1,53 @@
import { UserKeyDefinition, UserKeyDefinitionOptions } from "../../platform/state";
// eslint-disable-next-line -- `StateDefinition` used as a type
import type { StateDefinition } from "../../platform/state/state-definition";
import { ClassifiedFormat } from "./classified-format";
import { Classifier } from "./classifier";
/** A key for storing JavaScript objects (`{ an: "example" }`)
* in a UserStateSubject.
*/
// FIXME: promote to class: `ObjectConfiguration<State, Secret, Disclosed>`.
// The class receives `encryptor`, `prepareNext`, `adjust`, and `fix`
// From `UserStateSubject`. `UserStateSubject` keeps `classify` and
// `declassify`. The class should also include serialization
// facilities (to be used in place of JSON.parse/stringify) in it's
// options. Also allow swap between "classifier" and "classification"; the
// latter is a list of properties/arguments to the specific classifier in-use.
export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>> = {
target: "object";
key: string;
state: StateDefinition;
classifier: Classifier<State, Disclosed, Secret>;
format: "plain" | "classified";
options: UserKeyDefinitionOptions<State>;
};
export function isObjectKey(key: any): key is ObjectKey<unknown> {
return key.target === "object" && "format" in key && "classifier" in key;
}
export function toUserKeyDefinition<State, Secret, Disclosed>(
key: ObjectKey<State, Secret, Disclosed>,
) {
if (key.format === "plain") {
const plain = new UserKeyDefinition<State>(key.state, key.key, key.options);
return plain;
} else if (key.format === "classified") {
const classified = new UserKeyDefinition<ClassifiedFormat<void, Disclosed>>(
key.state,
key.key,
{
cleanupDelayMs: key.options.cleanupDelayMs,
deserializer: (jsonValue) => jsonValue as ClassifiedFormat<void, Disclosed>,
clearOn: key.options.clearOn,
},
);
return classified;
} else {
throw new Error(`unknown format: ${key.format}`);
}
}

View File

@ -1,6 +1,6 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { DynamicStateConstraints, StateConstraints } from "../types"; import { DynamicStateConstraints, StateConstraints, SubjectConstraints } from "../types";
/** A pattern for types that depend upon a dynamic set of constraints. /** A pattern for types that depend upon a dynamic set of constraints.
* *
@ -10,12 +10,12 @@ import { DynamicStateConstraints, StateConstraints } from "../types";
* last-emitted constraints. If `constraints$` completes, the consumer should * last-emitted constraints. If `constraints$` completes, the consumer should
* continue using the last-emitted constraints. * continue using the last-emitted constraints.
*/ */
export type StateConstraintsDependency<State> = { export type SubjectConstraintsDependency<State> = {
/** A stream that emits constraints when subscribed and when the /** A stream that emits constraints when subscribed and when the
* constraints change. The stream should not emit `null` or * constraints change. The stream should not emit `null` or
* `undefined`. * `undefined`.
*/ */
constraints$: Observable<StateConstraints<State> | DynamicStateConstraints<State>>; constraints$: Observable<SubjectConstraints<State>>;
}; };
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`. /** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.

View File

@ -1,15 +1,23 @@
import { Simplify } from "type-fest"; import { RequireExactlyOne, Simplify } from "type-fest";
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies"; import {
Dependencies,
SingleUserDependency,
SingleUserEncryptorDependency,
WhenDependency,
} from "../dependencies";
import { StateConstraintsDependency } from "./state-constraints-dependency"; import { SubjectConstraintsDependency } from "./state-constraints-dependency";
/** dependencies accepted by the user state subject */ /** dependencies accepted by the user state subject */
export type UserStateSubjectDependencies<State, Dependency> = Simplify< export type UserStateSubjectDependencies<State, Dependency> = Simplify<
SingleUserDependency & RequireExactlyOne<
SingleUserDependency & SingleUserEncryptorDependency,
"singleUserEncryptor$" | "singleUserId$"
> &
Partial<WhenDependency> & Partial<WhenDependency> &
Partial<Dependencies<Dependency>> & Partial<Dependencies<Dependency>> &
Partial<StateConstraintsDependency<State>> & { Partial<SubjectConstraintsDependency<State>> & {
/** Compute the next stored value. If this is not set, values /** Compute the next stored value. If this is not set, values
* provided to `next` unconditionally override state. * provided to `next` unconditionally override state.
* @param current the value stored in state * @param current the value stored in state

View File

@ -1,14 +1,50 @@
import { BehaviorSubject, of, Subject } from "rxjs"; import { BehaviorSubject, of, Subject } from "rxjs";
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec"; import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
import { UserBound } from "../dependencies";
import { PrivateClassifier } from "../private-classifier";
import { StateConstraints } from "../types"; import { StateConstraints } from "../types";
import { ClassifiedFormat } from "./classified-format";
import { ObjectKey } from "./object-key";
import { UserEncryptor } from "./user-encryptor.abstraction";
import { UserStateSubject } from "./user-state-subject"; import { UserStateSubject } from "./user-state-subject";
const SomeUser = "some user" as UserId; const SomeUser = "some user" as UserId;
type TestType = { foo: string }; type TestType = { foo: string };
const SomeKey = new UserKeyDefinition<TestType>(GENERATOR_DISK, "TestKey", {
deserializer: (d) => d as TestType,
clearOn: [],
});
const SomeObjectKey = {
target: "object",
key: "TestObjectKey",
state: GENERATOR_DISK,
classifier: new PrivateClassifier(),
format: "classified",
options: {
deserializer: (d) => d as TestType,
clearOn: ["logout"],
},
} satisfies ObjectKey<TestType>;
const SomeEncryptor: UserEncryptor = {
userId: SomeUser,
encrypt(secret) {
const tmp: any = secret;
return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any);
},
decrypt(secret) {
const tmp: any = JSON.parse(secret.encryptedString);
return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any);
},
};
function fooMaxLength(maxLength: number): StateConstraints<TestType> { function fooMaxLength(maxLength: number): StateConstraints<TestType> {
return Object.freeze({ return Object.freeze({
@ -43,7 +79,11 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(true); const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
nextValue,
when$,
});
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
subject.next({ foo: "next" }); subject.next({ foo: "next" });
@ -65,7 +105,11 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(true); const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
nextValue,
when$,
});
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
subject.next({ foo: "next" }); subject.next({ foo: "next" });
@ -79,11 +123,35 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalledTimes(1); expect(nextValue).toHaveBeenCalledTimes(1);
}); });
it("ignores repeated singleUserEncryptor$ emissions", async () => {
// this test looks for `nextValue` because a subscription isn't necessary for
// the subject to update
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const nextValue = jest.fn((_, next) => next);
const singleUserEncryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor: null });
const subject = new UserStateSubject(SomeKey, () => state, {
nextValue,
singleUserEncryptor$,
});
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
subject.next({ foo: "next" });
await awaitAsync();
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
await awaitAsync();
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
await awaitAsync();
expect(nextValue).toHaveBeenCalledTimes(1);
});
it("waits for constraints$", async () => { it("waits for constraints$", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>(); const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(3)); constraints$.next(fooMaxLength(3));
@ -91,13 +159,28 @@ describe("UserStateSubject", () => {
expect(initResult).toEqual({ foo: "ini" }); expect(initResult).toEqual({ foo: "ini" });
}); });
it("waits for singleUserEncryptor$", async () => {
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
SomeUser,
{ id: null, secret: '{"foo":"init"}', disclosed: {} },
);
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
const tracker = new ObservableTracker(subject);
singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
const [initResult] = await tracker.pauseUntilReceived(1);
expect(initResult).toEqual({ foo: "decrypt(init)" });
});
}); });
describe("next", () => { describe("next", () => {
it("emits the next value", async () => { it("emits the next value", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
let actual: TestType = null; let actual: TestType = null;
@ -114,7 +197,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" }; const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState); const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual: TestType = null; let actual: TestType = null;
subject.subscribe((value) => { subject.subscribe((value) => {
@ -132,7 +215,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => true); const shouldUpdate = jest.fn(() => true);
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
const nextVal: TestType = { foo: "next" }; const nextVal: TestType = { foo: "next" };
subject.next(nextVal); subject.next(nextVal);
@ -147,7 +230,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => true); const shouldUpdate = jest.fn(() => true);
const dependencyValue = { bar: "dependency" }; const dependencyValue = { bar: "dependency" };
const subject = new UserStateSubject(state, { const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$, singleUserId$,
shouldUpdate, shouldUpdate,
dependencies$: of(dependencyValue), dependencies$: of(dependencyValue),
@ -165,7 +248,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => true); const shouldUpdate = jest.fn(() => true);
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
let actual: TestType = null; let actual: TestType = null;
@ -183,7 +266,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => false); const shouldUpdate = jest.fn(() => false);
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
subject.next({ foo: "next" }); subject.next({ foo: "next" });
await awaitAsync(); await awaitAsync();
@ -200,7 +283,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const subject = new UserStateSubject(state, { singleUserId$, nextValue }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue });
const nextVal: TestType = { foo: "next" }; const nextVal: TestType = { foo: "next" };
subject.next(nextVal); subject.next(nextVal);
@ -215,7 +298,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const dependencyValue = { bar: "dependency" }; const dependencyValue = { bar: "dependency" };
const subject = new UserStateSubject(state, { const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$, singleUserId$,
nextValue, nextValue,
dependencies$: of(dependencyValue), dependencies$: of(dependencyValue),
@ -236,7 +319,11 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(true); const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
nextValue,
when$,
});
const nextVal: TestType = { foo: "next" }; const nextVal: TestType = { foo: "next" };
subject.next(nextVal); subject.next(nextVal);
@ -253,7 +340,11 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(false); const when$ = new BehaviorSubject(false);
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
nextValue,
when$,
});
const nextVal: TestType = { foo: "next" }; const nextVal: TestType = { foo: "next" };
subject.next(nextVal); subject.next(nextVal);
@ -265,42 +356,52 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalled(); expect(nextValue).toHaveBeenCalled();
}); });
it("waits to evaluate nextValue until singleUserId$ emits", async () => { it("waits to evaluate `UserState.update` until singleUserId$ emits", async () => {
// this test looks for `nextValue` because a subscription isn't necessary for // this test looks for `nextMock` because a subscription isn't necessary for
// the subject to update. // the subject to update.
const initialValue: TestType = { foo: "init" }; const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new Subject<UserId>(); const singleUserId$ = new Subject<UserId>();
const nextValue = jest.fn((_, next) => next); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const subject = new UserStateSubject(state, { singleUserId$, nextValue });
// precondition: subject doesn't update after `next`
const nextVal: TestType = { foo: "next" }; const nextVal: TestType = { foo: "next" };
subject.next(nextVal); subject.next(nextVal);
await awaitAsync(); await awaitAsync();
expect(nextValue).not.toHaveBeenCalled(); expect(state.nextMock).not.toHaveBeenCalled();
singleUserId$.next(SomeUser); singleUserId$.next(SomeUser);
await awaitAsync(); await awaitAsync();
expect(nextValue).toHaveBeenCalled(); expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" });
}); });
it("applies constraints$ on init", async () => { it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
const singleUserId$ = new BehaviorSubject(SomeUser); SomeUser,
const constraints$ = new BehaviorSubject(fooMaxLength(2)); { id: null, secret: '{"foo":"init"}', disclosed: null },
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); );
const tracker = new ObservableTracker(subject); const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
const [result] = await tracker.pauseUntilReceived(1); // precondition: subject doesn't update after `next`
const nextVal: TestType = { foo: "next" };
subject.next(nextVal);
await awaitAsync();
expect(state.nextMock).not.toHaveBeenCalled();
expect(result).toEqual({ foo: "in" }); singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
await awaitAsync();
const encrypted = { foo: "encrypt(next)" };
expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null });
}); });
it("applies dynamic constraints", async () => { it("applies dynamic constraints", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength); const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@ -311,24 +412,11 @@ describe("UserStateSubject", () => {
expect(actual).toEqual({ foo: "" }); expect(actual).toEqual({ foo: "" });
}); });
it("applies constraints$ on constraints$ emission", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(1));
const [, result] = await tracker.pauseUntilReceived(2);
expect(result).toEqual({ foo: "i" });
});
it("applies constraints$ on next", async () => { it("applies constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2)); const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
subject.next({ foo: "next" }); subject.next({ foo: "next" });
@ -341,7 +429,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2)); const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(3)); constraints$.next(fooMaxLength(3));
@ -355,13 +443,17 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>(); const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject); const results: any[] = [];
subject.subscribe((r) => {
results.push(r);
});
subject.next({ foo: "next" }); subject.next({ foo: "next" });
constraints$.next(fooMaxLength(3)); constraints$.next(fooMaxLength(3));
await awaitAsync();
// `init` is also waiting and is processed before `next` // `init` is also waiting and is processed before `next`
const [, nextResult] = await tracker.pauseUntilReceived(2); const [, nextResult] = results;
expect(nextResult).toEqual({ foo: "nex" }); expect(nextResult).toEqual({ foo: "nex" });
}); });
@ -370,7 +462,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3)); const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
constraints$.error({ some: "error" }); constraints$.error({ some: "error" });
@ -384,7 +476,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3)); const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
constraints$.complete(); constraints$.complete();
@ -399,7 +491,7 @@ describe("UserStateSubject", () => {
it("emits errors", async () => { it("emits errors", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const expected: TestType = { foo: "error" }; const expected: TestType = { foo: "error" };
let actual: TestType = null; let actual: TestType = null;
@ -418,7 +510,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" }; const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState); const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual: TestType = null; let actual: TestType = null;
subject.subscribe({ subject.subscribe({
@ -437,7 +529,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" }; const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState); const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let shouldNotRun = false; let shouldNotRun = false;
subject.subscribe({ subject.subscribe({
@ -457,7 +549,7 @@ describe("UserStateSubject", () => {
it("emits completes", async () => { it("emits completes", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual = false; let actual = false;
subject.subscribe({ subject.subscribe({
@ -475,7 +567,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" }; const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState); const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let shouldNotRun = false; let shouldNotRun = false;
subject.subscribe({ subject.subscribe({
@ -496,7 +588,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" }; const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState); const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let timesRun = 0; let timesRun = 0;
subject.subscribe({ subject.subscribe({
@ -513,11 +605,36 @@ describe("UserStateSubject", () => {
}); });
describe("subscribe", () => { describe("subscribe", () => {
it("applies constraints$ on init", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
const [result] = await tracker.pauseUntilReceived(1);
expect(result).toEqual({ foo: "in" });
});
it("applies constraints$ on constraints$ emission", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(1));
const [, result] = await tracker.pauseUntilReceived(2);
expect(result).toEqual({ foo: "i" });
});
it("completes when singleUserId$ completes", async () => { it("completes when singleUserId$ completes", async () => {
const initialValue: TestType = { foo: "init" }; const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual = false; let actual = false;
subject.subscribe({ subject.subscribe({
@ -531,12 +648,32 @@ describe("UserStateSubject", () => {
expect(actual).toBeTruthy(); expect(actual).toBeTruthy();
}); });
it("completes when singleUserId$ completes", async () => {
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
SomeUser,
{ id: null, secret: '{"foo":"init"}', disclosed: null },
);
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
let actual = false;
subject.subscribe({
complete: () => {
actual = true;
},
});
singleUserEncryptor$.complete();
await awaitAsync();
expect(actual).toBeTruthy();
});
it("completes when when$ completes", async () => { it("completes when when$ completes", async () => {
const initialValue: TestType = { foo: "init" }; const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const when$ = new BehaviorSubject(true); const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, when$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ });
let actual = false; let actual = false;
subject.subscribe({ subject.subscribe({
@ -557,7 +694,7 @@ describe("UserStateSubject", () => {
const initialValue: TestType = { foo: "init" }; const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const errorUserId = "error" as UserId; const errorUserId = "error" as UserId;
let error = false; let error = false;
@ -572,11 +709,32 @@ describe("UserStateSubject", () => {
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId }); expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
}); });
it("errors when singleUserEncryptor$ changes", async () => {
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
SomeUser,
{ id: null, secret: '{"foo":"init"}', disclosed: null },
);
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
const errorUserId = "error" as UserId;
let error = false;
subject.subscribe({
error: (e: unknown) => {
error = e as any;
},
});
singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor });
await awaitAsync();
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
});
it("errors when singleUserId$ errors", async () => { it("errors when singleUserId$ errors", async () => {
const initialValue: TestType = { foo: "init" }; const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const expected = { error: "description" }; const expected = { error: "description" };
let actual = false; let actual = false;
@ -591,12 +749,31 @@ describe("UserStateSubject", () => {
expect(actual).toEqual(expected); expect(actual).toEqual(expected);
}); });
it("errors when singleUserEncryptor$ errors", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeKey, () => state, { singleUserEncryptor$ });
const expected = { error: "description" };
let actual = false;
subject.subscribe({
error: (e: unknown) => {
actual = e as any;
},
});
singleUserEncryptor$.error(expected);
await awaitAsync();
expect(actual).toEqual(expected);
});
it("errors when when$ errors", async () => { it("errors when when$ errors", async () => {
const initialValue: TestType = { foo: "init" }; const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const when$ = new BehaviorSubject(true); const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, when$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ });
const expected = { error: "description" }; const expected = { error: "description" };
let actual = false; let actual = false;
@ -616,7 +793,7 @@ describe("UserStateSubject", () => {
it("returns the userId to which the subject is bound", () => { it("returns the userId to which the subject is bound", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new Subject<UserId>(); const singleUserId$ = new Subject<UserId>();
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
expect(subject.userId).toEqual(SomeUser); expect(subject.userId).toEqual(SomeUser);
}); });
@ -626,7 +803,7 @@ describe("UserStateSubject", () => {
it("emits the next value with an empty constraint", async () => { it("emits the next value with an empty constraint", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@ -642,7 +819,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" }; const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState); const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
subject.complete(); subject.complete();
@ -657,7 +834,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2)); const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(1); const expected = fooMaxLength(1);
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@ -673,7 +850,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength); const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@ -690,7 +867,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(2); const expected = fooMaxLength(2);
const constraints$ = new BehaviorSubject(expected); const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@ -705,7 +882,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2)); const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3); const expected = fooMaxLength(3);
constraints$.next(expected); constraints$.next(expected);
@ -722,7 +899,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>(); const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3); const expected = fooMaxLength(3);
@ -740,7 +917,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3); const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected); const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.error({ some: "error" }); constraints$.error({ some: "error" });
@ -756,7 +933,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser); const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3); const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected); const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.complete(); constraints$.complete();

View File

@ -5,15 +5,10 @@ import {
ReplaySubject, ReplaySubject,
filter, filter,
map, map,
Subject,
takeUntil, takeUntil,
pairwise, pairwise,
combineLatest,
distinctUntilChanged, distinctUntilChanged,
BehaviorSubject, BehaviorSubject,
race,
ignoreElements,
endWith,
startWith, startWith,
Observable, Observable,
Subscription, Subscription,
@ -22,16 +17,32 @@ import {
combineLatestWith, combineLatestWith,
catchError, catchError,
EMPTY, EMPTY,
concatMap,
OperatorFunction,
pipe,
first,
withLatestFrom,
scan,
skip,
} from "rxjs"; } from "rxjs";
import { SingleUserState } from "@bitwarden/common/platform/state"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { WithConstraints } from "../types"; import { UserBound } from "../dependencies";
import { anyComplete, ready, withLatestReady } from "../rx";
import { Constraints, SubjectConstraints, WithConstraints } from "../types";
import { IdentityConstraint } from "./identity-state-constraint"; import { ClassifiedFormat, isClassifiedFormat } from "./classified-format";
import { unconstrained$ } from "./identity-state-constraint";
import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key";
import { isDynamic } from "./state-constraints-dependency"; import { isDynamic } from "./state-constraints-dependency";
import { UserEncryptor } from "./user-encryptor.abstraction";
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"; import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
/** /**
* Adapt a state provider to an rxjs subject. * Adapt a state provider to an rxjs subject.
* *
@ -44,14 +55,20 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"
* @template State the state stored by the subject * @template State the state stored by the subject
* @template Dependencies use-specific dependencies provided by the user. * @template Dependencies use-specific dependencies provided by the user.
*/ */
export class UserStateSubject<State extends object, Dependencies = null> export class UserStateSubject<
State extends object,
Secret = State,
Disclosed = never,
Dependencies = null,
>
extends Observable<State> extends Observable<State>
implements SubjectLike<State> implements SubjectLike<State>
{ {
/** /**
* Instantiates the user state subject * Instantiates the user state subject bound to a persistent backing store
* @param state the backing store of the subject * @param key identifies the persistent backing store
* @param dependencies tailor the subject's behavior for a particular * @param getState creates a persistent backing store using a key
* @param context tailor the subject's behavior for a particular
* purpose. * purpose.
* @param dependencies.when$ blocks updates to the state subject until * @param dependencies.when$ blocks updates to the state subject until
* this becomes true. When this occurs, only the last-received update * this becomes true. When this occurs, only the last-received update
@ -61,93 +78,306 @@ export class UserStateSubject<State extends object, Dependencies = null>
* is available. * is available.
*/ */
constructor( constructor(
private state: SingleUserState<State>, private key: UserKeyDefinition<State> | ObjectKey<State, Secret, Disclosed>,
private dependencies: UserStateSubjectDependencies<State, Dependencies>, getState: (key: UserKeyDefinition<unknown>) => SingleUserState<unknown>,
private context: UserStateSubjectDependencies<State, Dependencies>,
) { ) {
super(); super();
if (isObjectKey(this.key)) {
// classification and encryption only supported with `ObjectKey`
this.objectKey = this.key;
this.stateKey = toUserKeyDefinition(this.key);
this.state = getState(this.stateKey);
} else {
// raw state access granted with `UserKeyDefinition`
this.objectKey = null;
this.stateKey = this.key as UserKeyDefinition<State>;
this.state = getState(this.stateKey);
}
// normalize dependencies // normalize dependencies
const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe( const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged());
distinctUntilChanged(),
);
const userIdAvailable$ = this.dependencies.singleUserId$.pipe(
startWith(state.userId),
pairwise(),
map(([expectedUserId, actualUserId]) => {
if (expectedUserId === actualUserId) {
return true;
} else {
throw { expectedUserId, actualUserId };
}
}),
distinctUntilChanged(),
);
const constraints$ = (
this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint<State>())
).pipe(
// FIXME: this should probably log that an error occurred
catchError(() => EMPTY),
);
// normalize input in case this `UserStateSubject` is not the only // manage dependencies through replay subjects since `UserStateSubject`
// observer of the backing store // reads them in multiple places
const input$ = combineLatest([this.input, constraints$]).pipe( const encryptor$ = new ReplaySubject<UserEncryptor>(1);
map(([input, constraints]) => { const { singleUserId$, singleUserEncryptor$ } = this.context;
const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints; this.encryptor(singleUserEncryptor$ ?? singleUserId$).subscribe(encryptor$);
const state = calibration.adjust(input);
return state;
}),
);
// when the output subscription completes, its last-emitted value const constraints$ = new ReplaySubject<SubjectConstraints<State>>(1);
// loops around to the input for finalization (this.context.constraints$ ?? unconstrained$<State>())
const finalize$ = this.pipe( .pipe(
last(), // FIXME: this should probably log that an error occurred
combineLatestWith(constraints$), catchError(() => EMPTY),
map(([output, constraints]) => { )
const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints; .subscribe(constraints$);
const state = calibration.fix(output);
return state;
}),
);
const updates$ = concat(input$, finalize$);
// observe completion const dependencies$ = new ReplaySubject<Dependencies>(1);
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true)); if (this.context.dependencies$) {
const inputComplete$ = this.input.pipe(ignoreElements(), endWith(true)); this.context.dependencies$.subscribe(dependencies$);
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true)); } else {
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$); dependencies$.next(null);
}
// wire output before input so that output normalizes the current state // wire output before input so that output normalizes the current state
// before any `next` value is processed // before any `next` value is processed
this.outputSubscription = this.state.state$ this.outputSubscription = this.state.state$
.pipe( .pipe(this.declassify(encryptor$), this.adjust(combineLatestWith(constraints$)))
combineLatestWith(constraints$),
map(([rawState, constraints]) => {
const calibration = isDynamic(constraints)
? constraints.calibrate(rawState)
: constraints;
const state = calibration.adjust(rawState);
return {
constraints: calibration.constraints,
state,
};
}),
)
.subscribe(this.output); .subscribe(this.output);
this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$])
const last$ = new ReplaySubject<State>(1);
this.output
.pipe( .pipe(
filter(([_, when]) => when), last(),
map(([state]) => state), map((o) => o.state),
takeUntil(completion$),
) )
.subscribe(last$);
// the update stream simulates the stateProvider's "shouldUpdate"
// functionality & applies policy
const updates$ = concat(
this.input.pipe(
this.when(when$),
this.adjust(withLatestReady(constraints$)),
this.prepareUpdate(this, dependencies$),
),
// when the output subscription completes, its last-emitted value
// loops around to the input for finalization
last$.pipe(this.fix(constraints$), this.prepareUpdate(last$, dependencies$)),
);
// classification/encryption bound to the input subscription's lifetime
// to ensure that `fix` has access to the encryptor key
//
// FIXME: this should probably timeout when a lock occurs
this.inputSubscription = updates$
.pipe(this.classify(encryptor$), takeUntil(anyComplete([when$, this.input, encryptor$])))
.subscribe({ .subscribe({
next: (r) => this.onNext(r), next: (state) => this.onNext(state),
error: (e: unknown) => this.onError(e), error: (e: unknown) => this.onError(e),
complete: () => this.onComplete(), complete: () => this.onComplete(),
}); });
} }
private stateKey: UserKeyDefinition<unknown>;
private objectKey: ObjectKey<State, Secret, Disclosed>;
private encryptor(
singleUserEncryptor$: Observable<UserBound<"encryptor", UserEncryptor> | UserId>,
): Observable<UserEncryptor> {
return singleUserEncryptor$.pipe(
// normalize inputs
map((maybe): UserBound<"encryptor", UserEncryptor> => {
if (typeof maybe === "object" && "encryptor" in maybe) {
return maybe;
} else if (typeof maybe === "string") {
return { encryptor: null, userId: maybe as UserId };
} else {
throw new Error(`Invalid encryptor input received for ${this.key.key}.`);
}
}),
// fail the stream if the state desyncs from the bound userId
startWith({ userId: this.state.userId, encryptor: null } as UserBound<
"encryptor",
UserEncryptor
>),
pairwise(),
map(([expected, actual]) => {
if (expected.userId === actual.userId) {
return actual;
} else {
throw {
expectedUserId: expected.userId,
actualUserId: actual.userId,
};
}
}),
// reduce emissions to when encryptor changes
distinctUntilChanged(),
map(({ encryptor }) => encryptor),
);
}
private when(when$: Observable<boolean>): OperatorFunction<State, State> {
return pipe(
combineLatestWith(when$.pipe(distinctUntilChanged())),
filter(([_, when]) => !!when),
map(([input]) => input),
);
}
private prepareUpdate(
init$: Observable<State>,
dependencies$: Observable<Dependencies>,
): OperatorFunction<Constrained<State>, State> {
return (input$) =>
concat(
// `init$` becomes the accumulator for `scan`
init$.pipe(
first(),
map((init) => [init, null] as const),
),
input$.pipe(
map((constrained) => constrained.state),
withLatestFrom(dependencies$),
),
).pipe(
// scan only emits values that can cause updates
scan(([prev], [pending, dependencies]) => {
const shouldUpdate = this.context.shouldUpdate?.(prev, pending, dependencies) ?? true;
if (shouldUpdate) {
// actual update
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
return [next, dependencies];
} else {
// false update
return [prev, null];
}
}),
// the first emission primes `scan`s aggregator
skip(1),
map(([state]) => state),
// clean up false updates
distinctUntilChanged(),
);
}
private adjust(
withConstraints: OperatorFunction<State, [State, SubjectConstraints<State>]>,
): OperatorFunction<State, Constrained<State>> {
return pipe(
// how constraints are blended with incoming emissions varies:
// * `output` needs to emit when constraints update
// * `input` needs to wait until a message flows through the pipe
withConstraints,
map(([loadedState, constraints]) => {
// bypass nulls
if (!loadedState) {
return {
constraints: {} as Constraints<State>,
state: null,
} satisfies Constrained<State>;
}
const calibration = isDynamic(constraints)
? constraints.calibrate(loadedState)
: constraints;
const adjusted = calibration.adjust(loadedState);
return {
constraints: calibration.constraints,
state: adjusted,
};
}),
);
}
private fix(
constraints$: Observable<SubjectConstraints<State>>,
): OperatorFunction<State, Constrained<State>> {
return pipe(
combineLatestWith(constraints$),
map(([loadedState, constraints]) => {
const calibration = isDynamic(constraints)
? constraints.calibrate(loadedState)
: constraints;
const fixed = calibration.fix(loadedState);
return {
constraints: calibration.constraints,
state: fixed,
};
}),
);
}
private declassify(encryptor$: Observable<UserEncryptor>): OperatorFunction<unknown, State> {
// short-circuit if they key lacks encryption support
if (!this.objectKey || this.objectKey.format === "plain") {
return (input$) => input$ as Observable<State>;
}
// if the key supports encryption, enable encryptor support
if (this.objectKey && this.objectKey.format === "classified") {
return pipe(
combineLatestWith(encryptor$),
concatMap(async ([input, encryptor]) => {
// pass through null values
if (input === null || input === undefined) {
return null;
}
// fail fast if the format is incorrect
if (!isClassifiedFormat(input)) {
throw new Error(`Cannot declassify ${this.key.key}; unknown format.`);
}
// decrypt classified data
const { secret, disclosed } = input;
const encrypted = EncString.fromJSON(secret);
const decryptedSecret = await encryptor.decrypt<Secret>(encrypted);
// assemble into proper state
const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret);
const state = this.objectKey.options.deserializer(declassified);
return state;
}),
);
}
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
}
private classify(encryptor$: Observable<UserEncryptor>): OperatorFunction<State, unknown> {
// short-circuit if they key lacks encryption support; `encryptor` is
// readied to preserve `dependencies.singleUserId$` emission contract
if (!this.objectKey || this.objectKey.format === "plain") {
return pipe(
ready(encryptor$),
map((input) => input as unknown),
);
}
// if the key supports encryption, enable encryptor support
if (this.objectKey && this.objectKey.format === "classified") {
return pipe(
withLatestReady(encryptor$),
concatMap(async ([input, encryptor]) => {
// fail fast if there's no value
if (input === null || input === undefined) {
return null;
}
// split data by classification level
const serialized = JSON.parse(JSON.stringify(input));
const classified = this.objectKey.classifier.classify(serialized);
// protect data
const encrypted = await encryptor.encrypt(classified.secret);
const secret = JSON.parse(JSON.stringify(encrypted));
// wrap result in classified format envelope for storage
const envelope = {
id: null as void,
secret,
disclosed: classified.disclosed,
} satisfies ClassifiedFormat<void, Disclosed>;
// deliberate type erasure; the type is restored during `declassify`
return envelope as unknown;
}),
);
}
// FIXME: add "encrypted" format --> key contains encryption logic
// CONSIDER: should "classified format" algorithm be embedded in subject keys...?
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
}
/** The userId to which the subject is bound. /** The userId to which the subject is bound.
*/ */
get userId() { get userId() {
@ -177,7 +407,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
// using subjects to ensure the right semantics are followed; // using subjects to ensure the right semantics are followed;
// if greater efficiency becomes desirable, consider implementing // if greater efficiency becomes desirable, consider implementing
// `SubjectLike` directly // `SubjectLike` directly
private input = new Subject<State>(); private input = new ReplaySubject<State>(1);
private state: SingleUserState<unknown>;
private readonly output = new ReplaySubject<WithConstraints<State>>(1); private readonly output = new ReplaySubject<WithConstraints<State>>(1);
/** A stream containing settings and their last-applied constraints. */ /** A stream containing settings and their last-applied constraints. */
@ -188,25 +419,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
private inputSubscription: Unsubscribable; private inputSubscription: Unsubscribable;
private outputSubscription: Unsubscribable; private outputSubscription: Unsubscribable;
private onNext(value: State) { private onNext(value: unknown) {
const nextValue = this.dependencies.nextValue ?? ((_: State, next: State) => next); this.state.update(() => value).catch((e: any) => this.onError(e));
const shouldUpdate = this.dependencies.shouldUpdate ?? ((_: State) => true);
this.state
.update(
(state, dependencies) => {
const next = nextValue(state, value, dependencies);
return next;
},
{
shouldUpdate(current, dependencies) {
const update = shouldUpdate(current, value, dependencies);
return update;
},
combineLatestWith: this.dependencies.dependencies$,
},
)
.catch((e: any) => this.onError(e));
} }
private onError(value: any) { private onError(value: any) {
@ -232,8 +446,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
private dispose() { private dispose() {
if (!this.isDisposed) { if (!this.isDisposed) {
// clean up internal subscriptions // clean up internal subscriptions
this.inputSubscription.unsubscribe(); this.inputSubscription?.unsubscribe();
this.outputSubscription.unsubscribe(); this.outputSubscription?.unsubscribe();
this.inputSubscription = null; this.inputSubscription = null;
this.outputSubscription = null; this.outputSubscription = null;

View File

@ -1,5 +1,7 @@
import { Simplify } from "type-fest"; import { Simplify } from "type-fest";
import { IntegrationId } from "./integration";
/** Constraints that are shared by all primitive field types */ /** Constraints that are shared by all primitive field types */
type PrimitiveConstraint = { type PrimitiveConstraint = {
/** `true` indicates the field is required; otherwise the field is optional */ /** `true` indicates the field is required; otherwise the field is optional */
@ -129,6 +131,8 @@ export type StateConstraints<State> = {
fix: (state: State) => State; fix: (state: State) => State;
}; };
export type SubjectConstraints<T> = StateConstraints<T> | DynamicStateConstraints<T>;
/** Options that provide contextual information about the application state /** Options that provide contextual information about the application state
* when a generator is invoked. * when a generator is invoked.
*/ */
@ -144,4 +148,7 @@ export type VaultItemRequest = {
/** Options that provide contextual information about the application state /** Options that provide contextual information about the application state
* when a generator is invoked. * when a generator is invoked.
*/ */
export type GenerationRequest = Partial<VaultItemRequest>; export type GenerationRequest = Partial<VaultItemRequest> &
Partial<{
integration: IntegrationId | null;
}>;

View File

@ -3,7 +3,7 @@
fullWidth fullWidth
class="tw-mb-4" class="tw-mb-4"
[selected]="(root$ | async).nav" [selected]="(root$ | async).nav"
(selectedChange)="onRootChanged($event)" (selectedChange)="onRootChanged({ nav: $event })"
attr.aria-label="{{ 'type' | i18n }}" attr.aria-label="{{ 'type' | i18n }}"
> >
<bit-toggle *ngFor="let option of rootOptions$ | async" [value]="option.value"> <bit-toggle *ngFor="let option of rootOptions$ | async" [value]="option.value">
@ -35,23 +35,23 @@
</bit-card> </bit-card>
<tools-password-settings <tools-password-settings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'password'" *ngIf="(showAlgorithm$ | async)?.id === 'password'"
[userId]="userId$ | async" [userId]="userId$ | async"
(onUpdated)="generate$.next()" (onUpdated)="generate$.next()"
/> />
<tools-passphrase-settings <tools-passphrase-settings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'passphrase'" *ngIf="(showAlgorithm$ | async)?.id === 'passphrase'"
[userId]="userId$ | async" [userId]="userId$ | async"
(onUpdated)="generate$.next()" (onUpdated)="generate$.next()"
/> />
<bit-section *ngIf="(category$ | async) !== 'password'"> <bit-section *ngIf="(category$ | async) !== 'password'">
<bit-section-header> <bit-section-header>
<h6 bitTypography="h6">{{ "options" | i18n }}</h6> <h2 bitTypography="h6">{{ "options" | i18n }}</h2>
</bit-section-header> </bit-section-header>
<div class="tw-mb-4"> <div class="tw-mb-4">
<bit-card> <bit-card>
<form class="box" [formGroup]="username" class="tw-container"> <form [formGroup]="username" class="box tw-container">
<bit-form-field> <bit-form-field>
<bit-label>{{ "type" | i18n }}</bit-label> <bit-label>{{ "type" | i18n }}</bit-label>
<bit-select [items]="usernameOptions$ | async" formControlName="nav"> </bit-select> <bit-select [items]="usernameOptions$ | async" formControlName="nav"> </bit-select>
@ -60,18 +60,29 @@
}}</bit-hint> }}</bit-hint>
</bit-form-field> </bit-form-field>
</form> </form>
<form *ngIf="showForwarder$ | async" [formGroup]="forwarder" class="box tw-container">
<bit-form-field>
<bit-label>{{ "service" | i18n }}</bit-label>
<bit-select [items]="forwarderOptions$ | async" formControlName="nav"> </bit-select>
</bit-form-field>
</form>
<tools-catchall-settings <tools-catchall-settings
*ngIf="(algorithm$ | async)?.id === 'catchall'" *ngIf="(showAlgorithm$ | async)?.id === 'catchall'"
[userId]="userId$ | async" [userId]="userId$ | async"
(onUpdated)="generate$.next()" (onUpdated)="generate$.next()"
/> />
<tools-forwarder-settings
*ngIf="!!(forwarderId$ | async)"
[forwarder]="forwarderId$ | async"
[userId]="this.userId$ | async"
/>
<tools-subaddress-settings <tools-subaddress-settings
*ngIf="(algorithm$ | async)?.id === 'subaddress'" *ngIf="(showAlgorithm$ | async)?.id === 'subaddress'"
[userId]="userId$ | async" [userId]="userId$ | async"
(onUpdated)="generate$.next()" (onUpdated)="generate$.next()"
/> />
<tools-username-settings <tools-username-settings
*ngIf="(algorithm$ | async)?.id === 'username'" *ngIf="(showAlgorithm$ | async)?.id === 'username'"
[userId]="userId$ | async" [userId]="userId$ | async"
(onUpdated)="generate$.next()" (onUpdated)="generate$.next()"
/> />

View File

@ -2,11 +2,12 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } fro
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { import {
BehaviorSubject, BehaviorSubject,
concat, catchError,
combineLatest,
combineLatestWith,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
map, map,
of,
ReplaySubject, ReplaySubject,
Subject, Subject,
switchMap, switchMap,
@ -16,25 +17,32 @@ import {
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { IntegrationId } from "@bitwarden/common/tools/integration";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { Option } from "@bitwarden/components/src/select/option"; import { Option } from "@bitwarden/components/src/select/option";
import { import {
AlgorithmInfo,
CredentialAlgorithm, CredentialAlgorithm,
CredentialCategory, CredentialCategory,
CredentialGeneratorInfo,
CredentialGeneratorService, CredentialGeneratorService,
GeneratedCredential, GeneratedCredential,
Generators, Generators,
getForwarderConfiguration,
isEmailAlgorithm, isEmailAlgorithm,
isForwarderIntegration,
isPasswordAlgorithm, isPasswordAlgorithm,
isSameAlgorithm,
isUsernameAlgorithm, isUsernameAlgorithm,
PasswordAlgorithm, toCredentialGeneratorConfiguration,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
/** root category that drills into username and email categories */ // constants used to identify navigation selections that are not
// generator algorithms
const IDENTIFIER = "identifier"; const IDENTIFIER = "identifier";
/** options available for the top-level navigation */ const FORWARDER = "forwarder";
type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER; const NONE_SELECTED = "none";
@Component({ @Component({
selector: "tools-credential-generator", selector: "tools-credential-generator",
@ -43,6 +51,8 @@ type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER;
export class CredentialGeneratorComponent implements OnInit, OnDestroy { export class CredentialGeneratorComponent implements OnInit, OnDestroy {
constructor( constructor(
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private toastService: ToastService,
private logService: LogService,
private i18nService: I18nService, private i18nService: I18nService,
private accountService: AccountService, private accountService: AccountService,
private zone: NgZone, private zone: NgZone,
@ -59,59 +69,25 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
@Output() @Output()
readonly onGenerated = new EventEmitter<GeneratedCredential>(); readonly onGenerated = new EventEmitter<GeneratedCredential>();
protected root$ = new BehaviorSubject<{ nav: RootNavValue }>({ protected root$ = new BehaviorSubject<{ nav: string }>({
nav: null, nav: null,
}); });
/** protected onRootChanged(value: { nav: string }) {
* Emits the copy button aria-label respective of the selected credential type
*
* FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`.
*/
protected credentialTypeCopyLabel$ = this.root$.pipe(
map(({ nav }) => {
if (nav === "password") {
return this.i18nService.t("copyPassword");
}
if (nav === "passphrase") {
return this.i18nService.t("copyPassphrase");
}
return this.i18nService.t("copyUsername");
}),
);
/**
* Emits the generate button aria-label respective of the selected credential type
*
* FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`.
*/
protected credentialTypeGenerateLabel$ = this.root$.pipe(
map(({ nav }) => {
if (nav === "password") {
return this.i18nService.t("generatePassword");
}
if (nav === "passphrase") {
return this.i18nService.t("generatePassphrase");
}
return this.i18nService.t("generateUsername");
}),
);
protected onRootChanged(nav: RootNavValue) {
// prevent subscription cycle // prevent subscription cycle
if (this.root$.value.nav !== nav) { if (this.root$.value.nav !== value.nav) {
this.zone.run(() => { this.zone.run(() => {
this.root$.next({ nav }); this.root$.next(value);
}); });
} }
} }
protected username = this.formBuilder.group({ protected username = this.formBuilder.group({
nav: [null as CredentialAlgorithm], nav: [null as string],
});
protected forwarder = this.formBuilder.group({
nav: [null as string],
}); });
async ngOnInit() { async ngOnInit() {
@ -130,16 +106,29 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
this.generatorService this.generatorService
.algorithms$(["email", "username"], { userId$: this.userId$ }) .algorithms$(["email", "username"], { userId$: this.userId$ })
.pipe( .pipe(
map((algorithms) => this.toOptions(algorithms)), map((algorithms) => {
const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id));
const usernameOptions = this.toOptions(usernames);
usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") });
const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id));
const forwarderOptions = this.toOptions(forwarders);
forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") });
return [usernameOptions, forwarderOptions] as const;
}),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(this.usernameOptions$); .subscribe(([usernames, forwarders]) => {
this.usernameOptions$.next(usernames);
this.forwarderOptions$.next(forwarders);
});
this.generatorService this.generatorService
.algorithms$("password", { userId$: this.userId$ }) .algorithms$("password", { userId$: this.userId$ })
.pipe( .pipe(
map((algorithms) => { map((algorithms) => {
const options = this.toOptions(algorithms) as Option<RootNavValue>[]; const options = this.toOptions(algorithms);
options.push({ value: IDENTIFIER, label: this.i18nService.t("username") }); options.push({ value: IDENTIFIER, label: this.i18nService.t("username") });
return options; return options;
}), }),
@ -149,7 +138,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
this.algorithm$ this.algorithm$
.pipe( .pipe(
map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), map((a) => a?.description),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe((hint) => { .subscribe((hint) => {
@ -162,7 +151,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
this.algorithm$ this.algorithm$
.pipe( .pipe(
map((a) => a.category), map((a) => a?.category),
distinctUntilChanged(), distinctUntilChanged(),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
@ -177,7 +166,22 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
// wire up the generator // wire up the generator
this.algorithm$ this.algorithm$
.pipe( .pipe(
filter((algorithm) => !!algorithm),
switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), switchMap((algorithm) => this.typeToGenerator$(algorithm.id)),
catchError((error: unknown, generator) => {
if (typeof error === "string") {
this.toastService.showToast({
message: error,
variant: "error",
title: "",
});
} else {
this.logService.error(error);
}
// continue with origin stream
return generator;
}),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe((generated) => { .subscribe((generated) => {
@ -189,35 +193,116 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
}); });
}); });
// assume the last-visible generator algorithm is the user's preferred one // normalize cascade selections; introduce subjects to allow changes
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); // from user selections and changes from preference updates to
// update the template
type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm };
const activeRoot$ = new Subject<CascadeValue>();
const activeIdentifier$ = new Subject<CascadeValue>();
const activeForwarder$ = new Subject<CascadeValue>();
this.root$ this.root$
.pipe( .pipe(
filter(({ nav }) => !!nav), map(
switchMap((root) => { (root): CascadeValue =>
if (root.nav === IDENTIFIER) { root.nav === IDENTIFIER
return concat(of(this.username.value), this.username.valueChanges); ? { nav: root.nav }
: { nav: root.nav, algorithm: JSON.parse(root.nav) },
),
takeUntil(this.destroyed),
)
.subscribe(activeRoot$);
this.username.valueChanges
.pipe(
map(
(username): CascadeValue =>
username.nav === FORWARDER
? { nav: username.nav }
: { nav: username.nav, algorithm: JSON.parse(username.nav) },
),
takeUntil(this.destroyed),
)
.subscribe(activeIdentifier$);
this.forwarder.valueChanges
.pipe(
map(
(forwarder): CascadeValue =>
forwarder.nav === NONE_SELECTED
? { nav: forwarder.nav }
: { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) },
),
takeUntil(this.destroyed),
)
.subscribe(activeForwarder$);
// update forwarder cascade visibility
combineLatest([activeRoot$, activeIdentifier$, activeForwarder$])
.pipe(
map(([root, username, forwarder]) => {
const showForwarder = !root.algorithm && !username.algorithm;
const forwarderId =
showForwarder && isForwarderIntegration(forwarder.algorithm)
? forwarder.algorithm.forwarder
: null;
return [showForwarder, forwarderId] as const;
}),
distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]),
takeUntil(this.destroyed),
)
.subscribe(([showForwarder, forwarderId]) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.showForwarder$.next(showForwarder);
this.forwarderId$.next(forwarderId);
});
});
// update active algorithm
combineLatest([activeRoot$, activeIdentifier$, activeForwarder$])
.pipe(
map(([root, username, forwarder]) => {
const selection = root.algorithm ?? username.algorithm ?? forwarder.algorithm;
if (selection) {
return this.generatorService.algorithm(selection);
} else { } else {
return of(root as { nav: PasswordAlgorithm }); return null;
} }
}), }),
filter(({ nav }) => !!nav), distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)),
takeUntil(this.destroyed),
)
.subscribe((algorithm) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.algorithm$.next(algorithm);
});
});
// assume the last-selected generator algorithm is the user's preferred one
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
this.algorithm$
.pipe(
filter((algorithm) => !!algorithm),
withLatestFrom(preferences), withLatestFrom(preferences),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([{ nav: algorithm }, preference]) => { .subscribe(([algorithm, preference]) => {
function setPreference(category: CredentialCategory) { function setPreference(category: CredentialCategory) {
const p = preference[category]; const p = preference[category];
p.algorithm = algorithm; p.algorithm = algorithm.id;
p.updated = new Date(); p.updated = new Date();
} }
// `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference` // `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference`
if (isEmailAlgorithm(algorithm)) { if (isEmailAlgorithm(algorithm.id)) {
setPreference("email"); setPreference("email");
} else if (isUsernameAlgorithm(algorithm)) { } else if (isUsernameAlgorithm(algorithm.id)) {
setPreference("username"); setPreference("username");
} else if (isPasswordAlgorithm(algorithm)) { } else if (isPasswordAlgorithm(algorithm.id)) {
setPreference("password"); setPreference("password");
} else { } else {
return; return;
@ -227,34 +312,74 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
}); });
// populate the form with the user's preferences to kick off interactivity // populate the form with the user's preferences to kick off interactivity
preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username, password }) => { preferences
// the last preference set by the user "wins"
const userNav = email.updated > username.updated ? email : username;
const rootNav: any = userNav.updated > password.updated ? IDENTIFIER : password.algorithm;
const credentialType = rootNav === IDENTIFIER ? userNav.algorithm : password.algorithm;
// update navigation; break subscription loop
this.onRootChanged(rootNav);
this.username.setValue({ nav: userNav.algorithm }, { emitEvent: false });
// load algorithm metadata
const algorithm = this.generatorService.algorithm(credentialType);
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.algorithm$.next(algorithm);
});
});
// generate on load unless the generator prohibits it
this.algorithm$
.pipe( .pipe(
distinctUntilChanged((prev, next) => prev.id === next.id), map(({ email, username, password }) => {
filter((a) => !a.onlyOnRequest), const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null;
const usernamePref = email.updated > username.updated ? email : username;
// inject drilldown flags
const forwarderNav = !forwarderPref
? NONE_SELECTED
: JSON.stringify(forwarderPref.algorithm);
const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm);
const rootNav =
usernamePref.updated > password.updated
? IDENTIFIER
: JSON.stringify(password.algorithm);
// construct cascade metadata
const cascade = {
root: {
selection: { nav: rootNav },
active: {
nav: rootNav,
algorithm: rootNav === IDENTIFIER ? null : password.algorithm,
} as CascadeValue,
},
username: {
selection: { nav: userNav },
active: {
nav: userNav,
algorithm: forwarderPref ? null : usernamePref.algorithm,
},
},
forwarder: {
selection: { nav: forwarderNav },
active: {
nav: forwarderNav,
algorithm: forwarderPref?.algorithm,
},
},
};
return cascade;
}),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(() => this.generate$.next()); .subscribe(({ root, username, forwarder }) => {
// update navigation; break subscription loop
this.onRootChanged(root.selection);
this.username.setValue(username.selection, { emitEvent: false });
this.forwarder.setValue(forwarder.selection, { emitEvent: false });
// update cascade visibility
activeRoot$.next(root.active);
activeIdentifier$.next(username.active);
activeForwarder$.next(forwarder.active);
});
// automatically regenerate when the algorithm switches if the algorithm
// allows it; otherwise set a placeholder
this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => {
this.zone.run(() => {
if (!a || a.onlyOnRequest) {
this.value$.next("-");
} else {
this.generate$.next();
}
});
});
} }
private typeToGenerator$(type: CredentialAlgorithm) { private typeToGenerator$(type: CredentialAlgorithm) {
@ -278,20 +403,61 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
case "passphrase": case "passphrase":
return this.generatorService.generate$(Generators.passphrase, dependencies); return this.generatorService.generate$(Generators.passphrase, dependencies);
default:
throw new Error(`Invalid generator type: "${type}"`);
} }
if (isForwarderIntegration(type)) {
const forwarder = getForwarderConfiguration(type.forwarder);
const configuration = toCredentialGeneratorConfiguration(forwarder);
const generator = this.generatorService.generate$(configuration, dependencies);
return generator;
}
throw new Error(`Invalid generator type: "${type}"`);
} }
/** Lists the credential types of the username algorithm box. */ /** Lists the top-level credential types supported by the component.
protected usernameOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]); * @remarks This is string-typed because angular doesn't support
* structural equality for objects, which prevents `CredentialAlgorithm`
* from being selectable within a dropdown when its value contains a
* `ForwarderIntegration`.
*/
protected rootOptions$ = new BehaviorSubject<Option<string>[]>([]);
/** Lists the top-level credential types supported by the component. */ /** Lists the credential types of the username algorithm box. */
protected rootOptions$ = new BehaviorSubject<Option<RootNavValue>[]>([]); protected usernameOptions$ = new BehaviorSubject<Option<string>[]>([]);
/** Lists the credential types of the username algorithm box. */
protected forwarderOptions$ = new BehaviorSubject<Option<string>[]>([]);
/** Tracks the currently selected forwarder. */
protected forwarderId$ = new BehaviorSubject<IntegrationId>(null);
/** Tracks forwarder control visibility */
protected showForwarder$ = new BehaviorSubject<boolean>(false);
/** tracks the currently selected credential type */ /** tracks the currently selected credential type */
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1); protected algorithm$ = new ReplaySubject<AlgorithmInfo>(1);
protected showAlgorithm$ = this.algorithm$.pipe(
combineLatestWith(this.showForwarder$),
map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)),
);
/**
* Emits the copy button aria-label respective of the selected credential type
*/
protected credentialTypeCopyLabel$ = this.algorithm$.pipe(
filter((algorithm) => !!algorithm),
map(({ copy }) => copy),
);
/**
* Emits the generate button aria-label respective of the selected credential type
*/
protected credentialTypeGenerateLabel$ = this.algorithm$.pipe(
filter((algorithm) => !!algorithm),
map(({ copy }) => copy),
);
/** Emits hint key for the currently selected credential type */ /** Emits hint key for the currently selected credential type */
protected credentialTypeHint$ = new ReplaySubject<string>(1); protected credentialTypeHint$ = new ReplaySubject<string>(1);
@ -308,10 +474,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
/** Emits when a new credential is requested */ /** Emits when a new credential is requested */
protected readonly generate$ = new Subject<void>(); protected readonly generate$ = new Subject<void>();
private toOptions(algorithms: CredentialGeneratorInfo[]) { private toOptions(algorithms: AlgorithmInfo[]) {
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({ const options: Option<string>[] = algorithms.map((algorithm) => ({
value: algorithm.id, value: JSON.stringify(algorithm.id),
label: this.i18nService.t(algorithm.nameKey), label: algorithm.name,
})); }));
return options; return options;

View File

@ -0,0 +1,16 @@
<form class="box" [formGroup]="settings" class="tw-container">
<bit-form-field *ngIf="displayDomain">
<bit-label>{{ "forwarderDomainName" | i18n }}</bit-label>
<input bitInput formControlName="domain" type="text" placeholder="example.com" />
<bit-hint>{{ "forwarderDomainNameHint" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field *ngIf="displayToken">
<bit-label>{{ "apiKey" | i18n }}</bit-label>
<input bitInput formControlName="token" type="password" />
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
<bit-form-field *ngIf="displayBaseUrl" disableMargin>
<bit-label>{{ "selfHostBaseUrl" | i18n }}</bit-label>
<input bitInput formControlName="baseUrl" type="text" />
</bit-form-field>
</form>

View File

@ -0,0 +1,195 @@
import {
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import {
BehaviorSubject,
concatMap,
map,
ReplaySubject,
skip,
Subject,
switchAll,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { IntegrationId } from "@bitwarden/common/tools/integration";
import { UserId } from "@bitwarden/common/types/guid";
import {
CredentialGeneratorConfiguration,
CredentialGeneratorService,
getForwarderConfiguration,
NoPolicy,
toCredentialGeneratorConfiguration,
} from "@bitwarden/generator-core";
import { completeOnAccountSwitch, toValidators } from "./util";
const Controls = Object.freeze({
domain: "domain",
token: "token",
baseUrl: "baseUrl",
});
/** Options group for forwarder integrations */
@Component({
selector: "tools-forwarder-settings",
templateUrl: "forwarder-settings.component.html",
})
export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component
* @param accountService queries user availability
* @param generatorService settings and policy logic
* @param formBuilder reactive form controls
*/
constructor(
private formBuilder: FormBuilder,
private generatorService: CredentialGeneratorService,
private accountService: AccountService,
) {}
/** Binds the component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/
@Input()
userId: UserId | null;
@Input({ required: true })
forwarder: IntegrationId;
/** Emits settings updates and completes if the settings become unavailable.
* @remarks this does not emit the initial settings. If you would like
* to receive live settings updates including the initial update,
* use `CredentialGeneratorService.settings$(...)` instead.
*/
@Output()
readonly onUpdated = new EventEmitter<unknown>();
/** The template's control bindings */
protected settings = this.formBuilder.group({
[Controls.domain]: [""],
[Controls.token]: [""],
[Controls.baseUrl]: [""],
});
private forwarderId$ = new ReplaySubject<IntegrationId>(1);
async ngOnInit() {
const singleUserId$ = this.singleUserId$();
const forwarder$ = new ReplaySubject<CredentialGeneratorConfiguration<any, NoPolicy>>(1);
this.forwarderId$
.pipe(
map((id) => getForwarderConfiguration(id)),
// type erasure necessary because the configuration properties are
// determined dynamically at runtime
// FIXME: this can be eliminated by unifying the forwarder settings types;
// see `ForwarderConfiguration<...>` for details.
map((forwarder) => toCredentialGeneratorConfiguration<any>(forwarder)),
takeUntil(this.destroyed$),
)
.subscribe((forwarder) => {
this.displayDomain = forwarder.request.includes("domain");
this.displayToken = forwarder.request.includes("token");
this.displayBaseUrl = forwarder.request.includes("baseUrl");
forwarder$.next(forwarder);
});
const settings$$ = forwarder$.pipe(
concatMap((forwarder) => this.generatorService.settings(forwarder, { singleUserId$ })),
);
// bind settings to the reactive form
settings$$.pipe(switchAll(), takeUntil(this.destroyed$)).subscribe((settings) => {
// skips reactive event emissions to break a subscription cycle
this.settings.patchValue(settings as any, { emitEvent: false });
});
// bind policy to the reactive form
forwarder$
.pipe(
switchMap((forwarder) => {
const constraints$ = this.generatorService
.policy$(forwarder, { userId$: singleUserId$ })
.pipe(map(({ constraints }) => [constraints, forwarder] as const));
return constraints$;
}),
takeUntil(this.destroyed$),
)
.subscribe(([constraints, forwarder]) => {
for (const name in Controls) {
const control = this.settings.get(name);
if (forwarder.request.includes(name as any)) {
control.enable({ emitEvent: false });
control.setValidators(
// the configuration's type erasure affects `toValidators` as well
toValidators(name, forwarder, constraints),
);
} else {
control.disable({ emitEvent: false });
control.clearValidators();
}
}
});
// the first emission is the current value; subsequent emissions are updates
settings$$
.pipe(
map((settings$) => settings$.pipe(skip(1))),
switchAll(),
takeUntil(this.destroyed$),
)
.subscribe(this.onUpdated);
// now that outputs are set up, connect inputs
this.settings.valueChanges
.pipe(withLatestFrom(settings$$), takeUntil(this.destroyed$))
.subscribe(([value, settings]) => {
settings.next(value);
});
}
ngOnChanges(changes: SimpleChanges): void {
this.refresh$.complete();
if ("forwarder" in changes) {
this.forwarderId$.next(this.forwarder);
}
}
protected displayDomain: boolean;
protected displayToken: boolean;
protected displayBaseUrl: boolean;
private singleUserId$() {
// FIXME: this branch should probably scan for the user and make sure
// the account is unlocked
if (this.userId) {
return new BehaviorSubject(this.userId as UserId).asObservable();
}
return this.accountService.activeAccount$.pipe(
completeOnAccountSwitch(),
takeUntil(this.destroyed$),
);
}
private readonly refresh$ = new Subject<void>();
private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void {
this.destroyed$.complete();
}
}

View File

@ -5,8 +5,11 @@ import { ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { StateProvider } from "@bitwarden/common/platform/state";
import { import {
CardComponent, CardComponent,
@ -30,6 +33,7 @@ import {
import { CatchallSettingsComponent } from "./catchall-settings.component"; import { CatchallSettingsComponent } from "./catchall-settings.component";
import { CredentialGeneratorComponent } from "./credential-generator.component"; import { CredentialGeneratorComponent } from "./credential-generator.component";
import { ForwarderSettingsComponent } from "./forwarder-settings.component";
import { PassphraseSettingsComponent } from "./passphrase-settings.component"; import { PassphraseSettingsComponent } from "./passphrase-settings.component";
import { PasswordGeneratorComponent } from "./password-generator.component"; import { PasswordGeneratorComponent } from "./password-generator.component";
import { PasswordSettingsComponent } from "./password-settings.component"; import { PasswordSettingsComponent } from "./password-settings.component";
@ -67,18 +71,27 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
safeProvider({ safeProvider({
provide: CredentialGeneratorService, provide: CredentialGeneratorService,
useClass: CredentialGeneratorService, useClass: CredentialGeneratorService,
deps: [RANDOMIZER, StateProvider, PolicyService], deps: [
RANDOMIZER,
StateProvider,
PolicyService,
ApiService,
I18nService,
EncryptService,
CryptoService,
],
}), }),
], ],
declarations: [ declarations: [
CatchallSettingsComponent, CatchallSettingsComponent,
CredentialGeneratorComponent, CredentialGeneratorComponent,
ForwarderSettingsComponent,
SubaddressSettingsComponent, SubaddressSettingsComponent,
UsernameSettingsComponent,
PasswordGeneratorComponent, PasswordGeneratorComponent,
PasswordSettingsComponent,
PassphraseSettingsComponent, PassphraseSettingsComponent,
PasswordSettingsComponent,
UsernameGeneratorComponent, UsernameGeneratorComponent,
UsernameSettingsComponent,
], ],
exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent],
}) })

View File

@ -21,9 +21,9 @@ import {
Generators, Generators,
PasswordAlgorithm, PasswordAlgorithm,
GeneratedCredential, GeneratedCredential,
CredentialGeneratorInfo,
CredentialAlgorithm, CredentialAlgorithm,
isPasswordAlgorithm, isPasswordAlgorithm,
AlgorithmInfo,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
/** Options group for passwords */ /** Options group for passwords */
@ -52,36 +52,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
/** tracks the currently selected credential type */ /** tracks the currently selected credential type */
protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>(null); protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>(null);
/**
* Emits the copy button aria-label respective of the selected credential
*
* FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`.
*/
protected credentialTypeCopyLabel$ = this.credentialType$.pipe(
map((cred) => {
if (cred === "password") {
return this.i18nService.t("copyPassword");
}
return this.i18nService.t("copyPassphrase");
}),
);
/**
* Emits the generate button aria-label respective of the selected credential
*
* FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`.
*/
protected credentialTypeGenerateLabel$ = this.credentialType$.pipe(
map((cred) => {
if (cred === "password") {
return this.i18nService.t("generatePassword");
}
return this.i18nService.t("generatePassphrase");
}),
);
/** Emits the last generated value. */ /** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>(""); protected readonly value$ = new BehaviorSubject<string>("");
@ -208,12 +178,28 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
protected passwordOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]); protected passwordOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]);
/** tracks the currently selected credential type */ /** tracks the currently selected credential type */
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1); protected algorithm$ = new ReplaySubject<AlgorithmInfo>(1);
private toOptions(algorithms: CredentialGeneratorInfo[]) { /**
* Emits the copy button aria-label respective of the selected credential type
*/
protected credentialTypeCopyLabel$ = this.algorithm$.pipe(
filter((algorithm) => !!algorithm),
map(({ copy }) => copy),
);
/**
* Emits the generate button aria-label respective of the selected credential type
*/
protected credentialTypeGenerateLabel$ = this.algorithm$.pipe(
filter((algorithm) => !!algorithm),
map(({ copy }) => copy),
);
private toOptions(algorithms: AlgorithmInfo[]) {
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({ const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
value: algorithm.id, value: algorithm.id,
label: this.i18nService.t(algorithm.nameKey), label: this.i18nService.t(algorithm.name),
})); }));
return options; return options;

View File

@ -3,45 +3,54 @@
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password> <bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
</div> </div>
<div class="tw-flex tw-items-center tw-space-x-1"> <div class="tw-flex tw-items-center tw-space-x-1">
<!-- FIXME: Move appA11yTitle translation to `AlgorithmInfo` within the `CredentialGeneratorService`. -->
<button <button
type="button" type="button"
bitIconButton="bwi-generate" bitIconButton="bwi-generate"
buttonType="main" buttonType="main"
(click)="generate$.next()" (click)="generate$.next()"
[appA11yTitle]="'generateUsername' | i18n" [appA11yTitle]="credentialTypeGenerateLabel$ | async"
></button> ></button>
<!-- FIXME: Move appA11yTitle translation to `AlgorithmInfo` within the `CredentialGeneratorService`. -->
<button <button
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
buttonType="main" buttonType="main"
showToast showToast
[appA11yTitle]="'copyUsername' | i18n" [appA11yTitle]="credentialTypeCopyLabel$ | async"
[appCopyClick]="value$ | async" [appCopyClick]="value$ | async"
></button> ></button>
</div> </div>
</bit-card> </bit-card>
<bit-section [disableMargin]="disableMargin"> <bit-section [disableMargin]="disableMargin">
<bit-section-header> <bit-section-header>
<h6 bitTypography="h6">{{ "options" | i18n }}</h6> <h2 bitTypography="h6">{{ "options" | i18n }}</h2>
</bit-section-header> </bit-section-header>
<div [ngClass]="{ 'tw-mb-4': !disableMargin }"> <div [ngClass]="{ 'tw-mb-4': !disableMargin }">
<bit-card> <bit-card>
<form class="box" [formGroup]="credential" class="tw-container"> <form class="box" [formGroup]="username" class="tw-container">
<bit-form-field> <bit-form-field>
<bit-label>{{ "type" | i18n }}</bit-label> <bit-label>{{ "type" | i18n }}</bit-label>
<bit-select [items]="typeOptions$ | async" formControlName="type"> </bit-select> <bit-select [items]="typeOptions$ | async" formControlName="nav"> </bit-select>
<bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{ <bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{
credentialTypeHint$ | async credentialTypeHint$ | async
}}</bit-hint> }}</bit-hint>
</bit-form-field> </bit-form-field>
</form> </form>
<form *ngIf="showForwarder$ | async" [formGroup]="forwarder" class="box tw-container">
<bit-form-field>
<bit-label>{{ "service" | i18n }}</bit-label>
<bit-select [items]="forwarderOptions$ | async" formControlName="nav"> </bit-select>
</bit-form-field>
</form>
<tools-catchall-settings <tools-catchall-settings
*ngIf="(algorithm$ | async)?.id === 'catchall'" *ngIf="(algorithm$ | async)?.id === 'catchall'"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"
(onUpdated)="generate$.next()" (onUpdated)="generate$.next()"
/> />
<tools-forwarder-settings
*ngIf="!!(forwarderId$ | async)"
[forwarder]="forwarderId$ | async"
[userId]="this.userId$ | async"
/>
<tools-subaddress-settings <tools-subaddress-settings
*ngIf="(algorithm$ | async)?.id === 'subaddress'" *ngIf="(algorithm$ | async)?.id === 'subaddress'"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"

View File

@ -3,6 +3,9 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } fro
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { import {
BehaviorSubject, BehaviorSubject,
catchError,
combineLatest,
combineLatestWith,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
map, map,
@ -15,18 +18,30 @@ import {
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { IntegrationId } from "@bitwarden/common/tools/integration";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { Option } from "@bitwarden/components/src/select/option"; import { Option } from "@bitwarden/components/src/select/option";
import { import {
AlgorithmInfo,
CredentialAlgorithm, CredentialAlgorithm,
CredentialGeneratorInfo,
CredentialGeneratorService, CredentialGeneratorService,
GeneratedCredential, GeneratedCredential,
Generators, Generators,
getForwarderConfiguration,
isEmailAlgorithm, isEmailAlgorithm,
isForwarderIntegration,
isSameAlgorithm,
isUsernameAlgorithm, isUsernameAlgorithm,
toCredentialGeneratorConfiguration,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
// constants used to identify navigation selections that are not
// generator algorithms
const FORWARDER = "forwarder";
const NONE_SELECTED = "none";
/** Component that generates usernames and emails */ /** Component that generates usernames and emails */
@Component({ @Component({
selector: "tools-username-generator", selector: "tools-username-generator",
@ -42,6 +57,8 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
*/ */
constructor( constructor(
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private toastService: ToastService,
private logService: LogService,
private i18nService: I18nService, private i18nService: I18nService,
private accountService: AccountService, private accountService: AccountService,
private zone: NgZone, private zone: NgZone,
@ -62,8 +79,12 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
@Input({ transform: coerceBooleanProperty }) disableMargin = false; @Input({ transform: coerceBooleanProperty }) disableMargin = false;
/** Tracks the selected generation algorithm */ /** Tracks the selected generation algorithm */
protected credential = this.formBuilder.group({ protected username = this.formBuilder.group({
type: [null as CredentialAlgorithm], nav: [null as string],
});
protected forwarder = this.formBuilder.group({
nav: [null as string],
}); });
async ngOnInit() { async ngOnInit() {
@ -82,14 +103,27 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
this.generatorService this.generatorService
.algorithms$(["email", "username"], { userId$: this.userId$ }) .algorithms$(["email", "username"], { userId$: this.userId$ })
.pipe( .pipe(
map((algorithms) => this.toOptions(algorithms)), map((algorithms) => {
const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id));
const usernameOptions = this.toOptions(usernames);
usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwarder") });
const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id));
const forwarderOptions = this.toOptions(forwarders);
forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") });
return [usernameOptions, forwarderOptions] as const;
}),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(this.typeOptions$); .subscribe(([usernames, forwarders]) => {
this.typeOptions$.next(usernames);
this.forwarderOptions$.next(forwarders);
});
this.algorithm$ this.algorithm$
.pipe( .pipe(
map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), map((a) => a?.description),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe((hint) => { .subscribe((hint) => {
@ -103,7 +137,22 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
// wire up the generator // wire up the generator
this.algorithm$ this.algorithm$
.pipe( .pipe(
filter((algorithm) => !!algorithm),
switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), switchMap((algorithm) => this.typeToGenerator$(algorithm.id)),
catchError((error: unknown, generator) => {
if (typeof error === "string") {
this.toastService.showToast({
message: error,
variant: "error",
title: "",
});
} else {
this.logService.error(error);
}
// continue with origin stream
return generator;
}),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe((generated) => { .subscribe((generated) => {
@ -115,20 +164,96 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
}); });
}); });
// normalize cascade selections; introduce subjects to allow changes
// from user selections and changes from preference updates to
// update the template
type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm };
const activeIdentifier$ = new Subject<CascadeValue>();
const activeForwarder$ = new Subject<CascadeValue>();
this.username.valueChanges
.pipe(
map(
(username): CascadeValue =>
username.nav === FORWARDER
? { nav: username.nav }
: { nav: username.nav, algorithm: JSON.parse(username.nav) },
),
takeUntil(this.destroyed),
)
.subscribe(activeIdentifier$);
this.forwarder.valueChanges
.pipe(
map(
(forwarder): CascadeValue =>
forwarder.nav === NONE_SELECTED
? { nav: forwarder.nav }
: { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) },
),
takeUntil(this.destroyed),
)
.subscribe(activeForwarder$);
// update forwarder cascade visibility
combineLatest([activeIdentifier$, activeForwarder$])
.pipe(
map(([username, forwarder]) => {
const showForwarder = !username.algorithm;
const forwarderId =
showForwarder && isForwarderIntegration(forwarder.algorithm)
? forwarder.algorithm.forwarder
: null;
return [showForwarder, forwarderId] as const;
}),
distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]),
takeUntil(this.destroyed),
)
.subscribe(([showForwarder, forwarderId]) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.showForwarder$.next(showForwarder);
this.forwarderId$.next(forwarderId);
});
});
// update active algorithm
combineLatest([activeIdentifier$, activeForwarder$])
.pipe(
map(([username, forwarder]) => {
const selection = username.algorithm ?? forwarder.algorithm;
if (selection) {
return this.generatorService.algorithm(selection);
} else {
return null;
}
}),
distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)),
takeUntil(this.destroyed),
)
.subscribe((algorithm) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.algorithm$.next(algorithm);
});
});
// assume the last-visible generator algorithm is the user's preferred one // assume the last-visible generator algorithm is the user's preferred one
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
this.credential.valueChanges this.algorithm$
.pipe( .pipe(
filter(({ type }) => !!type), filter((algorithm) => !!algorithm),
withLatestFrom(preferences), withLatestFrom(preferences),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([{ type }, preference]) => { .subscribe(([algorithm, preference]) => {
if (isEmailAlgorithm(type)) { if (isEmailAlgorithm(algorithm.id)) {
preference.email.algorithm = type; preference.email.algorithm = algorithm.id;
preference.email.updated = new Date(); preference.email.updated = new Date();
} else if (isUsernameAlgorithm(type)) { } else if (isUsernameAlgorithm(algorithm.id)) {
preference.username.algorithm = type; preference.username.algorithm = algorithm.id;
preference.username.updated = new Date(); preference.username.updated = new Date();
} else { } else {
return; return;
@ -137,31 +262,61 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
preferences.next(preference); preferences.next(preference);
}); });
// populate the form with the user's preferences to kick off interactivity preferences
preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username }) => {
// this generator supports email & username; the last preference
// set by the user "wins"
const preference = email.updated > username.updated ? email.algorithm : username.algorithm;
// break subscription loop
this.credential.setValue({ type: preference }, { emitEvent: false });
const algorithm = this.generatorService.algorithm(preference);
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.algorithm$.next(algorithm);
});
});
// generate on load unless the generator prohibits it
this.algorithm$
.pipe( .pipe(
distinctUntilChanged((prev, next) => prev.id === next.id), map(({ email, username }) => {
filter((a) => !a.onlyOnRequest), const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null;
const usernamePref = email.updated > username.updated ? email : username;
// inject drilldown flags
const forwarderNav = !forwarderPref
? NONE_SELECTED
: JSON.stringify(forwarderPref.algorithm);
const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm);
// construct cascade metadata
const cascade = {
username: {
selection: { nav: userNav },
active: {
nav: userNav,
algorithm: forwarderPref ? null : usernamePref.algorithm,
},
},
forwarder: {
selection: { nav: forwarderNav },
active: {
nav: forwarderNav,
algorithm: forwarderPref?.algorithm,
},
},
};
return cascade;
}),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(() => this.generate$.next()); .subscribe(({ username, forwarder }) => {
// update navigation; break subscription loop
this.username.setValue(username.selection, { emitEvent: false });
this.forwarder.setValue(forwarder.selection, { emitEvent: false });
// update cascade visibility
activeIdentifier$.next(username.active);
activeForwarder$.next(forwarder.active);
});
// automatically regenerate when the algorithm switches if the algorithm
// allows it; otherwise set a placeholder
this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => {
this.zone.run(() => {
if (!a || a.onlyOnRequest) {
this.value$.next("-");
} else {
this.generate$.next();
}
});
});
} }
private typeToGenerator$(type: CredentialAlgorithm) { private typeToGenerator$(type: CredentialAlgorithm) {
@ -179,17 +334,52 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
case "username": case "username":
return this.generatorService.generate$(Generators.username, dependencies); return this.generatorService.generate$(Generators.username, dependencies);
default:
throw new Error(`Invalid generator type: "${type}"`);
} }
if (isForwarderIntegration(type)) {
const forwarder = getForwarderConfiguration(type.forwarder);
const configuration = toCredentialGeneratorConfiguration(forwarder);
return this.generatorService.generate$(configuration, dependencies);
}
throw new Error(`Invalid generator type: "${type}"`);
} }
/** Lists the credential types supported by the component. */ /** Lists the credential types supported by the component. */
protected typeOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]); protected typeOptions$ = new BehaviorSubject<Option<string>[]>([]);
/** Tracks the currently selected forwarder. */
protected forwarderId$ = new BehaviorSubject<IntegrationId>(null);
/** Lists the credential types supported by the component. */
protected forwarderOptions$ = new BehaviorSubject<Option<string>[]>([]);
/** Tracks forwarder control visibility */
protected showForwarder$ = new BehaviorSubject<boolean>(false);
/** tracks the currently selected credential type */ /** tracks the currently selected credential type */
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1); protected algorithm$ = new ReplaySubject<AlgorithmInfo>(1);
protected showAlgorithm$ = this.algorithm$.pipe(
combineLatestWith(this.showForwarder$),
map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)),
);
/**
* Emits the copy button aria-label respective of the selected credential type
*/
protected credentialTypeCopyLabel$ = this.algorithm$.pipe(
filter((algorithm) => !!algorithm),
map(({ copy }) => copy),
);
/**
* Emits the generate button aria-label respective of the selected credential type
*/
protected credentialTypeGenerateLabel$ = this.algorithm$.pipe(
filter((algorithm) => !!algorithm),
map(({ copy }) => copy),
);
/** Emits hint key for the currently selected credential type */ /** Emits hint key for the currently selected credential type */
protected credentialTypeHint$ = new ReplaySubject<string>(1); protected credentialTypeHint$ = new ReplaySubject<string>(1);
@ -203,10 +393,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
/** Emits when a new credential is requested */ /** Emits when a new credential is requested */
protected readonly generate$ = new Subject<void>(); protected readonly generate$ = new Subject<void>();
private toOptions(algorithms: CredentialGeneratorInfo[]) { private toOptions(algorithms: AlgorithmInfo[]) {
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({ const options: Option<string>[] = algorithms.map((algorithm) => ({
value: algorithm.id, value: JSON.stringify(algorithm.id),
label: this.i18nService.t(algorithm.nameKey), label: this.i18nService.t(algorithm.name),
})); }));
return options; return options;

View File

@ -63,7 +63,7 @@ function getConstraint<Key extends keyof AnyConstraint>(
) { ) {
if (policy && key in policy) { if (policy && key in policy) {
return policy[key] ?? config[key]; return policy[key] ?? config[key];
} else if (key in config) { } else if (config && key in config) {
return config[key]; return config[key];
} }
} }

View File

@ -5,7 +5,7 @@ export const PasswordAlgorithms = Object.freeze(["password", "passphrase"] as co
export const UsernameAlgorithms = Object.freeze(["username"] as const); export const UsernameAlgorithms = Object.freeze(["username"] as const);
/** Types of email addresses that may be generated by the credential generator */ /** Types of email addresses that may be generated by the credential generator */
export const EmailAlgorithms = Object.freeze(["catchall", "forwarder", "subaddress"] as const); export const EmailAlgorithms = Object.freeze(["catchall", "subaddress"] as const);
/** All types of credentials that may be generated by the credential generator */ /** All types of credentials that may be generated by the credential generator */
export const CredentialAlgorithms = Object.freeze([ export const CredentialAlgorithms = Object.freeze([

View File

@ -1,9 +1,15 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
import { Randomizer } from "../abstractions"; import {
import { EmailRandomizer, PasswordRandomizer, UsernameRandomizer } from "../engine"; EmailRandomizer,
ForwarderConfiguration,
PasswordRandomizer,
UsernameRandomizer,
} from "../engine";
import { Forwarder } from "../engine/forwarder";
import { import {
DefaultPolicyEvaluator, DefaultPolicyEvaluator,
DynamicPasswordPolicyConstraints, DynamicPasswordPolicyConstraints,
@ -25,6 +31,7 @@ import {
CredentialGenerator, CredentialGenerator,
CredentialGeneratorConfiguration, CredentialGeneratorConfiguration,
EffUsernameGenerationOptions, EffUsernameGenerationOptions,
GeneratorDependencyProvider,
NoPolicy, NoPolicy,
PassphraseGenerationOptions, PassphraseGenerationOptions,
PassphraseGeneratorPolicy, PassphraseGeneratorPolicy,
@ -45,10 +52,15 @@ const PASSPHRASE = Object.freeze({
id: "passphrase", id: "passphrase",
category: "password", category: "password",
nameKey: "passphrase", nameKey: "passphrase",
generateKey: "generatePassphrase",
copyKey: "copyPassphrase",
onlyOnRequest: false, onlyOnRequest: false,
request: [],
engine: { engine: {
create(randomizer: Randomizer): CredentialGenerator<PassphraseGenerationOptions> { create(
return new PasswordRandomizer(randomizer); dependencies: GeneratorDependencyProvider,
): CredentialGenerator<PassphraseGenerationOptions> {
return new PasswordRandomizer(dependencies.randomizer);
}, },
}, },
settings: { settings: {
@ -82,10 +94,15 @@ const PASSWORD = Object.freeze({
id: "password", id: "password",
category: "password", category: "password",
nameKey: "password", nameKey: "password",
generateKey: "generatePassword",
copyKey: "copyPassword",
onlyOnRequest: false, onlyOnRequest: false,
request: [],
engine: { engine: {
create(randomizer: Randomizer): CredentialGenerator<PasswordGenerationOptions> { create(
return new PasswordRandomizer(randomizer); dependencies: GeneratorDependencyProvider,
): CredentialGenerator<PasswordGenerationOptions> {
return new PasswordRandomizer(dependencies.randomizer);
}, },
}, },
settings: { settings: {
@ -127,10 +144,15 @@ const USERNAME = Object.freeze({
id: "username", id: "username",
category: "username", category: "username",
nameKey: "randomWord", nameKey: "randomWord",
generateKey: "generateUsername",
copyKey: "copyUsername",
onlyOnRequest: false, onlyOnRequest: false,
request: [],
engine: { engine: {
create(randomizer: Randomizer): CredentialGenerator<EffUsernameGenerationOptions> { create(
return new UsernameRandomizer(randomizer); dependencies: GeneratorDependencyProvider,
): CredentialGenerator<EffUsernameGenerationOptions> {
return new UsernameRandomizer(dependencies.randomizer);
}, },
}, },
settings: { settings: {
@ -158,10 +180,15 @@ const CATCHALL = Object.freeze({
category: "email", category: "email",
nameKey: "catchallEmail", nameKey: "catchallEmail",
descriptionKey: "catchallEmailDesc", descriptionKey: "catchallEmailDesc",
generateKey: "generateEmail",
copyKey: "copyEmail",
onlyOnRequest: false, onlyOnRequest: false,
request: [],
engine: { engine: {
create(randomizer: Randomizer): CredentialGenerator<CatchallGenerationOptions> { create(
return new EmailRandomizer(randomizer); dependencies: GeneratorDependencyProvider,
): CredentialGenerator<CatchallGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
}, },
}, },
settings: { settings: {
@ -189,10 +216,15 @@ const SUBADDRESS = Object.freeze({
category: "email", category: "email",
nameKey: "plusAddressedEmail", nameKey: "plusAddressedEmail",
descriptionKey: "plusAddressedEmailDesc", descriptionKey: "plusAddressedEmailDesc",
generateKey: "generateEmail",
copyKey: "copyEmail",
onlyOnRequest: false, onlyOnRequest: false,
request: [],
engine: { engine: {
create(randomizer: Randomizer): CredentialGenerator<SubaddressGenerationOptions> { create(
return new EmailRandomizer(randomizer); dependencies: GeneratorDependencyProvider,
): CredentialGenerator<SubaddressGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
}, },
}, },
settings: { settings: {
@ -215,6 +247,48 @@ const SUBADDRESS = Object.freeze({
}, },
} satisfies CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy>); } satisfies CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy>);
export function toCredentialGeneratorConfiguration<Settings extends ApiSettings = ApiSettings>(
configuration: ForwarderConfiguration<Settings>,
) {
const forwarder = Object.freeze({
id: { forwarder: configuration.id },
category: "email",
nameKey: configuration.name,
descriptionKey: "forwardedEmailDesc",
generateKey: "generateEmail",
copyKey: "copyEmail",
onlyOnRequest: true,
request: configuration.forwarder.request,
engine: {
create(dependencies: GeneratorDependencyProvider) {
// FIXME: figure out why `configuration` fails to typecheck
const config: any = configuration;
return new Forwarder(config, dependencies.client, dependencies.i18nService);
},
},
settings: {
initial: configuration.forwarder.defaultSettings,
constraints: configuration.forwarder.settingsConstraints,
account: configuration.forwarder.settings,
},
policy: {
type: PolicyType.PasswordGenerator,
disabledValue: {},
combine(_acc: NoPolicy, _policy: Policy) {
return {};
},
createEvaluator(_policy: NoPolicy) {
return new DefaultPolicyEvaluator<Settings>();
},
toConstraints(_policy: NoPolicy) {
return new IdentityConstraint<Settings>();
},
},
} satisfies CredentialGeneratorConfiguration<Settings, NoPolicy>);
return forwarder;
}
/** Generator configurations */ /** Generator configurations */
export const Generators = Object.freeze({ export const Generators = Object.freeze({
/** Passphrase generator configuration */ /** Passphrase generator configuration */

View File

@ -1,3 +1,7 @@
import { IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
import { ForwarderConfiguration } from "../engine";
import { AddyIo } from "../integration/addy-io"; import { AddyIo } from "../integration/addy-io";
import { DuckDuckGo } from "../integration/duck-duck-go"; import { DuckDuckGo } from "../integration/duck-duck-go";
import { Fastmail } from "../integration/fastmail"; import { Fastmail } from "../integration/fastmail";
@ -5,6 +9,13 @@ import { FirefoxRelay } from "../integration/firefox-relay";
import { ForwardEmail } from "../integration/forward-email"; import { ForwardEmail } from "../integration/forward-email";
import { SimpleLogin } from "../integration/simple-login"; import { SimpleLogin } from "../integration/simple-login";
/** Fixed list of integrations available to the application
* @example
*
* // Use `toCredentialGeneratorConfiguration(id :ForwarderIntegration)`
* // to convert an integration to a generator configuration
* const generator = toCredentialGeneratorConfiguration(Integrations.AddyIo);
*/
export const Integrations = Object.freeze({ export const Integrations = Object.freeze({
AddyIo, AddyIo,
DuckDuckGo, DuckDuckGo,
@ -13,3 +24,15 @@ export const Integrations = Object.freeze({
ForwardEmail, ForwardEmail,
SimpleLogin, SimpleLogin,
} as const); } as const);
const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i]));
export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration<ApiSettings> {
const maybeForwarder = integrations.get(id);
if (maybeForwarder && "forwarder" in maybeForwarder) {
return maybeForwarder as ForwarderConfiguration<ApiSettings>;
} else {
return null;
}
}

View File

@ -1,11 +1,14 @@
import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { UserKeyDefinition } from "@bitwarden/common/platform/state";
import { IntegrationConfiguration } from "@bitwarden/common/tools/integration/integration-configuration"; import { IntegrationConfiguration } from "@bitwarden/common/tools/integration/integration-configuration";
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; import { ApiSettings, SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc";
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc/integration-request"; import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc/integration-request";
import { RpcConfiguration } from "@bitwarden/common/tools/integration/rpc/rpc-definition"; import { RpcConfiguration } from "@bitwarden/common/tools/integration/rpc/rpc-definition";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { Constraints } from "@bitwarden/common/tools/types";
import { ForwarderContext } from "./forwarder-context"; import { ForwarderContext } from "./forwarder-context";
import { EmailDomainSettings, EmailPrefixSettings } from "./settings";
/** Mixin for transmitting `getAccountId` result. */ /** Mixin for transmitting `getAccountId` result. */
export type AccountRequest = { export type AccountRequest = {
@ -24,8 +27,16 @@ export type GetAccountIdRpcDef<
Request extends IntegrationRequest = IntegrationRequest, Request extends IntegrationRequest = IntegrationRequest,
> = RpcConfiguration<Request, ForwarderContext<Settings>, string>; > = RpcConfiguration<Request, ForwarderContext<Settings>, string>;
export type ForwarderRequestFields = keyof (ApiSettings &
SelfHostedApiSettings &
EmailDomainSettings &
EmailPrefixSettings);
/** Forwarder-specific static definition */ /** Forwarder-specific static definition */
export type ForwarderConfiguration< export type ForwarderConfiguration<
// FIXME: simply forwarder settings to an object that has all
// settings properties. The runtime dynamism should be limited
// to which have values, not which have properties listed.
Settings extends ApiSettings, Settings extends ApiSettings,
Request extends IntegrationRequest = IntegrationRequest, Request extends IntegrationRequest = IntegrationRequest,
> = IntegrationConfiguration & { > = IntegrationConfiguration & {
@ -34,12 +45,30 @@ export type ForwarderConfiguration<
/** default value of all fields */ /** default value of all fields */
defaultSettings: Partial<Settings>; defaultSettings: Partial<Settings>;
/** forwarder settings storage */ settingsConstraints: Constraints<Settings>;
/** Well-known fields to display on the forwarder screen */
request: readonly ForwarderRequestFields[];
/** forwarder settings storage
* @deprecated use local.settings instead
*/
settings: UserKeyDefinition<Settings>; settings: UserKeyDefinition<Settings>;
/** forwarder settings import buffer; `undefined` when there is no buffer. */ /** forwarder settings import buffer; `undefined` when there is no buffer.
* @deprecated use local.settings import
*/
importBuffer?: BufferedKeyDefinition<Settings>; importBuffer?: BufferedKeyDefinition<Settings>;
/** locally stored data; forwarder-partitioned */
local: {
/** integration settings storage */
settings: ObjectKey<Settings>;
/** plaintext import buffer - used during data migrations */
import?: ObjectKey<Settings, Record<string, never>, Settings>;
};
/** createForwardingEmail RPC definition */ /** createForwardingEmail RPC definition */
createForwardingEmail: CreateForwardingEmailRpcDef<Settings, Request>; createForwardingEmail: CreateForwardingEmailRpcDef<Settings, Request>;

View File

@ -0,0 +1,75 @@
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
ApiSettings,
IntegrationRequest,
RestClient,
} from "@bitwarden/common/tools/integration/rpc";
import { GenerationRequest } from "@bitwarden/common/tools/types";
import { CredentialGenerator, GeneratedCredential } from "../types";
import { AccountRequest, ForwarderConfiguration } from "./forwarder-configuration";
import { ForwarderContext } from "./forwarder-context";
import { CreateForwardingAddressRpc, GetAccountIdRpc } from "./rpc";
/** Generation algorithms that query an email forwarding service to
* create anonymized email addresses.
*/
export class Forwarder implements CredentialGenerator<ApiSettings> {
/** Instantiates the email forwarder engine
* @param configuration The forwarder to query
* @param client requests data from the forwarding service
* @param i18nService localizes messages sent to the forwarding service
* and user-addressable errors
*/
constructor(
private configuration: ForwarderConfiguration<ApiSettings>,
private client: RestClient,
private i18nService: I18nService,
) {}
async generate(request: GenerationRequest, settings: ApiSettings) {
const requestOptions: IntegrationRequest & AccountRequest = { website: request.website };
const getAccount = await this.getAccountId(this.configuration, settings);
if (getAccount) {
requestOptions.accountId = await this.client.fetchJson(getAccount, requestOptions);
}
const create = this.createForwardingAddress(this.configuration, settings);
const result = await this.client.fetchJson(create, requestOptions);
const id = { forwarder: this.configuration.id };
return new GeneratedCredential(result, id, Date.now());
}
private createContext<Settings>(
configuration: ForwarderConfiguration<Settings>,
settings: Settings,
) {
return new ForwarderContext(configuration, settings, this.i18nService);
}
private createForwardingAddress<Settings extends ApiSettings>(
configuration: ForwarderConfiguration<Settings>,
settings: Settings,
) {
const context = this.createContext(configuration, settings);
const rpc = new CreateForwardingAddressRpc<Settings>(configuration, context);
return rpc;
}
private getAccountId<Settings extends ApiSettings>(
configuration: ForwarderConfiguration<Settings>,
settings: Settings,
) {
if (!configuration.forwarder.getAccountId) {
return null;
}
const context = this.createContext(configuration, settings);
const rpc = new GetAccountIdRpc<Settings>(configuration, context);
return rpc;
}
}

View File

@ -1,11 +1,18 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import {
GENERATOR_DISK,
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { import {
ApiSettings, ApiSettings,
IntegrationRequest, IntegrationRequest,
SelfHostedApiSettings, SelfHostedApiSettings,
} from "@bitwarden/common/tools/integration/rpc"; } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine"; import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
@ -44,6 +51,40 @@ const createForwardingEmail = Object.freeze({
// forwarder configuration // forwarder configuration
const forwarder = Object.freeze({ const forwarder = Object.freeze({
defaultSettings, defaultSettings,
createForwardingEmail,
request: ["token", "baseUrl", "domain"],
settingsConstraints: {
token: { required: true },
domain: { required: true },
baseUrl: {},
},
local: {
settings: {
// FIXME: integration should issue keys at runtime
// based on integrationId & extension metadata
// e.g. key: "forwarder.AddyIo.local.settings",
key: "addyIoForwarder",
target: "object",
format: "classified",
classifier: new PrivateClassifier<AddyIoSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<AddyIoSettings>,
import: {
key: "forwarder.AddyIo.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<AddyIoSettings>(["token", "baseUrl", "domain"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<AddyIoSettings, Record<string, never>, AddyIoSettings>,
},
settings: new UserKeyDefinition<AddyIoSettings>(GENERATOR_DISK, "addyIoForwarder", { settings: new UserKeyDefinition<AddyIoSettings>(GENERATOR_DISK, "addyIoForwarder", {
deserializer: (value) => value, deserializer: (value) => value,
clearOn: [], clearOn: [],
@ -52,7 +93,6 @@ const forwarder = Object.freeze({
deserializer: (value) => value, deserializer: (value) => value,
clearOn: ["logout"], clearOn: ["logout"],
}), }),
createForwardingEmail,
} as const); } as const);
export const AddyIo = Object.freeze({ export const AddyIo = Object.freeze({

View File

@ -1,7 +1,14 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import {
GENERATOR_DISK,
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { ForwarderConfiguration, ForwarderContext } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
@ -36,6 +43,38 @@ const createForwardingEmail = Object.freeze({
// forwarder configuration // forwarder configuration
const forwarder = Object.freeze({ const forwarder = Object.freeze({
defaultSettings, defaultSettings,
createForwardingEmail,
request: ["token"],
settingsConstraints: {
token: { required: true },
},
local: {
settings: {
// FIXME: integration should issue keys at runtime
// based on integrationId & extension metadata
// e.g. key: "forwarder.DuckDuckGo.local.settings",
key: "duckDuckGoForwarder",
target: "object",
format: "classified",
classifier: new PrivateClassifier<DuckDuckGoSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<DuckDuckGoSettings>,
import: {
key: "forwarder.DuckDuckGo.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<DuckDuckGoSettings>(["token"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<DuckDuckGoSettings, Record<string, never>, DuckDuckGoSettings>,
},
settings: new UserKeyDefinition<DuckDuckGoSettings>(GENERATOR_DISK, "duckDuckGoForwarder", { settings: new UserKeyDefinition<DuckDuckGoSettings>(GENERATOR_DISK, "duckDuckGoForwarder", {
deserializer: (value) => value, deserializer: (value) => value,
clearOn: [], clearOn: [],
@ -44,7 +83,6 @@ const forwarder = Object.freeze({
deserializer: (value) => value, deserializer: (value) => value,
clearOn: ["logout"], clearOn: ["logout"],
}), }),
createForwardingEmail,
} as const); } as const);
// integration-wide configuration // integration-wide configuration

View File

@ -1,7 +1,14 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import {
GENERATOR_DISK,
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { import {
ForwarderConfiguration, ForwarderConfiguration,
@ -101,6 +108,41 @@ const createForwardingEmail = Object.freeze({
// forwarder configuration // forwarder configuration
const forwarder = Object.freeze({ const forwarder = Object.freeze({
defaultSettings, defaultSettings,
createForwardingEmail,
getAccountId,
request: ["token"],
settingsConstraints: {
token: { required: true },
domain: { required: true },
prefix: {},
},
local: {
settings: {
// FIXME: integration should issue keys at runtime
// based on integrationId & extension metadata
// e.g. key: "forwarder.Fastmail.local.settings"
key: "fastmailForwarder",
target: "object",
format: "classified",
classifier: new PrivateClassifier<FastmailSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<FastmailSettings>,
import: {
key: "forwarder.Fastmail.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<FastmailSettings>(["token"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<FastmailSettings, Record<string, never>, FastmailSettings>,
},
settings: new UserKeyDefinition<FastmailSettings>(GENERATOR_DISK, "fastmailForwarder", { settings: new UserKeyDefinition<FastmailSettings>(GENERATOR_DISK, "fastmailForwarder", {
deserializer: (value) => value, deserializer: (value) => value,
clearOn: [], clearOn: [],
@ -109,8 +151,6 @@ const forwarder = Object.freeze({
deserializer: (value) => value, deserializer: (value) => value,
clearOn: ["logout"], clearOn: ["logout"],
}), }),
createForwardingEmail,
getAccountId,
} as const); } as const);
// integration-wide configuration // integration-wide configuration

View File

@ -1,7 +1,14 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import {
GENERATOR_DISK,
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { ForwarderConfiguration, ForwarderContext } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
@ -40,6 +47,38 @@ const createForwardingEmail = Object.freeze({
// forwarder configuration // forwarder configuration
const forwarder = Object.freeze({ const forwarder = Object.freeze({
defaultSettings, defaultSettings,
createForwardingEmail,
request: ["token"],
settingsConstraints: {
token: { required: true },
},
local: {
settings: {
// FIXME: integration should issue keys at runtime
// based on integrationId & extension metadata
// e.g. key: "forwarder.Firefox.local.settings",
key: "firefoxRelayForwarder",
target: "object",
format: "classified",
classifier: new PrivateClassifier<FirefoxRelaySettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<FirefoxRelaySettings>,
import: {
key: "forwarder.Firefox.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<FirefoxRelaySettings>(["token"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<FirefoxRelaySettings, Record<string, never>, FirefoxRelaySettings>,
},
settings: new UserKeyDefinition<FirefoxRelaySettings>(GENERATOR_DISK, "firefoxRelayForwarder", { settings: new UserKeyDefinition<FirefoxRelaySettings>(GENERATOR_DISK, "firefoxRelayForwarder", {
deserializer: (value) => value, deserializer: (value) => value,
clearOn: [], clearOn: [],
@ -52,7 +91,6 @@ const forwarder = Object.freeze({
clearOn: ["logout"], clearOn: ["logout"],
}, },
), ),
createForwardingEmail,
} as const); } as const);
// integration-wide configuration // integration-wide configuration

View File

@ -1,7 +1,14 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import {
GENERATOR_DISK,
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine"; import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
@ -43,6 +50,38 @@ const createForwardingEmail = Object.freeze({
// forwarder configuration // forwarder configuration
const forwarder = Object.freeze({ const forwarder = Object.freeze({
defaultSettings, defaultSettings,
request: ["token", "domain"],
settingsConstraints: {
token: { required: true },
domain: { required: true },
},
local: {
settings: {
// FIXME: integration should issue keys at runtime
// based on integrationId & extension metadata
// e.g. key: "forwarder.ForwardEmail.local.settings",
key: "forwardEmailForwarder",
target: "object",
format: "classified",
classifier: new PrivateClassifier<ForwardEmailSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<ForwardEmailSettings>,
import: {
key: "forwarder.ForwardEmail.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<ForwardEmailSettings>(["token", "domain"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<ForwardEmailSettings, Record<string, never>, ForwardEmailSettings>,
},
settings: new UserKeyDefinition<ForwardEmailSettings>(GENERATOR_DISK, "forwardEmailForwarder", { settings: new UserKeyDefinition<ForwardEmailSettings>(GENERATOR_DISK, "forwardEmailForwarder", {
deserializer: (value) => value, deserializer: (value) => value,
clearOn: [], clearOn: [],

View File

@ -1,11 +1,18 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import {
GENERATOR_DISK,
GENERATOR_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { import {
ApiSettings, ApiSettings,
IntegrationRequest, IntegrationRequest,
SelfHostedApiSettings, SelfHostedApiSettings,
} from "@bitwarden/common/tools/integration/rpc"; } from "@bitwarden/common/tools/integration/rpc";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { ForwarderConfiguration, ForwarderContext } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
@ -45,6 +52,38 @@ const createForwardingEmail = Object.freeze({
// forwarder configuration // forwarder configuration
const forwarder = Object.freeze({ const forwarder = Object.freeze({
defaultSettings, defaultSettings,
createForwardingEmail,
request: ["token", "baseUrl"],
settingsConstraints: {
token: { required: true },
},
local: {
settings: {
// FIXME: integration should issue keys at runtime
// based on integrationId & extension metadata
// e.g. key: "forwarder.SimpleLogin.local.settings",
key: "simpleLoginForwarder",
target: "object",
format: "classified",
classifier: new PrivateClassifier<SimpleLoginSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<SimpleLoginSettings>,
import: {
key: "forwarder.SimpleLogin.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<SimpleLoginSettings>(["token", "baseUrl"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<SimpleLoginSettings, Record<string, never>, SimpleLoginSettings>,
},
settings: new UserKeyDefinition<SimpleLoginSettings>(GENERATOR_DISK, "simpleLoginForwarder", { settings: new UserKeyDefinition<SimpleLoginSettings>(GENERATOR_DISK, "simpleLoginForwarder", {
deserializer: (value) => value, deserializer: (value) => value,
clearOn: [], clearOn: [],
@ -57,7 +96,6 @@ const forwarder = Object.freeze({
clearOn: ["logout"], clearOn: ["logout"],
}, },
), ),
createForwardingEmail,
} as const); } as const);
// integration-wide configuration // integration-wide configuration

View File

@ -1,352 +0,0 @@
import { EmptyError, Subject, tap } from "rxjs";
import { anyComplete, on, ready } from "./rx";
describe("anyComplete", () => {
it("emits true when its input completes", () => {
const input$ = new Subject<void>();
const emissions: boolean[] = [];
anyComplete(input$).subscribe((e) => emissions.push(e));
input$.complete();
expect(emissions).toEqual([true]);
});
it("completes when its input is already complete", () => {
const input = new Subject<void>();
input.complete();
let completed = false;
anyComplete(input).subscribe({ complete: () => (completed = true) });
expect(completed).toBe(true);
});
it("completes when any input completes", () => {
const input$ = new Subject<void>();
const completing$ = new Subject<void>();
let completed = false;
anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) });
completing$.complete();
expect(completed).toBe(true);
});
it("ignores emissions", () => {
const input$ = new Subject<number>();
const emissions: boolean[] = [];
anyComplete(input$).subscribe((e) => emissions.push(e));
input$.next(1);
input$.next(2);
input$.complete();
expect(emissions).toEqual([true]);
});
it("forwards errors", () => {
const input$ = new Subject<void>();
const expected = { some: "error" };
let error = null;
anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) });
input$.error(expected);
expect(error).toEqual(expected);
});
});
describe("ready", () => {
it("connects when subscribed", () => {
const watch$ = new Subject<void>();
let connected = false;
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
// precondition: ready$ should be cold
const ready$ = source$.pipe(ready(watch$));
expect(connected).toBe(false);
ready$.subscribe();
expect(connected).toBe(true);
});
it("suppresses source emissions until its watch emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
// precondition: no emissions
source$.next(1);
expect(results).toEqual([]);
watch$.next();
expect(results).toEqual([1]);
});
it("suppresses source emissions until all watches emit", () => {
const watchA$ = new Subject<void>();
const watchB$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready([watchA$, watchB$]));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
// preconditions: no emissions
source$.next(1);
expect(results).toEqual([]);
watchA$.next();
expect(results).toEqual([]);
watchB$.next();
expect(results).toEqual([1]);
});
it("emits the last source emission when its watch emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
// precondition: no emissions
source$.next(1);
expect(results).toEqual([]);
source$.next(2);
watch$.next();
expect(results).toEqual([2]);
});
it("emits all source emissions after its watch emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
watch$.next();
source$.next(1);
source$.next(2);
expect(results).toEqual([1, 2]);
});
it("ignores repeated watch emissions", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const results: number[] = [];
ready$.subscribe((n) => results.push(n));
watch$.next();
source$.next(1);
watch$.next();
source$.next(2);
watch$.next();
expect(results).toEqual([1, 2]);
});
it("completes when its source completes", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
let completed = false;
ready$.subscribe({ complete: () => (completed = true) });
source$.complete();
expect(completed).toBeTruthy();
});
it("errors when its source errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const expected = { some: "error" };
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
source$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
const expected = { some: "error" };
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
watch$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch completes before emitting", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const ready$ = source$.pipe(ready(watch$));
let error = null;
ready$.subscribe({ error: (e: unknown) => (error = e) });
watch$.complete();
expect(error).toBeInstanceOf(EmptyError);
});
});
describe("on", () => {
it("connects when subscribed", () => {
const watch$ = new Subject<void>();
let connected = false;
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
// precondition: on$ should be cold
const on$ = source$.pipe(on(watch$));
expect(connected).toBeFalsy();
on$.subscribe();
expect(connected).toBeTruthy();
});
it("suppresses source emissions until `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
// precondition: on$ should be cold
source$.next(1);
expect(results).toEqual([]);
watch$.next();
expect(results).toEqual([1]);
});
it("repeats source emissions when `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
source$.next(1);
watch$.next();
watch$.next();
expect(results).toEqual([1, 1]);
});
it("updates source emissions when `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
source$.next(1);
watch$.next();
source$.next(2);
watch$.next();
expect(results).toEqual([1, 2]);
});
it("emits a value when `on` emits before the source is ready", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
watch$.next();
source$.next(1);
expect(results).toEqual([1]);
});
it("ignores repeated `on` emissions before the source is ready", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
watch$.next();
watch$.next();
source$.next(1);
expect(results).toEqual([1]);
});
it("emits only the latest source emission when `on` emits", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
source$.next(1);
watch$.next();
source$.next(2);
source$.next(3);
watch$.next();
expect(results).toEqual([1, 3]);
});
it("completes when its source completes", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
let complete: boolean = false;
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
source$.complete();
expect(complete).toBeTruthy();
});
it("completes when its watch completes", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
let complete: boolean = false;
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
watch$.complete();
expect(complete).toBeTruthy();
});
it("errors when its source errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const expected = { some: "error" };
let error = null;
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
source$.error(expected);
expect(error).toEqual(expected);
});
it("errors when its watch errors", () => {
const watch$ = new Subject<void>();
const source$ = new Subject<number>();
const expected = { some: "error" };
let error = null;
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
watch$.error(expected);
expect(error).toEqual(expected);
});
});

View File

@ -1,18 +1,4 @@
import { import { map, pipe } from "rxjs";
concat,
concatMap,
connect,
endWith,
first,
ignoreElements,
map,
Observable,
pipe,
race,
ReplaySubject,
takeUntil,
zip,
} from "rxjs";
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx"; import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
@ -51,86 +37,3 @@ export function newDefaultEvaluator<Target>() {
return pipe(map((_) => new DefaultPolicyEvaluator<Target>())); return pipe(map((_) => new DefaultPolicyEvaluator<Target>()));
}; };
} }
/** Create an observable that, once subscribed, emits `true` then completes when
* any input completes. If an input is already complete when the subscription
* occurs, it emits immediately.
* @param watch$ the observable(s) to watch for completion; if an array is passed,
* null and undefined members are ignored. If `watch$` is empty, `anyComplete`
* will never complete.
* @returns An observable that emits `true` when any of its inputs
* complete. The observable forwards the first error from its input.
* @remarks This method is particularly useful in combination with `takeUntil` and
* streams that are not guaranteed to complete on their own.
*/
export function anyComplete(watch$: Observable<any> | Observable<any>[]): Observable<any> {
if (Array.isArray(watch$)) {
const completes$ = watch$
.filter((w$) => !!w$)
.map((w$) => w$.pipe(ignoreElements(), endWith(true)));
const completed$ = race(completes$);
return completed$;
} else {
return watch$.pipe(ignoreElements(), endWith(true));
}
}
/**
* Create an observable that delays the input stream until all watches have
* emitted a value. The watched values are not included in the source stream.
* The last emission from the source is output when all the watches have
* emitted at least once.
* @param watch$ the observable(s) to watch for readiness. If `watch$` is empty,
* `ready` will never emit.
* @returns An observable that emits when the source stream emits. The observable
* errors if one of its watches completes before emitting. It also errors if one
* of its watches errors.
*/
export function ready<T>(watch$: Observable<any> | Observable<any>[]) {
const watching$ = Array.isArray(watch$) ? watch$ : [watch$];
return pipe(
connect<T, Observable<T>>((source$) => {
// this subscription is safe because `source$` connects only after there
// is an external subscriber.
const source = new ReplaySubject<T>(1);
source$.subscribe(source);
// `concat` is subscribed immediately after it's returned, at which point
// `zip` blocks until all items in `watching$` are ready. If that occurs
// after `source$` is hot, then the replay subject sends the last-captured
// emission through immediately. Otherwise, `ready` waits for the next
// emission
return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe(
takeUntil(anyComplete(source)),
);
}),
);
}
/**
* Create an observable that emits the latest value of the source stream
* when `watch$` emits. If `watch$` emits before the stream emits, then
* an emission occurs as soon as a value becomes ready.
* @param watch$ the observable that triggers emissions
* @returns An observable that emits when `watch$` emits. The observable
* errors if its source stream errors. It also errors if `on` errors. It
* completes if its watch completes.
*
* @remarks This works like `audit`, but it repeats emissions when
* watch$ fires.
*/
export function on<T>(watch$: Observable<any>) {
return pipe(
connect<T, Observable<T>>((source$) => {
const source = new ReplaySubject<T>(1);
source$.subscribe(source);
return watch$
.pipe(
ready(source),
concatMap(() => source.pipe(first())),
)
.pipe(takeUntil(anyComplete(source)));
}),
);
}

View File

@ -1,12 +1,17 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs"; import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { StateConstraints } from "@bitwarden/common/tools/types"; import { StateConstraints } from "@bitwarden/common/tools/types";
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { import {
FakeStateProvider, FakeStateProvider,
@ -67,15 +72,20 @@ const SomeTime = new Date(1);
const SomeAlgorithm = "passphrase"; const SomeAlgorithm = "passphrase";
const SomeCategory = "password"; const SomeCategory = "password";
const SomeNameKey = "passphraseKey"; const SomeNameKey = "passphraseKey";
const SomeGenerateKey = "generateKey";
const SomeCopyKey = "copyKey";
// fake the configuration // fake the configuration
const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePolicy> = { const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePolicy> = {
id: SomeAlgorithm, id: SomeAlgorithm,
category: SomeCategory, category: SomeCategory,
nameKey: SomeNameKey, nameKey: SomeNameKey,
generateKey: SomeGenerateKey,
copyKey: SomeCopyKey,
onlyOnRequest: false, onlyOnRequest: false,
request: [],
engine: { engine: {
create: (randomizer) => { create: (_randomizer) => {
return { return {
generate: (request, settings) => { generate: (request, settings) => {
const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo;
@ -159,10 +169,22 @@ const stateProvider = new FakeStateProvider(accountService);
// fake randomizer // fake randomizer
const randomizer = mock<Randomizer>(); const randomizer = mock<Randomizer>();
const i18nService = mock<I18nService>();
const apiService = mock<ApiService>();
const encryptService = mock<EncryptService>();
const cryptoService = mock<CryptoService>();
describe("CredentialGeneratorService", () => { describe("CredentialGeneratorService", () => {
beforeEach(async () => { beforeEach(async () => {
await accountService.switchAccount(SomeUser); await accountService.switchAccount(SomeUser);
policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable()); policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable());
i18nService.t.mockImplementation((key) => key);
apiService.fetch.mockImplementation(() => Promise.resolve(mock<Response>()));
const keyAvailable = new BehaviorSubject({} as UserKey);
cryptoService.userKey$.mockReturnValue(keyAvailable);
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -170,7 +192,15 @@ describe("CredentialGeneratorService", () => {
it("emits a generation for the active user when subscribed", async () => { it("emits a generation for the active user when subscribed", async () => {
const settings = { foo: "value" }; const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser); await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
const result = await generated.expectEmission(); const result = await generated.expectEmission();
@ -183,7 +213,15 @@ describe("CredentialGeneratorService", () => {
const anotherSettings = { foo: "another value" }; const anotherSettings = { foo: "another value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
await accountService.switchAccount(AnotherUser); await accountService.switchAccount(AnotherUser);
@ -200,7 +238,15 @@ describe("CredentialGeneratorService", () => {
const someSettings = { foo: "some value" }; const someSettings = { foo: "some value" };
const anotherSettings = { foo: "another value" }; const anotherSettings = { foo: "another value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser); await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser);
@ -220,7 +266,15 @@ describe("CredentialGeneratorService", () => {
it("includes `website$`'s last emitted value", async () => { it("includes `website$`'s last emitted value", async () => {
const settings = { foo: "value" }; const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser); await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const website$ = new BehaviorSubject("some website"); const website$ = new BehaviorSubject("some website");
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ }));
@ -233,7 +287,15 @@ describe("CredentialGeneratorService", () => {
it("errors when `website$` errors", async () => { it("errors when `website$` errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const website$ = new BehaviorSubject("some website"); const website$ = new BehaviorSubject("some website");
let error = null; let error = null;
@ -250,7 +312,15 @@ describe("CredentialGeneratorService", () => {
it("completes when `website$` completes", async () => { it("completes when `website$` completes", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const website$ = new BehaviorSubject("some website"); const website$ = new BehaviorSubject("some website");
let completed = false; let completed = false;
@ -268,7 +338,15 @@ describe("CredentialGeneratorService", () => {
it("emits a generation for a specific user when `user$` supplied", async () => { it("emits a generation for a specific user when `user$` supplied", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
@ -280,7 +358,15 @@ describe("CredentialGeneratorService", () => {
it("emits a generation for a specific user when `user$` emits", async () => { it("emits a generation for a specific user when `user$` emits", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.pipe(filter((u) => !!u)); const userId$ = userId.pipe(filter((u) => !!u));
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
@ -296,7 +382,15 @@ describe("CredentialGeneratorService", () => {
it("errors when `user$` errors", async () => { it("errors when `user$` errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId$ = new BehaviorSubject(SomeUser); const userId$ = new BehaviorSubject(SomeUser);
let error = null; let error = null;
@ -313,7 +407,15 @@ describe("CredentialGeneratorService", () => {
it("completes when `user$` completes", async () => { it("completes when `user$` completes", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId$ = new BehaviorSubject(SomeUser); const userId$ = new BehaviorSubject(SomeUser);
let completed = false; let completed = false;
@ -331,7 +433,15 @@ describe("CredentialGeneratorService", () => {
it("emits a generation only when `on$` emits", async () => { it("emits a generation only when `on$` emits", async () => {
// This test breaks from arrange/act/assert because it is testing causality // This test breaks from arrange/act/assert because it is testing causality
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const on$ = new Subject<void>(); const on$ = new Subject<void>();
const results: any[] = []; const results: any[] = [];
@ -365,7 +475,15 @@ describe("CredentialGeneratorService", () => {
it("errors when `on$` errors", async () => { it("errors when `on$` errors", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const on$ = new Subject<void>(); const on$ = new Subject<void>();
let error: any = null; let error: any = null;
@ -383,7 +501,15 @@ describe("CredentialGeneratorService", () => {
it("completes when `on$` completes", async () => { it("completes when `on$` completes", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const on$ = new Subject<void>(); const on$ = new Subject<void>();
let complete = false; let complete = false;
@ -406,54 +532,86 @@ describe("CredentialGeneratorService", () => {
describe("algorithms", () => { describe("algorithms", () => {
it("outputs password generation metadata", () => { it("outputs password generation metadata", () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = generator.algorithms("password"); const result = generator.algorithms("password");
expect(result).toContain(Generators.password); expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
expect(result).toContain(Generators.passphrase); expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy();
// this test shouldn't contain entries outside of the current category // this test shouldn't contain entries outside of the current category
expect(result).not.toContain(Generators.username); expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy();
expect(result).not.toContain(Generators.catchall); expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy();
}); });
it("outputs username generation metadata", () => { it("outputs username generation metadata", () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = generator.algorithms("username"); const result = generator.algorithms("username");
expect(result).toContain(Generators.username); expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
// this test shouldn't contain entries outside of the current category // this test shouldn't contain entries outside of the current category
expect(result).not.toContain(Generators.catchall); expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy();
expect(result).not.toContain(Generators.password); expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy();
}); });
it("outputs email generation metadata", () => { it("outputs email generation metadata", () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = generator.algorithms("email"); const result = generator.algorithms("email");
expect(result).toContain(Generators.catchall); expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
expect(result).toContain(Generators.subaddress); expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
// this test shouldn't contain entries outside of the current category // this test shouldn't contain entries outside of the current category
expect(result).not.toContain(Generators.username); expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy();
expect(result).not.toContain(Generators.password); expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy();
}); });
it("combines metadata across categories", () => { it("combines metadata across categories", () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = generator.algorithms(["username", "email"]); const result = generator.algorithms(["username", "email"]);
expect(result).toContain(Generators.username); expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
expect(result).toContain(Generators.catchall); expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
expect(result).toContain(Generators.subaddress); expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
// this test shouldn't contain entries outside of the current categories // this test shouldn't contain entries outside of the current categories
expect(result).not.toContain(Generators.password); expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy();
}); });
}); });
@ -461,39 +619,71 @@ describe("CredentialGeneratorService", () => {
// these tests cannot use the observable tracker because they return // these tests cannot use the observable tracker because they return
// data that cannot be cloned // data that cannot be cloned
it("returns password metadata", async () => { it("returns password metadata", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = await firstValueFrom(generator.algorithms$("password")); const result = await firstValueFrom(generator.algorithms$("password"));
expect(result).toContain(Generators.password); expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
expect(result).toContain(Generators.passphrase); expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy();
}); });
it("returns username metadata", async () => { it("returns username metadata", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = await firstValueFrom(generator.algorithms$("username")); const result = await firstValueFrom(generator.algorithms$("username"));
expect(result).toContain(Generators.username); expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
}); });
it("returns email metadata", async () => { it("returns email metadata", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = await firstValueFrom(generator.algorithms$("email")); const result = await firstValueFrom(generator.algorithms$("email"));
expect(result).toContain(Generators.catchall); expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
expect(result).toContain(Generators.subaddress); expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
}); });
it("returns username and email metadata", async () => { it("returns username and email metadata", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = await firstValueFrom(generator.algorithms$(["username", "email"])); const result = await firstValueFrom(generator.algorithms$(["username", "email"]));
expect(result).toContain(Generators.username); expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
expect(result).toContain(Generators.catchall); expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
expect(result).toContain(Generators.subaddress); expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
}); });
// Subsequent tests focus on passwords and passphrases as an example of policy // Subsequent tests focus on passwords and passphrases as an example of policy
@ -501,13 +691,21 @@ describe("CredentialGeneratorService", () => {
it("enforces the active user's policy", async () => { it("enforces the active user's policy", async () => {
const policy$ = new BehaviorSubject([passwordOverridePolicy]); const policy$ = new BehaviorSubject([passwordOverridePolicy]);
policyService.getAll$.mockReturnValue(policy$); policyService.getAll$.mockReturnValue(policy$);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = await firstValueFrom(generator.algorithms$(["password"])); const result = await firstValueFrom(generator.algorithms$(["password"]));
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
expect(result).toContain(Generators.password); expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
expect(result).not.toContain(Generators.passphrase); expect(result.some((a) => a.id === Generators.passphrase.id)).toBeFalsy();
}); });
it("follows changes to the active user", async () => { it("follows changes to the active user", async () => {
@ -518,7 +716,15 @@ describe("CredentialGeneratorService", () => {
await accountService.switchAccount(SomeUser); await accountService.switchAccount(SomeUser);
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const results: any = []; const results: any = [];
const sub = generator.algorithms$("password").subscribe((r) => results.push(r)); const sub = generator.algorithms$("password").subscribe((r) => results.push(r));
@ -533,34 +739,50 @@ describe("CredentialGeneratorService", () => {
PolicyType.PasswordGenerator, PolicyType.PasswordGenerator,
SomeUser, SomeUser,
); );
expect(someResult).toContain(Generators.password); expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy();
expect(someResult).not.toContain(Generators.passphrase); expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy();
expect(policyService.getAll$).toHaveBeenNthCalledWith( expect(policyService.getAll$).toHaveBeenNthCalledWith(
2, 2,
PolicyType.PasswordGenerator, PolicyType.PasswordGenerator,
AnotherUser, AnotherUser,
); );
expect(anotherResult).toContain(Generators.passphrase); expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy();
expect(anotherResult).not.toContain(Generators.password); expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy();
}); });
it("reads an arbitrary user's settings", async () => { it("reads an arbitrary user's settings", async () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const result = await firstValueFrom(generator.algorithms$("password", { userId$ })); const result = await firstValueFrom(generator.algorithms$("password", { userId$ }));
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser);
expect(result).toContain(Generators.password); expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy();
expect(result).not.toContain(Generators.passphrase); expect(result.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy();
}); });
it("follows changes to the arbitrary user", async () => { it("follows changes to the arbitrary user", async () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
const results: any = []; const results: any = [];
@ -572,17 +794,25 @@ describe("CredentialGeneratorService", () => {
const [someResult, anotherResult] = results; const [someResult, anotherResult] = results;
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
expect(someResult).toContain(Generators.password); expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy();
expect(someResult).not.toContain(Generators.passphrase); expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy();
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser);
expect(anotherResult).toContain(Generators.passphrase); expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy();
expect(anotherResult).not.toContain(Generators.password); expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy();
}); });
it("errors when the arbitrary user's stream errors", async () => { it("errors when the arbitrary user's stream errors", async () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
let error = null; let error = null;
@ -600,7 +830,15 @@ describe("CredentialGeneratorService", () => {
it("completes when the arbitrary user's stream completes", async () => { it("completes when the arbitrary user's stream completes", async () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
let completed = false; let completed = false;
@ -618,7 +856,15 @@ describe("CredentialGeneratorService", () => {
it("ignores repeated arbitrary user emissions", async () => { it("ignores repeated arbitrary user emissions", async () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
let count = 0; let count = 0;
@ -642,7 +888,15 @@ describe("CredentialGeneratorService", () => {
describe("settings$", () => { describe("settings$", () => {
it("defaults to the configuration's initial settings if settings aren't found", async () => { it("defaults to the configuration's initial settings if settings aren't found", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = await firstValueFrom(generator.settings$(SomeConfiguration)); const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@ -652,7 +906,15 @@ describe("CredentialGeneratorService", () => {
it("reads from the active user's configuration-defined storage", async () => { it("reads from the active user's configuration-defined storage", async () => {
const settings = { foo: "value" }; const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser); await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = await firstValueFrom(generator.settings$(SomeConfiguration)); const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@ -664,7 +926,15 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, settings, SomeUser); await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const policy$ = new BehaviorSubject([somePolicy]); const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$); policyService.getAll$.mockReturnValue(policy$);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const result = await firstValueFrom(generator.settings$(SomeConfiguration)); const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@ -672,7 +942,7 @@ describe("CredentialGeneratorService", () => {
}); });
it("follows changes to the active user", async () => { it("follows changes to the active user", async () => {
// initialize local accound service and state provider because this test is sensitive // initialize local account service and state provider because this test is sensitive
// to some shared data in `FakeAccountService`. // to some shared data in `FakeAccountService`.
const accountService = new FakeAccountService(accounts); const accountService = new FakeAccountService(accounts);
const stateProvider = new FakeStateProvider(accountService); const stateProvider = new FakeStateProvider(accountService);
@ -681,7 +951,15 @@ describe("CredentialGeneratorService", () => {
const anotherSettings = { foo: "another" }; const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const results: any = []; const results: any = [];
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
@ -698,7 +976,15 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const anotherSettings = { foo: "another" }; const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ })); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ }));
@ -711,7 +997,15 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
const anotherSettings = { foo: "another" }; const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
const results: any = []; const results: any = [];
@ -730,7 +1024,15 @@ describe("CredentialGeneratorService", () => {
it("errors when the arbitrary user's stream errors", async () => { it("errors when the arbitrary user's stream errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
let error = null; let error = null;
@ -748,7 +1050,15 @@ describe("CredentialGeneratorService", () => {
it("completes when the arbitrary user's stream completes", async () => { it("completes when the arbitrary user's stream completes", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
let completed = false; let completed = false;
@ -766,7 +1076,15 @@ describe("CredentialGeneratorService", () => {
it("ignores repeated arbitrary user emissions", async () => { it("ignores repeated arbitrary user emissions", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
let count = 0; let count = 0;
@ -790,7 +1108,15 @@ describe("CredentialGeneratorService", () => {
describe("settings", () => { describe("settings", () => {
it("writes to the user's state", async () => { it("writes to the user's state", async () => {
const singleUserId$ = new BehaviorSubject(SomeUser).asObservable(); const singleUserId$ = new BehaviorSubject(SomeUser).asObservable();
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
subject.next({ foo: "next value" }); subject.next({ foo: "next value" });
@ -803,7 +1129,15 @@ describe("CredentialGeneratorService", () => {
it("waits for the user to become available", async () => { it("waits for the user to become available", async () => {
const singleUserId = new BehaviorSubject(null); const singleUserId = new BehaviorSubject(null);
const singleUserId$ = singleUserId.asObservable(); const singleUserId$ = singleUserId.asObservable();
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
let completed = false; let completed = false;
const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => { const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => {
@ -821,7 +1155,15 @@ describe("CredentialGeneratorService", () => {
describe("policy$", () => { describe("policy$", () => {
it("creates constraints without policy in effect when there is no policy", async () => { it("creates constraints without policy in effect when there is no policy", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId$ = new BehaviorSubject(SomeUser).asObservable(); const userId$ = new BehaviorSubject(SomeUser).asObservable();
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
@ -830,7 +1172,15 @@ describe("CredentialGeneratorService", () => {
}); });
it("creates constraints with policy in effect when there is a policy", async () => { it("creates constraints with policy in effect when there is a policy", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId$ = new BehaviorSubject(SomeUser).asObservable(); const userId$ = new BehaviorSubject(SomeUser).asObservable();
const policy$ = new BehaviorSubject([somePolicy]); const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$); policyService.getAll$.mockReturnValue(policy$);
@ -841,7 +1191,15 @@ describe("CredentialGeneratorService", () => {
}); });
it("follows policy emissions", async () => { it("follows policy emissions", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
const somePolicySubject = new BehaviorSubject([somePolicy]); const somePolicySubject = new BehaviorSubject([somePolicy]);
@ -862,7 +1220,15 @@ describe("CredentialGeneratorService", () => {
}); });
it("follows user emissions", async () => { it("follows user emissions", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
@ -884,7 +1250,15 @@ describe("CredentialGeneratorService", () => {
}); });
it("errors when the user errors", async () => { it("errors when the user errors", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
const expectedError = { some: "error" }; const expectedError = { some: "error" };
@ -902,7 +1276,15 @@ describe("CredentialGeneratorService", () => {
}); });
it("completes when the user completes", async () => { it("completes when the user completes", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptService,
cryptoService,
);
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();

View File

@ -11,38 +11,60 @@ import {
ignoreElements, ignoreElements,
map, map,
Observable, Observable,
race,
share, share,
skipUntil, skipUntil,
switchMap, switchMap,
takeUntil, takeUntil,
takeWhile,
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
import { Simplify } from "type-fest"; import { Simplify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state"; import { StateProvider } from "@bitwarden/common/platform/state";
import { import {
OnDependency, OnDependency,
SingleUserDependency, SingleUserDependency,
UserBound,
UserDependency, UserDependency,
} from "@bitwarden/common/tools/dependencies"; } from "@bitwarden/common/tools/dependencies";
import { isDynamic } from "@bitwarden/common/tools/state/state-constraints-dependency"; import { IntegrationId, IntegrationMetadata } from "@bitwarden/common/tools/integration";
import { RestClient } from "@bitwarden/common/tools/integration/rpc";
import { anyComplete } from "@bitwarden/common/tools/rx";
import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer";
import { UserEncryptor } from "@bitwarden/common/tools/state/user-encryptor.abstraction";
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { UserId } from "@bitwarden/common/types/guid";
import { Randomizer } from "../abstractions"; import { Randomizer } from "../abstractions";
import { Generators } from "../data"; import {
Generators,
getForwarderConfiguration,
Integrations,
toCredentialGeneratorConfiguration,
} from "../data";
import { availableAlgorithms } from "../policies/available-algorithms-policy"; import { availableAlgorithms } from "../policies/available-algorithms-policy";
import { mapPolicyToConstraints } from "../rx"; import { mapPolicyToConstraints } from "../rx";
import { import {
CredentialAlgorithm, CredentialAlgorithm,
CredentialCategories, CredentialCategories,
CredentialCategory, CredentialCategory,
CredentialGeneratorInfo, AlgorithmInfo,
CredentialPreference, CredentialPreference,
isForwarderIntegration,
ForwarderIntegration,
} from "../types"; } from "../types";
import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; import {
CredentialGeneratorConfiguration as Configuration,
CredentialGeneratorInfo,
GeneratorDependencyProvider,
} from "../types/credential-generator-configuration";
import { GeneratorConstraints } from "../types/generator-constraints"; import { GeneratorConstraints } from "../types/generator-constraints";
import { PREFERENCES } from "./credential-preferences"; import { PREFERENCES } from "./credential-preferences";
@ -59,17 +81,33 @@ type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDepend
* When `website$` errors, the generator forwards the error. * When `website$` errors, the generator forwards the error.
*/ */
website$?: Observable<string>; website$?: Observable<string>;
integration$?: Observable<IntegrationId>;
}; };
type Algorithms$Dependencies = Partial<UserDependency>; type Algorithms$Dependencies = Partial<UserDependency>;
const OPTIONS_FRAME_SIZE = 512;
export class CredentialGeneratorService { export class CredentialGeneratorService {
constructor( constructor(
private randomizer: Randomizer, private readonly randomizer: Randomizer,
private stateProvider: StateProvider, private readonly stateProvider: StateProvider,
private policyService: PolicyService, private readonly policyService: PolicyService,
private readonly apiService: ApiService,
private readonly i18nService: I18nService,
private readonly encryptService: EncryptService,
private readonly cryptoService: CryptoService,
) {} ) {}
private getDependencyProvider(): GeneratorDependencyProvider {
return {
client: new RestClient(this.apiService, this.i18nService),
i18nService: this.i18nService,
randomizer: this.randomizer,
};
}
// FIXME: the rxjs methods of this service can be a lot more resilient if // FIXME: the rxjs methods of this service can be a lot more resilient if
// `Subjects` are introduced where sharing occurs // `Subjects` are introduced where sharing occurs
@ -84,18 +122,13 @@ export class CredentialGeneratorService {
dependencies?: Generate$Dependencies, dependencies?: Generate$Dependencies,
) { ) {
// instantiate the engine // instantiate the engine
const engine = configuration.engine.create(this.randomizer); const engine = configuration.engine.create(this.getDependencyProvider());
// stream blocks until all of these values are received // stream blocks until all of these values are received
const website$ = dependencies?.website$ ?? new BehaviorSubject<string>(null); const website$ = dependencies?.website$ ?? new BehaviorSubject<string>(null);
const request$ = website$.pipe(map((website) => ({ website }))); const request$ = website$.pipe(map((website) => ({ website })));
const settings$ = this.settings$(configuration, dependencies); const settings$ = this.settings$(configuration, dependencies);
// monitor completion
const requestComplete$ = request$.pipe(ignoreElements(), endWith(true));
const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true));
const complete$ = race(requestComplete$, settingsComplete$);
// if on$ triggers before settings are loaded, trigger as soon // if on$ triggers before settings are loaded, trigger as soon
// as they become available. // as they become available.
let readyOn$: Observable<any> = null; let readyOn$: Observable<any> = null;
@ -116,7 +149,7 @@ export class CredentialGeneratorService {
const generate$ = (readyOn$ ?? settings$).pipe( const generate$ = (readyOn$ ?? settings$).pipe(
withLatestFrom(request$, settings$), withLatestFrom(request$, settings$),
concatMap(([, request, settings]) => engine.generate(request, settings)), concatMap(([, request, settings]) => engine.generate(request, settings)),
takeUntil(complete$), takeUntil(anyComplete([request$, settings$])),
); );
return generate$; return generate$;
@ -132,11 +165,11 @@ export class CredentialGeneratorService {
algorithms$( algorithms$(
category: CredentialCategory, category: CredentialCategory,
dependencies?: Algorithms$Dependencies, dependencies?: Algorithms$Dependencies,
): Observable<CredentialGeneratorInfo[]>; ): Observable<AlgorithmInfo[]>;
algorithms$( algorithms$(
category: CredentialCategory[], category: CredentialCategory[],
dependencies?: Algorithms$Dependencies, dependencies?: Algorithms$Dependencies,
): Observable<CredentialGeneratorInfo[]>; ): Observable<AlgorithmInfo[]>;
algorithms$( algorithms$(
category: CredentialCategory | CredentialCategory[], category: CredentialCategory | CredentialCategory[],
dependencies?: Algorithms$Dependencies, dependencies?: Algorithms$Dependencies,
@ -163,7 +196,9 @@ export class CredentialGeneratorService {
return policies$; return policies$;
}), }),
map((available) => { map((available) => {
const filtered = algorithms.filter((c) => available.has(c.id)); const filtered = algorithms.filter(
(c) => isForwarderIntegration(c.id) || available.has(c.id),
);
return filtered; return filtered;
}), }),
); );
@ -175,24 +210,79 @@ export class CredentialGeneratorService {
* @param category the category or categories of interest * @param category the category or categories of interest
* @returns A list containing the requested metadata. * @returns A list containing the requested metadata.
*/ */
algorithms(category: CredentialCategory): CredentialGeneratorInfo[]; algorithms(category: CredentialCategory): AlgorithmInfo[];
algorithms(category: CredentialCategory[]): CredentialGeneratorInfo[]; algorithms(category: CredentialCategory[]): AlgorithmInfo[];
algorithms(category: CredentialCategory | CredentialCategory[]): CredentialGeneratorInfo[] { algorithms(category: CredentialCategory | CredentialCategory[]): AlgorithmInfo[] {
const categories = Array.isArray(category) ? category : [category]; const categories: CredentialCategory[] = Array.isArray(category) ? category : [category];
const algorithms = categories const algorithms = categories
.flatMap((c) => CredentialCategories[c]) .flatMap((c) => CredentialCategories[c] as CredentialAlgorithm[])
.map((c) => (c === "forwarder" ? null : Generators[c])) .map((id) => this.algorithm(id))
.filter((info) => info !== null); .filter((info) => info !== null);
return algorithms; const forwarders = Object.keys(Integrations)
.map((key: keyof typeof Integrations) => {
const forwarder: ForwarderIntegration = { forwarder: Integrations[key].id };
return this.algorithm(forwarder);
})
.filter((forwarder) => categories.includes(forwarder.category));
return algorithms.concat(forwarders);
} }
/** Look up the metadata for a specific generator algorithm /** Look up the metadata for a specific generator algorithm
* @param id identifies the algorithm * @param id identifies the algorithm
* @returns the requested metadata, or `null` if the metadata wasn't found. * @returns the requested metadata, or `null` if the metadata wasn't found.
*/ */
algorithm(id: CredentialAlgorithm): CredentialGeneratorInfo { algorithm(id: CredentialAlgorithm): AlgorithmInfo {
return (id === "forwarder" ? null : Generators[id]) ?? null; let generator: CredentialGeneratorInfo = null;
let integration: IntegrationMetadata = null;
if (isForwarderIntegration(id)) {
const forwarderConfig = getForwarderConfiguration(id.forwarder);
integration = forwarderConfig;
if (forwarderConfig) {
generator = toCredentialGeneratorConfiguration(forwarderConfig);
}
} else {
generator = Generators[id];
}
if (!generator) {
throw new Error(`Invalid credential algorithm: ${JSON.stringify(id)}`);
}
const info: AlgorithmInfo = {
id: generator.id,
category: generator.category,
name: integration ? integration.name : this.i18nService.t(generator.nameKey),
generate: this.i18nService.t(generator.generateKey),
copy: this.i18nService.t(generator.copyKey),
onlyOnRequest: generator.onlyOnRequest,
request: generator.request,
};
if (generator.descriptionKey) {
info.description = this.i18nService.t(generator.descriptionKey);
}
return info;
}
private encryptor$(userId: UserId) {
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
const encryptor$ = this.cryptoService.userKey$(userId).pipe(
// complete when the account locks
takeWhile((key) => !!key),
map((key) => {
const encryptor = new UserKeyEncryptor(userId, this.encryptService, key, packer);
return { userId, encryptor } satisfies UserBound<"encryptor", UserEncryptor>;
}),
);
return encryptor$;
} }
/** Get the settings for the provided configuration /** Get the settings for the provided configuration
@ -208,27 +298,21 @@ export class CredentialGeneratorService {
dependencies?: Settings$Dependencies, dependencies?: Settings$Dependencies,
) { ) {
const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$; const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$;
const completion$ = userId$.pipe(ignoreElements(), endWith(true)); const constraints$ = this.policy$(configuration, { userId$ });
const state$ = userId$.pipe( const settings$ = userId$.pipe(
filter((userId) => !!userId), filter((userId) => !!userId),
distinctUntilChanged(), distinctUntilChanged(),
switchMap((userId) => { switchMap((userId) => {
const state$ = this.stateProvider const state$ = new UserStateSubject(
.getUserState$(configuration.settings.account, userId) configuration.settings.account,
.pipe(takeUntil(completion$)); (key) => this.stateProvider.getUser(userId, key),
{ constraints$, singleUserEncryptor$: this.encryptor$(userId) },
);
return state$; return state$;
}), }),
map((settings) => settings ?? structuredClone(configuration.settings.initial)), map((settings) => settings ?? structuredClone(configuration.settings.initial)),
); takeUntil(anyComplete(userId$)),
const settings$ = combineLatest([state$, this.policy$(configuration, { userId$ })]).pipe(
map(([settings, policy]) => {
const calibration = isDynamic(policy) ? policy.calibrate(settings) : policy;
const adjusted = calibration.adjust(settings);
return adjusted;
}),
); );
return settings$; return settings$;
@ -251,8 +335,11 @@ export class CredentialGeneratorService {
); );
// FIXME: enforce policy // FIXME: enforce policy
const state = this.stateProvider.getUser(userId, PREFERENCES); const subject = new UserStateSubject(
const subject = new UserStateSubject(state, { ...dependencies }); PREFERENCES,
(key) => this.stateProvider.getUser(userId, key),
{ singleUserEncryptor$: this.encryptor$(userId) },
);
return subject; return subject;
} }
@ -271,10 +358,14 @@ export class CredentialGeneratorService {
const userId = await firstValueFrom( const userId = await firstValueFrom(
dependencies.singleUserId$.pipe(filter((userId) => !!userId)), dependencies.singleUserId$.pipe(filter((userId) => !!userId)),
); );
const state = this.stateProvider.getUser(userId, configuration.settings.account);
const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ }); const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ });
const subject = new UserStateSubject(state, { ...dependencies, constraints$ }); const subject = new UserStateSubject(
configuration.settings.account,
(key) => this.stateProvider.getUser(userId, key),
{ constraints$, singleUserEncryptor$: this.encryptor$(userId) },
);
return subject; return subject;
} }

View File

@ -1,4 +1,7 @@
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { UserKeyDefinition } from "@bitwarden/common/platform/state";
import { RestClient } from "@bitwarden/common/tools/integration/rpc";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { Constraints } from "@bitwarden/common/tools/types"; import { Constraints } from "@bitwarden/common/tools/types";
import { Randomizer } from "../abstractions"; import { Randomizer } from "../abstractions";
@ -6,9 +9,58 @@ import { CredentialAlgorithm, CredentialCategory, PolicyConfiguration } from "..
import { CredentialGenerator } from "./credential-generator"; import { CredentialGenerator } from "./credential-generator";
export type GeneratorDependencyProvider = {
randomizer: Randomizer;
client: RestClient;
i18nService: I18nService;
};
export type AlgorithmInfo = {
/** Uniquely identifies the credential configuration
* @example
* // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)`
* // to pattern test whether the credential describes a forwarder algorithm
* const meta : CredentialGeneratorInfo = // ...
* const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {};
*/
id: CredentialAlgorithm;
/** The kind of credential generated by this configuration */
category: CredentialCategory;
/** Localized algorithm name */
name: string;
/* Localized generate button label */
generate: string;
/* Localized copy button label */
copy: string;
/** Localized algorithm description */
description?: string;
/** When true, credential generation must be explicitly requested.
* @remarks this property is useful when credential generation
* carries side effects, such as configuring a service external
* to Bitwarden.
*/
onlyOnRequest: boolean;
/** Well-known fields to display on the options panel or collect from the environment.
* @remarks: at present, this is only used by forwarders
*/
request: readonly string[];
};
/** Credential generator metadata common across credential generators */ /** Credential generator metadata common across credential generators */
export type CredentialGeneratorInfo = { export type CredentialGeneratorInfo = {
/** Uniquely identifies the credential configuration /** Uniquely identifies the credential configuration
* @example
* // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)`
* // to pattern test whether the credential describes a forwarder algorithm
* const meta : CredentialGeneratorInfo = // ...
* const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {};
*/ */
id: CredentialAlgorithm; id: CredentialAlgorithm;
@ -21,15 +73,32 @@ export type CredentialGeneratorInfo = {
/** Key used to localize the credential description in the I18nService */ /** Key used to localize the credential description in the I18nService */
descriptionKey?: string; descriptionKey?: string;
/* Localized generate button label */
generateKey: string;
/* Localized copy button label */
copyKey: string;
/** When true, credential generation must be explicitly requested. /** When true, credential generation must be explicitly requested.
* @remarks this property is useful when credential generation * @remarks this property is useful when credential generation
* carries side effects, such as configuring a service external * carries side effects, such as configuring a service external
* to Bitwarden. * to Bitwarden.
*/ */
onlyOnRequest: boolean; onlyOnRequest: boolean;
/** Well-known fields to display on the options panel or collect from the environment.
* @remarks: at present, this is only used by forwarders
*/
request: readonly string[];
}; };
/** Credential generator metadata that relies upon typed setting and policy definitions. */ /** Credential generator metadata that relies upon typed setting and policy definitions.
* @example
* // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)`
* // to pattern test whether the credential describes a forwarder algorithm
* const meta : CredentialGeneratorInfo = // ...
* const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {};
*/
export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGeneratorInfo & { export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGeneratorInfo & {
/** An algorithm that generates credentials when ran. */ /** An algorithm that generates credentials when ran. */
engine: { engine: {
@ -40,7 +109,7 @@ export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGener
// the credential generator, but engine configurations should return // the credential generator, but engine configurations should return
// the underlying type. `create` may be able to do double-duty w/ an // the underlying type. `create` may be able to do double-duty w/ an
// engine definition if `CredentialGenerator` can be made covariant. // engine definition if `CredentialGenerator` can be made covariant.
create: (randomizer: Randomizer) => CredentialGenerator<Settings>; create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator<Settings>;
}; };
/** Defines the stored parameters for credential generation */ /** Defines the stored parameters for credential generation */
settings: { settings: {
@ -51,7 +120,10 @@ export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGener
constraints: Constraints<Settings>; constraints: Constraints<Settings>;
/** storage location for account-global settings */ /** storage location for account-global settings */
account: UserKeyDefinition<Settings>; account: UserKeyDefinition<Settings> | ObjectKey<Settings>;
/** storage location for *plaintext* settings imports */
import?: UserKeyDefinition<Settings> | ObjectKey<Settings, Record<string, never>, Settings>;
}; };
/** defines how to construct policy for this settings instance */ /** defines how to construct policy for this settings instance */

View File

@ -1,3 +1,5 @@
import { IntegrationId } from "@bitwarden/common/tools/integration";
import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types"; import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types";
/** A type of password that may be generated by the credential generator. */ /** A type of password that may be generated by the credential generator. */
@ -9,8 +11,31 @@ export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number];
/** A type of email address that may be generated by the credential generator. */ /** A type of email address that may be generated by the credential generator. */
export type EmailAlgorithm = (typeof EmailAlgorithms)[number]; export type EmailAlgorithm = (typeof EmailAlgorithms)[number];
export type ForwarderIntegration = { forwarder: IntegrationId };
/** Returns true when the input algorithm is a forwarder integration. */
export function isForwarderIntegration(
algorithm: CredentialAlgorithm,
): algorithm is ForwarderIntegration {
return algorithm && typeof algorithm === "object" && "forwarder" in algorithm;
}
export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorithm) {
if (lhs === rhs) {
return true;
} else if (isForwarderIntegration(lhs) && isForwarderIntegration(rhs)) {
return lhs.forwarder === rhs.forwarder;
} else {
return false;
}
}
/** A type of credential that may be generated by the credential generator. */ /** A type of credential that may be generated by the credential generator. */
export type CredentialAlgorithm = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm; export type CredentialAlgorithm =
| PasswordAlgorithm
| UsernameAlgorithm
| EmailAlgorithm
| ForwarderIntegration;
/** Compound credential types supported by the credential generator. */ /** Compound credential types supported by the credential generator. */
export const CredentialCategories = Object.freeze({ export const CredentialCategories = Object.freeze({
@ -21,7 +46,7 @@ export const CredentialCategories = Object.freeze({
username: UsernameAlgorithms as Readonly<UsernameAlgorithm[]>, username: UsernameAlgorithms as Readonly<UsernameAlgorithm[]>,
/** Lists algorithms in the "email" credential category */ /** Lists algorithms in the "email" credential category */
email: EmailAlgorithms as Readonly<EmailAlgorithm[]>, email: EmailAlgorithms as Readonly<(EmailAlgorithm | ForwarderIntegration)[]>,
}); });
/** Returns true when the input algorithm is a password algorithm. */ /** Returns true when the input algorithm is a password algorithm. */
@ -40,7 +65,7 @@ export function isUsernameAlgorithm(
/** Returns true when the input algorithm is an email algorithm. */ /** Returns true when the input algorithm is an email algorithm. */
export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm {
return EmailAlgorithms.includes(algorithm as any); return EmailAlgorithms.includes(algorithm as any) || isForwarderIntegration(algorithm);
} }
/** A type of compound credential that may be generated by the credential generator. */ /** A type of compound credential that may be generated by the credential generator. */

View File

@ -1,4 +1,4 @@
import { CredentialAlgorithm, PasswordAlgorithm } from "./generator-type"; import { EmailAlgorithm, PasswordAlgorithm, UsernameAlgorithm } from "./generator-type";
export * from "./boundary"; export * from "./boundary";
export * from "./catchall-generator-options"; export * from "./catchall-generator-options";
@ -22,7 +22,7 @@ export * from "./word-options";
/** Provided for backwards compatibility only. /** Provided for backwards compatibility only.
* @deprecated Use one of the Algorithm types instead. * @deprecated Use one of the Algorithm types instead.
*/ */
export type GeneratorType = CredentialAlgorithm; export type GeneratorType = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm;
/** Provided for backwards compatibility only. /** Provided for backwards compatibility only.
* @deprecated Use one of the Algorithm types instead. * @deprecated Use one of the Algorithm types instead.

View File

@ -2,8 +2,11 @@ import { NgModule } from "@angular/core";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { StateProvider } from "@bitwarden/common/platform/state";
import { import {
createRandomizer, createRandomizer,
@ -32,7 +35,15 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
safeProvider({ safeProvider({
useClass: CredentialGeneratorService, useClass: CredentialGeneratorService,
provide: CredentialGeneratorService, provide: CredentialGeneratorService,
deps: [RANDOMIZER, StateProvider, PolicyService], deps: [
RANDOMIZER,
StateProvider,
PolicyService,
ApiService,
I18nService,
EncryptService,
CryptoService,
],
}), }),
], ],
exports: [SendFormComponent], exports: [SendFormComponent],