1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-26 12:25:20 +01:00

[PM-9613] port forwarders to integrations (#10075)

* introduced forwarder integrations
* simply contexts
* report error and message when both are present in an RPC response
This commit is contained in:
✨ Audrey ✨ 2024-07-30 08:40:52 -04:00 committed by GitHub
parent 8f437dc773
commit 8c78959aaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 2392 additions and 2415 deletions

View File

@ -1,9 +1,12 @@
import { IntegrationContext } from "./integration-context"; import { IntegrationContext } from "./integration-context";
import { IntegrationMetadata } from "./integration-metadata"; import { IntegrationMetadata } from "./integration-metadata";
import { ApiSettings, TokenHeader } from "./rpc"; import { ApiSettings, IntegrationRequest, TokenHeader } from "./rpc";
/** Configures integration-wide settings */ /** Configures integration-wide settings */
export type IntegrationConfiguration = IntegrationMetadata & { export type IntegrationConfiguration = IntegrationMetadata & {
/** Creates the authentication header for all integration remote procedure calls */ /** Creates the authentication header for all integration remote procedure calls */
authenticate: (settings: ApiSettings, context: IntegrationContext) => TokenHeader; authenticate: (
request: IntegrationRequest,
context: IntegrationContext<ApiSettings>,
) => TokenHeader;
}; };

View File

@ -25,7 +25,7 @@ describe("IntegrationContext", () => {
describe("baseUrl", () => { describe("baseUrl", () => {
it("outputs the base url from metadata", () => { it("outputs the base url from metadata", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, null, i18n);
const result = context.baseUrl(); const result = context.baseUrl();
@ -41,15 +41,15 @@ describe("IntegrationContext", () => {
}; };
i18n.t.mockReturnValue("error"); i18n.t.mockReturnValue("error");
const context = new IntegrationContext(noBaseUrl, i18n); const context = new IntegrationContext(noBaseUrl, null, i18n);
expect(() => context.baseUrl()).toThrow("error"); expect(() => context.baseUrl()).toThrow("error");
}); });
it("reads from the settings", () => { it("reads from the settings", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, { baseUrl: "httpbin.org" }, i18n);
const result = context.baseUrl({ baseUrl: "httpbin.org" }); const result = context.baseUrl();
expect(result).toBe("httpbin.org"); expect(result).toBe("httpbin.org");
}); });
@ -62,9 +62,9 @@ describe("IntegrationContext", () => {
baseUrl: "example.com", baseUrl: "example.com",
selfHost: "never", selfHost: "never",
}; };
const context = new IntegrationContext(selfHostNever, i18n); const context = new IntegrationContext(selfHostNever, { baseUrl: "httpbin.org" }, i18n);
const result = context.baseUrl({ baseUrl: "httpbin.org" }); const result = context.baseUrl();
expect(result).toBe("example.com"); expect(result).toBe("example.com");
}); });
@ -77,11 +77,22 @@ describe("IntegrationContext", () => {
baseUrl: "example.com", baseUrl: "example.com",
selfHost: "always", selfHost: "always",
}; };
const context = new IntegrationContext(selfHostAlways, i18n); const context = new IntegrationContext(selfHostAlways, { baseUrl: "http.bin" }, i18n);
// expect success // expect success
const result = context.baseUrl({ baseUrl: "http.bin" }); const result = context.baseUrl();
expect(result).toBe("http.bin"); expect(result).toBe("http.bin");
});
it("fails when the settings are empty and selfhost is 'always'", () => {
const selfHostAlways: IntegrationMetadata = {
id: "simplelogin" as IntegrationId, // arbitrary
name: "Example",
extends: ["forwarder"], // arbitrary
baseUrl: "example.com",
selfHost: "always",
};
const context = new IntegrationContext(selfHostAlways, {}, i18n);
// expect error // expect error
i18n.t.mockReturnValue("error"); i18n.t.mockReturnValue("error");
@ -97,7 +108,7 @@ describe("IntegrationContext", () => {
selfHost: "maybe", selfHost: "maybe",
}; };
const context = new IntegrationContext(selfHostMaybe, i18n); const context = new IntegrationContext(selfHostMaybe, null, i18n);
const result = context.baseUrl(); const result = context.baseUrl();
@ -113,9 +124,9 @@ describe("IntegrationContext", () => {
selfHost: "maybe", selfHost: "maybe",
}; };
const context = new IntegrationContext(selfHostMaybe, i18n); const context = new IntegrationContext(selfHostMaybe, { baseUrl: "httpbin.org" }, i18n);
const result = context.baseUrl({ baseUrl: "httpbin.org" }); const result = context.baseUrl();
expect(result).toBe("httpbin.org"); expect(result).toBe("httpbin.org");
}); });
@ -123,39 +134,47 @@ describe("IntegrationContext", () => {
describe("authenticationToken", () => { describe("authenticationToken", () => {
it("reads from the settings", () => { it("reads from the settings", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, { token: "example" }, i18n);
const result = context.authenticationToken({ token: "example" }); const result = context.authenticationToken();
expect(result).toBe("example"); expect(result).toBe("example");
}); });
it("base64 encodes the read value", () => { it("suffix is appended to the token", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, { token: "example" }, i18n);
const result = context.authenticationToken({ token: "example" }, { base64: true }); const result = context.authenticationToken({ suffix: " with suffix" });
expect(result).toBe("example with suffix");
});
it("base64 encodes the read value", () => {
const context = new IntegrationContext(EXAMPLE_META, { token: "example" }, i18n);
const result = context.authenticationToken({ base64: true });
expect(result).toBe("ZXhhbXBsZQ=="); expect(result).toBe("ZXhhbXBsZQ==");
}); });
it("throws an error when the value is missing", () => { it("throws an error when the value is missing", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, {}, i18n);
i18n.t.mockReturnValue("error"); i18n.t.mockReturnValue("error");
expect(() => context.authenticationToken({})).toThrow("error"); expect(() => context.authenticationToken()).toThrow("error");
}); });
it("throws an error when the value is empty", () => { it.each([[undefined], [null], [""]])("throws an error when the value is %p", (token) => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, { token }, i18n);
i18n.t.mockReturnValue("error"); i18n.t.mockReturnValue("error");
expect(() => context.authenticationToken({ token: "" })).toThrow("error"); expect(() => context.authenticationToken()).toThrow("error");
}); });
}); });
describe("website", () => { describe("website", () => {
it("returns the website", () => { it("returns the website", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, null, i18n);
const result = context.website({ website: "www.example.com" }); const result = context.website({ website: "www.example.com" });
@ -163,7 +182,7 @@ describe("IntegrationContext", () => {
}); });
it("returns an empty string when the website is not specified", () => { it("returns an empty string when the website is not specified", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, null, i18n);
const result = context.website({ website: undefined }); const result = context.website({ website: undefined });
@ -173,7 +192,7 @@ describe("IntegrationContext", () => {
describe("generatedBy", () => { describe("generatedBy", () => {
it("creates generated by text", () => { it("creates generated by text", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, null, i18n);
i18n.t.mockReturnValue("result"); i18n.t.mockReturnValue("result");
const result = context.generatedBy({ website: null }); const result = context.generatedBy({ website: null });
@ -183,7 +202,7 @@ describe("IntegrationContext", () => {
}); });
it("creates generated by text including the website", () => { it("creates generated by text including the website", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n); const context = new IntegrationContext(EXAMPLE_META, null, i18n);
i18n.t.mockReturnValue("result"); i18n.t.mockReturnValue("result");
const result = context.generatedBy({ website: "www.example.com" }); const result = context.generatedBy({ website: "www.example.com" });

View File

@ -2,30 +2,33 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { IntegrationMetadata } from "./integration-metadata"; import { IntegrationMetadata } from "./integration-metadata";
import { ApiSettings, SelfHostedApiSettings, IntegrationRequest } from "./rpc"; import { ApiSettings, IntegrationRequest } from "./rpc";
/** Utilities for processing integration settings */ /** Utilities for processing integration settings */
export class IntegrationContext { export class IntegrationContext<Settings extends object> {
/** Instantiates an integration context /** Instantiates an integration context
* @param metadata - defines integration capabilities * @param metadata - defines integration capabilities
* @param i18n - localizes error messages * @param i18n - localizes error messages
*/ */
constructor( constructor(
readonly metadata: IntegrationMetadata, readonly metadata: IntegrationMetadata,
protected settings: Settings,
protected i18n: I18nService, protected i18n: I18nService,
) {} ) {}
/** Lookup the integration's baseUrl /** Lookup the integration's baseUrl
* @param settings settings that override the baseUrl.
* @returns the baseUrl for the API's integration point. * @returns the baseUrl for the API's integration point.
* - By default this is defined by the metadata * - By default this is defined by the metadata
* - When a service allows self-hosting, this can be supplied by `settings`. * - When a service allows self-hosting, this can be supplied by `settings`.
* @throws a localized error message when a base URL is neither defined by the metadata or * @throws a localized error message when a base URL is neither defined by the metadata or
* supplied by an argument. * supplied by an argument.
*/ */
baseUrl(settings?: SelfHostedApiSettings) { baseUrl(): string {
// normalize baseUrl // normalize baseUrl
const setting = settings && "baseUrl" in settings ? settings.baseUrl : ""; const setting =
(this.settings && "baseUrl" in this.settings
? (this.settings.baseUrl as string)
: undefined) ?? "";
let result = ""; let result = "";
// look up definition // look up definition
@ -47,18 +50,24 @@ export class IntegrationContext {
} }
/** look up a service API's authentication token /** look up a service API's authentication token
* @param settings store the API token
* @param options.base64 when `true`, base64 encodes the result. Defaults to `false`. * @param options.base64 when `true`, base64 encodes the result. Defaults to `false`.
* @param options.suffix a string to append to the token. Defaults to empty.
* @returns the user's authentication token * @returns the user's authentication token
* @throws a localized error message when the token is invalid. * @throws a localized error message when the token is invalid.
* @remarks the string is thrown for backwards compatibility
*/ */
authenticationToken(settings: ApiSettings, options: { base64?: boolean } = null) { authenticationToken(
if (!settings.token || settings.token === "") { options: { base64?: boolean; suffix?: string } = null,
): Settings extends ApiSettings ? string : never {
// normalize `token` then assert it has a value
let token = "token" in this.settings ? ((this.settings.token as string) ?? "") : "";
if (token === "") {
const error = this.i18n.t("forwaderInvalidToken", this.metadata.name); const error = this.i18n.t("forwaderInvalidToken", this.metadata.name);
throw error; throw error;
} }
let token = settings.token; // if a suffix exists, it needs to be included before encoding
token += options?.suffix ?? "";
if (options?.base64) { if (options?.base64) {
token = Utils.fromUtf8ToB64(token); token = Utils.fromUtf8ToB64(token);
} }

View File

@ -51,17 +51,41 @@ describe("RestClient", () => {
expect(api.nativeFetch).toHaveBeenCalledWith(expectedRpc.fetchRequest); expect(api.nativeFetch).toHaveBeenCalledWith(expectedRpc.fetchRequest);
}); });
it.each([[401], [403]])( it.each([[401] /*,[403]*/])(
"throws an invalid token error when HTTP status is %i", "throws an invalid token error when HTTP status is %i",
async (status) => { async (status) => {
const client = new RestClient(api, i18n); const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null }; const request: IntegrationRequest = { website: null };
const response = mock<Response>({ status }); const response = mock<Response>({ status, statusText: null });
api.nativeFetch.mockResolvedValue(response); api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request); const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderInvalidToken"); await expect(result).rejects.toEqual("forwaderInvalidToken");
},
);
it.each([
[401, null, null],
[401, undefined, undefined],
[401, undefined, null],
[403, null, null],
[403, undefined, undefined],
[403, undefined, null],
])(
"throws an invalid token error when HTTP status is %i, message is %p, and error is %p",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
text: () => Promise.resolve(`{ "message": null, "error": null }`),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwaderInvalidToken");
}, },
); );
@ -83,16 +107,73 @@ describe("RestClient", () => {
const result = client.fetchJson(rpc, request); const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderInvalidTokenWithMessage"); await expect(result).rejects.toEqual("forwaderInvalidTokenWithMessage");
expect(i18n.t).toHaveBeenCalledWith( expect(i18n.t).toHaveBeenCalledWith(
"forwarderInvalidTokenWithMessage", "forwaderInvalidTokenWithMessage",
"mock", "mock",
"expected message", "expected message",
); );
}, },
); );
it.each([[500], [501]])( it.each([[401], [403]])(
"throws an invalid token detailed error when HTTP status is %i and the payload has a %s",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
text: () =>
Promise.resolve(`{ "error": "that happened", "message": "expected message" }`),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwaderInvalidTokenWithMessage");
expect(i18n.t).toHaveBeenCalledWith(
"forwaderInvalidTokenWithMessage",
"mock",
"that happened: expected message",
);
},
);
it.each([[429], [500], [501]])(
"throws a forwarder error when HTTP status is %i",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({ status, statusText: null });
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderUnknownError");
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
},
);
it.each([[429], [500], [501]])(
"throws a forwarder error when HTTP status is %i and the body is empty",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
statusText: null,
text: () => Promise.resolve(""),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderUnknownError");
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
},
);
it.each([[429], [500], [501]])(
"throws a forwarder error with the status text when HTTP status is %i", "throws a forwarder error with the status text when HTTP status is %i",
async (status) => { async (status) => {
const client = new RestClient(api, i18n); const client = new RestClient(api, i18n);
@ -108,8 +189,10 @@ describe("RestClient", () => {
); );
it.each([ it.each([
[429, "message"],
[500, "message"], [500, "message"],
[500, "message"], [500, "message"],
[429, "error"],
[501, "error"], [501, "error"],
[501, "error"], [501, "error"],
])( ])(
@ -130,6 +213,61 @@ describe("RestClient", () => {
}, },
); );
it.each([[429], [500], [500]])(
"throws a detailed forwarder error when HTTP status is %i and the payload is a string",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
text: () => Promise.resolve('"expected message"'),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderError");
expect(i18n.t).toHaveBeenCalledWith("forwarderError", "mock", "expected message");
},
);
it.each([[429], [500], [500]])(
"throws an unknown forwarder error when HTTP status is %i and the payload could contain an html tag",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
statusText: null,
text: () => Promise.resolve("<head>"),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderUnknownError");
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
},
);
it.each([[429], [500], [500]])(
"throws a unknown forwarder error when HTTP status is %i and the payload is malformed",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
text: () => Promise.resolve(`{ foo: "not json" }`),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderUnknownError");
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
},
);
it("outputs an error if there's no json payload", async () => { it("outputs an error if there's no json payload", async () => {
const client = new RestClient(api, i18n); const client = new RestClient(api, i18n);
rpc.hasJsonPayload.mockReturnValue(false); rpc.hasJsonPayload.mockReturnValue(false);

View File

@ -10,59 +10,96 @@ export class RestClient {
private api: ApiService, private api: ApiService,
private i18n: I18nService, private i18n: I18nService,
) {} ) {}
/** uses the fetch API to request a JSON payload. */ /** uses the fetch API to request a JSON payload. */
async fetchJson<Parameters extends IntegrationRequest, Response>( // FIXME: once legacy password generator is removed, replace forwarder-specific error
rpc: JsonRpc<Parameters, Response>, // messages with RPC-generalized ones.
async fetchJson<Parameters extends IntegrationRequest, Result>(
rpc: JsonRpc<Parameters, Result>,
params: Parameters, params: Parameters,
): Promise<Response> { ): Promise<Result> {
// run the request
const request = rpc.toRequest(params); const request = rpc.toRequest(params);
const response = await this.api.nativeFetch(request); const response = await this.api.nativeFetch(request);
// FIXME: once legacy password generator is removed, replace forwarder-specific error let result: Result = undefined;
// messages with RPC-generalized ones. let errorKey: string = undefined;
let error: string = undefined; let errorMessage: string = undefined;
let cause: string = undefined;
const commonError = await this.detectCommonErrors(response);
if (commonError) {
[errorKey, errorMessage] = commonError;
} else if (rpc.hasJsonPayload(response)) {
[result, errorMessage] = rpc.processJson(await response.json());
}
if (result) {
return result;
}
// handle failures
errorKey ??= errorMessage ? "forwarderError" : "forwarderUnknownError";
const error = this.i18n.t(errorKey, rpc.requestor.name, errorMessage);
throw error;
}
private async detectCommonErrors(response: Response): Promise<[string, string] | undefined> {
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
cause = await this.tryGetErrorMessage(response); const message = await this.tryGetErrorMessage(response);
error = cause ? "forwarderInvalidTokenWithMessage" : "forwarderInvalidToken"; const key = message ? "forwaderInvalidTokenWithMessage" : "forwaderInvalidToken";
} else if (response.status >= 500) { return [key, message];
cause = await this.tryGetErrorMessage(response); } else if (response.status === 429 || response.status >= 500) {
cause = cause ?? response.statusText; const message = await this.tryGetErrorMessage(response);
error = "forwarderError"; const key = message ? "forwarderError" : "forwarderUnknownError";
return [key, message];
} }
let ok: Response = undefined;
if (!error && rpc.hasJsonPayload(response)) {
[ok, cause] = rpc.processJson(await response.json());
}
// success
if (ok) {
return ok;
}
// failure
if (!error) {
error = cause ? "forwarderError" : "forwarderUnknownError";
}
throw this.i18n.t(error, rpc.requestor.name, cause);
} }
private async tryGetErrorMessage(response: Response) { private async tryGetErrorMessage(response: Response) {
const body = (await response.text()) ?? ""; const body = (await response.text()) ?? "";
if (!body.startsWith("{")) { // nullish continues processing; false returns undefined
const error =
this.tryFindErrorAsJson(body) ?? this.tryFindErrorAsText(body) ?? response.statusText;
return error || undefined;
}
private tryFindErrorAsJson(body: string) {
// tryParse JSON object or string
const parsable = body.startsWith("{") || body.startsWith(`'`) || body.startsWith(`"`);
if (!parsable) {
// fail-and-continue because it's not JSON
return undefined;
}
let parsed = undefined;
try {
parsed = JSON.parse(body);
} catch {
// fail-and-exit in case `body` is malformed JSON
return false;
}
// could be a string
if (parsed && typeof parsed === "string") {
return parsed;
}
// could be { error?: T, message?: U }
const error = parsed.error?.toString() ?? null;
const message = parsed.message?.toString() ?? null;
// `false` signals no message found
const result = error && message ? `${error}: ${message}` : (error ?? message ?? false);
return result;
}
private tryFindErrorAsText(body: string) {
if (!body.length || body.includes("<")) {
return undefined; return undefined;
} }
const json = JSON.parse(body); return body;
if ("error" in json) {
return json.error;
} else if ("message" in json) {
return json.message;
}
return undefined;
} }
} }

View File

@ -0,0 +1,37 @@
import { Jsonify } from "type-fest";
/** Classifies an object's JSON-serializable data by property into
* 3 categories:
* * Disclosed data MAY be stored in plaintext.
* * Excluded data MUST NOT be saved.
* * The remaining data is secret and MUST be stored using encryption.
*
* This type should not be used to classify functions.
* Data that cannot be serialized by JSON.stringify() should
* be excluded.
*/
export interface Classifier<Plaintext, Disclosed, Secret> {
/** Partitions `secret` into its disclosed properties and secret properties.
* @param value The object to partition
* @returns an object that classifies secrets.
* The `disclosed` member is new and contains disclosed properties.
* The `secret` member is a copy of the secret parameter, including its
* prototype, with all disclosed and excluded properties deleted.
*/
classify(value: Plaintext): { disclosed: Jsonify<Disclosed>; secret: Jsonify<Secret> };
/** Merges the properties of `secret` and `disclosed`. When `secret` and
* `disclosed` contain the same property, the `secret` property overrides
* the `disclosed` property.
* @param disclosed an object whose disclosed properties are merged into
* the output. Unknown properties are ignored.
* @param secret an objects whose properties are merged into the output.
* Excluded properties are ignored. Unknown properties are retained.
* @returns a new object containing the merged data.
*
* @remarks Declassified data is always jsonified--the purpose of classifying it is
* to Jsonify it,
* which causes type conversions.
*/
declassify(disclosed: Jsonify<Disclosed>, secret: Jsonify<Secret>): Jsonify<Plaintext>;
}

View File

@ -1,5 +1,7 @@
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
import { Classifier } from "./classifier";
/** Classifies an object's JSON-serializable data by property into /** Classifies an object's JSON-serializable data by property into
* 3 categories: * 3 categories:
* * Disclosed data MAY be stored in plaintext. * * Disclosed data MAY be stored in plaintext.
@ -10,7 +12,9 @@ import { Jsonify } from "type-fest";
* Data that cannot be serialized by JSON.stringify() should * Data that cannot be serialized by JSON.stringify() should
* be excluded. * be excluded.
*/ */
export class SecretClassifier<Plaintext extends object, Disclosed, Secret> { export class SecretClassifier<Plaintext extends object, Disclosed, Secret>
implements Classifier<Plaintext, Disclosed, Secret>
{
private constructor( private constructor(
disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[], disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[],
excluded: readonly (keyof Plaintext)[], excluded: readonly (keyof Plaintext)[],

View File

@ -1,15 +1,19 @@
import { mock } from "jest-mock-extended";
import { Jsonify } from "type-fest";
import { GENERATOR_DISK, UserKeyDefinitionOptions } from "../../platform/state"; import { GENERATOR_DISK, UserKeyDefinitionOptions } from "../../platform/state";
import { SecretClassifier } from "./secret-classifier"; import { Classifier } from "./classifier";
import { SecretKeyDefinition } from "./secret-key-definition"; import { SecretKeyDefinition } from "./secret-key-definition";
describe("SecretKeyDefinition", () => { describe("SecretKeyDefinition", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); type TestData = { foo: boolean };
const classifier = mock<Classifier<any, Record<string, never>, TestData>>();
const options: UserKeyDefinitionOptions<any> = { deserializer: (v: any) => v, clearOn: [] }; const options: UserKeyDefinitionOptions<any> = { deserializer: (v: any) => v, clearOn: [] };
it("toEncryptedStateKey returns a key", () => { it("toEncryptedStateKey returns a key", () => {
const expectedOptions: UserKeyDefinitionOptions<any> = { const expectedOptions: UserKeyDefinitionOptions<TestData> = {
deserializer: (v: any) => v, deserializer: (v: Jsonify<TestData>) => v,
cleanupDelayMs: 100, cleanupDelayMs: 100,
clearOn: ["logout", "lock"], clearOn: ["logout", "lock"],
}; };

View File

@ -2,7 +2,7 @@ import { UserKeyDefinitionOptions, UserKeyDefinition } from "../../platform/stat
// eslint-disable-next-line -- `StateDefinition` used as an argument // eslint-disable-next-line -- `StateDefinition` used as an argument
import { StateDefinition } from "../../platform/state/state-definition"; import { StateDefinition } from "../../platform/state/state-definition";
import { ClassifiedFormat } from "./classified-format"; import { ClassifiedFormat } from "./classified-format";
import { SecretClassifier } from "./secret-classifier"; import { Classifier } from "./classifier";
/** Encryption and storage settings for data stored by a `SecretState`. /** Encryption and storage settings for data stored by a `SecretState`.
*/ */
@ -10,7 +10,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
private constructor( private constructor(
readonly stateDefinition: StateDefinition, readonly stateDefinition: StateDefinition,
readonly key: string, readonly key: string,
readonly classifier: SecretClassifier<Inner, Disclosed, Secret>, readonly classifier: Classifier<Inner, Disclosed, Secret>,
readonly options: UserKeyDefinitionOptions<Inner>, readonly options: UserKeyDefinitionOptions<Inner>,
// type erasure is necessary here because typescript doesn't support // type erasure is necessary here because typescript doesn't support
// higher kinded types that generalize over collections. The invariants // higher kinded types that generalize over collections. The invariants
@ -46,7 +46,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
static value<Value extends object, Disclosed, Secret>( static value<Value extends object, Disclosed, Secret>(
stateDefinition: StateDefinition, stateDefinition: StateDefinition,
key: string, key: string,
classifier: SecretClassifier<Value, Disclosed, Secret>, classifier: Classifier<Value, Disclosed, Secret>,
options: UserKeyDefinitionOptions<Value>, options: UserKeyDefinitionOptions<Value>,
) { ) {
return new SecretKeyDefinition<Value, void, Value, Disclosed, Secret>( return new SecretKeyDefinition<Value, void, Value, Disclosed, Secret>(
@ -70,7 +70,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
static array<Item extends object, Disclosed, Secret>( static array<Item extends object, Disclosed, Secret>(
stateDefinition: StateDefinition, stateDefinition: StateDefinition,
key: string, key: string,
classifier: SecretClassifier<Item, Disclosed, Secret>, classifier: Classifier<Item, Disclosed, Secret>,
options: UserKeyDefinitionOptions<Item>, options: UserKeyDefinitionOptions<Item>,
) { ) {
return new SecretKeyDefinition<Item[], number, Item, Disclosed, Secret>( return new SecretKeyDefinition<Item[], number, Item, Disclosed, Secret>(
@ -94,7 +94,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
static record<Item extends object, Disclosed, Secret, Id extends string | number>( static record<Item extends object, Disclosed, Secret, Id extends string | number>(
stateDefinition: StateDefinition, stateDefinition: StateDefinition,
key: string, key: string,
classifier: SecretClassifier<Item, Disclosed, Secret>, classifier: Classifier<Item, Disclosed, Secret>,
options: UserKeyDefinitionOptions<Item>, options: UserKeyDefinitionOptions<Item>,
) { ) {
return new SecretKeyDefinition<Record<Id, Item>, Id, Item, Disclosed, Secret>( return new SecretKeyDefinition<Record<Id, Item>, Id, Item, Disclosed, Secret>(

View File

@ -14,5 +14,6 @@ export * from "./default-simple-login-options";
export * from "./disabled-passphrase-generator-policy"; export * from "./disabled-passphrase-generator-policy";
export * from "./disabled-password-generator-policy"; export * from "./disabled-password-generator-policy";
export * from "./forwarders"; export * from "./forwarders";
export * from "./integrations";
export * from "./policies"; export * from "./policies";
export * from "./username-digits"; export * from "./username-digits";

View File

@ -0,0 +1,15 @@
import { AddyIo } from "../integration/addy-io";
import { DuckDuckGo } from "../integration/duck-duck-go";
import { Fastmail } from "../integration/fastmail";
import { FirefoxRelay } from "../integration/firefox-relay";
import { ForwardEmail } from "../integration/forward-email";
import { SimpleLogin } from "../integration/simple-login";
export const Integrations = Object.freeze({
AddyIo,
DuckDuckGo,
Fastmail,
FirefoxRelay,
ForwardEmail,
SimpleLogin,
} as const);

View File

@ -0,0 +1,51 @@
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
import { IntegrationConfiguration } from "@bitwarden/common/tools/integration/integration-configuration";
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc/integration-request";
import { RpcConfiguration } from "@bitwarden/common/tools/integration/rpc/rpc-definition";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ForwarderContext } from "./forwarder-context";
/** Mixin for transmitting `getAccountId` result. */
export type AccountRequest = {
accountId?: string;
};
/** definition of the create forwarding request api call for an integration */
export type CreateForwardingEmailRpcDef<
Settings extends ApiSettings,
Request extends IntegrationRequest = IntegrationRequest,
> = RpcConfiguration<Request, ForwarderContext<Settings>, string>;
/** definition of the get account id api call for an integration */
export type GetAccountIdRpcDef<
Settings extends ApiSettings,
Request extends IntegrationRequest = IntegrationRequest,
> = RpcConfiguration<Request, ForwarderContext<Settings>, string>;
/** Forwarder-specific static definition */
export type ForwarderConfiguration<
Settings extends ApiSettings,
Request extends IntegrationRequest = IntegrationRequest,
> = IntegrationConfiguration & {
/** forwarder endpoint definition */
forwarder: {
/** default value of all fields */
defaultSettings: Partial<Settings>;
/** forwarder settings storage */
settings: UserKeyDefinition<Settings>;
/** forwarder settings import buffer; `undefined` when there is no buffer. */
importBuffer?: BufferedKeyDefinition<Settings>;
/** createForwardingEmail RPC definition */
createForwardingEmail: CreateForwardingEmailRpcDef<Settings, Request>;
/** getAccountId RPC definition; the response updates `accountId` which has a
* structural mixin type `RequestAccount`.
*/
getAccountId?: GetAccountIdRpcDef<Settings, Request>;
};
};

View File

@ -0,0 +1,84 @@
import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
import { ForwarderConfiguration } from "./forwarder-configuration";
import { ForwarderContext } from "./forwarder-context";
import { EmailDomainSettings, EmailPrefixSettings } from "./settings";
describe("ForwarderContext", () => {
const i18n = mock<I18nService>({
t(key: string) {
return key;
},
});
describe("emailDomain", () => {
it("returns the domain", () => {
const settings = mock<EmailDomainSettings & ApiSettings>({ domain: "example.com" });
const config = mock<ForwarderConfiguration<typeof settings>>();
const context = new ForwarderContext(config, settings, i18n);
const result = context.emailDomain();
expect(result).toEqual("example.com");
});
it.each([[null], [undefined], [""]])("throws an error if the domain is %p", (domain) => {
const settings = mock<EmailDomainSettings & ApiSettings>({ domain });
const config = mock<ForwarderConfiguration<typeof settings>>();
const context = new ForwarderContext(config, settings, i18n);
expect(() => context.emailDomain()).toThrow("forwarderNoDomain");
});
it("throws an error if the domain is not an enumerable member of settings", () => {
const settings = {} as EmailDomainSettings & ApiSettings;
const config = mock<ForwarderConfiguration<typeof settings>>();
const context = new ForwarderContext(config, settings, i18n);
expect(() => context.emailDomain()).toThrow("forwarderNoDomain");
});
});
describe("emailPrefix", () => {
it("returns the prefix", () => {
const settings = mock<EmailPrefixSettings & ApiSettings>({ prefix: "foo" });
const config = mock<ForwarderConfiguration<typeof settings>>();
const context = new ForwarderContext(config, settings, i18n);
const result = context.emailPrefix();
expect(result).toEqual("foo");
});
it.each([[null], [undefined], [""]])("throws an error if the prefix is %p", (prefix) => {
const settings = mock<EmailPrefixSettings & ApiSettings>({ prefix });
const config = mock<ForwarderConfiguration<typeof settings>>();
const context = new ForwarderContext(config, settings, i18n);
expect(() => context.emailPrefix()).toThrow("forwarderNoPrefix");
});
it("throws an error if the prefix is not an enumerable member of settings", () => {
const settings = {} as EmailPrefixSettings & ApiSettings;
const config = mock<ForwarderConfiguration<typeof settings>>();
const context = new ForwarderContext(config, settings, i18n);
expect(() => context.emailPrefix()).toThrow("forwarderNoPrefix");
});
});
describe("missingAccountIdCause", () => {
it("returns the cause", () => {
const settings = mock<EmailDomainSettings & ApiSettings>();
const config = mock<ForwarderConfiguration<typeof settings>>();
const context = new ForwarderContext(config, settings, i18n);
const result = context.missingAccountIdCause();
expect(result).toEqual("forwarderNoAccountId");
});
});
});

View File

@ -0,0 +1,63 @@
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { IntegrationContext } from "@bitwarden/common/tools/integration/integration-context";
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
import { ForwarderConfiguration } from "./forwarder-configuration";
import { EmailDomainSettings, EmailPrefixSettings } from "./settings";
/**
* Surfaces contextual information to forwarder integrations.
*/
export class ForwarderContext<Settings extends ApiSettings> extends IntegrationContext<Settings> {
/** Instantiates the context.
* @param configuration of the forwarder this context assists
* @param settings loaded from the forwarder's state
* @param i18n localizes error handling
*/
constructor(
readonly configuration: ForwarderConfiguration<Settings>,
settings: Settings,
i18n: I18nService,
) {
super(configuration, settings, i18n);
}
/** look up the domain part of an email address from the forwarder's settings.
* @returns a domain part of an email address
* @throws a localized error message when the domain isn't found.
* @remarks the string is thrown for backwards compatibility
*/
emailDomain(): Settings extends EmailDomainSettings ? string : never {
const domain = "domain" in this.settings ? (this.settings.domain ?? "") : "";
if (domain === "") {
const error = this.i18n.t("forwarderNoDomain", this.configuration.name);
throw error;
}
return domain as any;
}
/** look up a prefix applied to the email address from the forwarder's settings.
* @returns the prefix
* @throws a localized error message when the prefix isn't found.
* @remarks the string is thrown for backwards compatibility
*/
emailPrefix(): Settings extends EmailPrefixSettings ? string : never {
const prefix = "prefix" in this.settings ? (this.settings.prefix ?? "") : "";
if (prefix === "") {
const error = this.i18n.t("forwarderNoPrefix", this.configuration.name);
throw error;
}
return prefix as any;
}
/** look up a localized error message indicating an account id is required
* but wasn't found.
* @remarks this returns a string instead of throwing it so that the
* user can decide upon control flow.
*/
missingAccountIdCause() {
return this.i18n.t("forwarderNoAccountId", this.configuration.name);
}
}

View File

@ -1,4 +1,7 @@
export { CryptoServiceRandomizer } from "./crypto-service-randomizer"; export { CryptoServiceRandomizer } from "./crypto-service-randomizer";
export { ForwarderConfiguration, AccountRequest } from "./forwarder-configuration";
export { ForwarderContext } from "./forwarder-context";
export * from "./settings";
export { EmailRandomizer } from "./email-randomizer"; export { EmailRandomizer } from "./email-randomizer";
export { EmailCalculator } from "./email-calculator"; export { EmailCalculator } from "./email-calculator";
export { PasswordRandomizer } from "./password-randomizer"; export { PasswordRandomizer } from "./password-randomizer";

View File

@ -0,0 +1,117 @@
import { mock } from "jest-mock-extended";
import {
ApiSettings,
IntegrationRequest,
TokenHeader,
} from "@bitwarden/common/tools/integration/rpc";
import { CreateForwardingEmailRpcDef, ForwarderConfiguration } from "../forwarder-configuration";
import { ForwarderContext } from "../forwarder-context";
import { CreateForwardingAddressRpc } from "./create-forwarding-address";
describe("CreateForwardingAddressRpc", () => {
const createForwardingEmail =
mock<CreateForwardingEmailRpcDef<ApiSettings, IntegrationRequest>>();
const requestor = mock<ForwarderConfiguration<ApiSettings>>({
forwarder: { createForwardingEmail },
});
const context = mock<ForwarderContext<ApiSettings>>();
beforeEach(() => {
createForwardingEmail.url.mockReturnValue("https://httpbin.org/json");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("toRequest", () => {
it("constructs a request", () => {
const builder = new CreateForwardingAddressRpc(requestor, context);
const result = builder.toRequest({ website: null });
expect(result.redirect).toEqual("manual");
expect(result.cache).toEqual("no-store");
expect(result.method).toEqual("POST");
expect(result.headers.get("Content-Type")).toEqual("application/json");
expect(result.headers.get("X-Requested-With")).toEqual("XMLHttpRequest");
});
it("provides the request and context to the rpc definition functions", () => {
const builder = new CreateForwardingAddressRpc(requestor, context);
const request: IntegrationRequest = { website: null };
builder.toRequest(request);
expect(requestor.authenticate).toHaveBeenCalledWith(request, context);
expect(createForwardingEmail.url).toHaveBeenCalledWith(request, context);
expect(createForwardingEmail.body).toHaveBeenCalledWith(request, context);
});
it("stringifies the body", async () => {
createForwardingEmail.body.mockReturnValue({ foo: 1 });
const builder = new CreateForwardingAddressRpc(requestor, context);
const request = builder.toRequest({ website: null });
// extract the text from the body; it's wild there isn't
// a more clear way to do this
const result = await new Response(request.body).text();
expect(result).toEqual('{"foo":1}');
});
it("omits the body", async () => {
// can't use the mock here because it defines a `body` function
// on `createForwardingEmail`
const requestor = {
authenticate() {
return undefined as TokenHeader;
},
forwarder: {
createForwardingEmail: {
url() {
return "https://httpbin.org/json";
},
},
},
} as unknown as ForwarderConfiguration<ApiSettings>;
const builder = new CreateForwardingAddressRpc(requestor, context);
const result = builder.toRequest({ website: null });
expect(result.body).toBeNull();
});
});
describe("hasJsonPayload", () => {
it("forwards the call to the rpc definition with context", () => {
const builder = new CreateForwardingAddressRpc(requestor, context);
const response: Response = {} as any;
createForwardingEmail.hasJsonPayload.mockReturnValue(true);
const result = builder.hasJsonPayload(response);
expect(result).toBe(true);
expect(createForwardingEmail.hasJsonPayload).toHaveBeenCalledWith(response, context);
});
});
describe("processJson", () => {
it("forwards the call to the rpc definition with context", () => {
const builder = new CreateForwardingAddressRpc(requestor, context);
const json = {} as any;
createForwardingEmail.processJson.mockReturnValue(["foo"]);
const result = builder.processJson(json);
expect(result).toEqual(["foo"]);
expect(createForwardingEmail.processJson).toHaveBeenCalledWith(json, context);
});
});
});

View File

@ -0,0 +1,64 @@
import { IntegrationContext } from "@bitwarden/common/tools/integration";
import { JsonRpc, IntegrationRequest, ApiSettings } from "@bitwarden/common/tools/integration/rpc";
import { ForwarderConfiguration } from "../forwarder-configuration";
import { ForwarderContext } from "../forwarder-context";
export class CreateForwardingAddressRpc<
Settings extends ApiSettings,
Req extends IntegrationRequest = IntegrationRequest,
> implements JsonRpc<Req, string>
{
constructor(
readonly requestor: ForwarderConfiguration<Settings>,
readonly context: ForwarderContext<Settings>,
) {}
private get createForwardingEmail() {
return this.requestor.forwarder.createForwardingEmail;
}
toRequest(req: Req) {
const url = this.createForwardingEmail.url(req, this.context);
const token = this.requestor.authenticate(req, this.context as IntegrationContext<Settings>);
const body = this.body(req);
const request = new Request(url, {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
...token,
// X-Requested-With header required by some endpoints for
// detailed error descriptions (see #5565)
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/json",
}),
body,
});
return request;
}
private body(req: Req) {
const toBody = this.createForwardingEmail.body;
if (!toBody) {
return undefined;
}
const body = toBody(req, this.context);
if (!body) {
return undefined;
}
return JSON.stringify(body);
}
hasJsonPayload(response: Response): boolean {
return this.createForwardingEmail.hasJsonPayload(response, this.context);
}
processJson(json: any): [string?, string?] {
return this.createForwardingEmail.processJson(json, this.context);
}
}

View File

@ -0,0 +1,83 @@
import { mock } from "jest-mock-extended";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { GetAccountIdRpcDef, ForwarderConfiguration } from "../forwarder-configuration";
import { ForwarderContext } from "../forwarder-context";
import { GetAccountIdRpc } from "./get-account-id";
describe("GetAccountIdRpc", () => {
const getAccountId = mock<GetAccountIdRpcDef<ApiSettings, IntegrationRequest>>();
const requestor = mock<ForwarderConfiguration<ApiSettings>>({
forwarder: { getAccountId },
});
const context = mock<ForwarderContext<ApiSettings>>();
beforeEach(() => {
getAccountId.url.mockReturnValue("https://httpbin.org/json");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("toRequest", () => {
it("constructs a request", () => {
const builder = new GetAccountIdRpc(requestor, context);
const result = builder.toRequest({ website: null });
expect(result.redirect).toEqual("manual");
expect(result.cache).toEqual("no-store");
expect(result.method).toEqual("GET");
expect(result.headers.get("Content-Type")).toEqual("application/json");
});
it("provides the request and context to the rpc definition functions", () => {
const builder = new GetAccountIdRpc(requestor, context);
const request: IntegrationRequest = { website: null };
builder.toRequest(request);
expect(requestor.authenticate).toHaveBeenCalledWith(request, context);
expect(getAccountId.url).toHaveBeenCalledWith(request, context);
});
it("omits the body", async () => {
const builder = new GetAccountIdRpc(requestor, context);
const result = builder.toRequest({ website: null });
expect(result.body).toBeNull();
expect(getAccountId.body).not.toHaveBeenCalled();
});
});
describe("hasJsonPayload", () => {
it("forwards the call to the rpc definition with context", () => {
const builder = new GetAccountIdRpc(requestor, context);
const response: Response = {} as any;
getAccountId.hasJsonPayload.mockReturnValue(true);
const result = builder.hasJsonPayload(response);
expect(result).toBe(true);
expect(getAccountId.hasJsonPayload).toHaveBeenCalledWith(response, context);
});
});
describe("processJson", () => {
it("forwards the call to the rpc definition with context", () => {
const builder = new GetAccountIdRpc(requestor, context);
const json = {} as any;
getAccountId.processJson.mockReturnValue(["foo"]);
const result = builder.processJson(json);
expect(result).toEqual(["foo"]);
expect(getAccountId.processJson).toHaveBeenCalledWith(json, context);
});
});
});

View File

@ -0,0 +1,41 @@
import { IntegrationContext } from "@bitwarden/common/tools/integration";
import { JsonRpc, IntegrationRequest, ApiSettings } from "@bitwarden/common/tools/integration/rpc";
import { ForwarderConfiguration } from "../forwarder-configuration";
import { ForwarderContext } from "../forwarder-context";
export class GetAccountIdRpc<
Settings extends ApiSettings,
Req extends IntegrationRequest = IntegrationRequest,
> implements JsonRpc<Req, string>
{
constructor(
readonly requestor: ForwarderConfiguration<Settings>,
readonly context: ForwarderContext<Settings>,
) {}
hasJsonPayload(response: Response) {
return this.requestor.forwarder.getAccountId.hasJsonPayload(response, this.context);
}
processJson(json: any) {
return this.requestor.forwarder.getAccountId.processJson(json, this.context);
}
toRequest(req: Req) {
const url = this.requestor.forwarder.getAccountId.url(req, this.context);
const token = this.requestor.authenticate(req, this.context as IntegrationContext<Settings>);
const request = new Request(url, {
redirect: "manual",
cache: "no-store",
method: "GET",
headers: new Headers({
...token,
"Content-Type": "application/json",
}),
});
return request;
}
}

View File

@ -0,0 +1,2 @@
export * from "./create-forwarding-address";
export * from "./get-account-id";

View File

@ -0,0 +1,20 @@
/** Api configuration for forwarders that support custom domains. */
export type EmailDomainSettings = {
/** The domain part of the generated email address.
* @remarks The domain should be authorized by the forwarder before
* submitting a request through bitwarden.
* @example If the domain is `domain.io` and the generated username
* is `jd`, then the generated email address will be `jd@domain.io`
*/
domain: string;
};
/** Api configuration for forwarders that support custom email parts. */
export type EmailPrefixSettings = {
/** A prefix joined to the generated email address' username.
* @example If the prefix is `foo`, the generated username is `bar`,
* and the domain is `domain.io`, then the generated email address is `
* then the generated username is `foobar@domain.io`.
*/
prefix: string;
};

View File

@ -2,6 +2,7 @@ export * from "./abstractions";
export * from "./data"; export * from "./data";
export { createRandomizer } from "./factories"; export { createRandomizer } from "./factories";
export * as engine from "./engine"; export * as engine from "./engine";
export * as integration from "./integration";
export * as policies from "./policies"; export * as policies from "./policies";
export * as rx from "./rx"; export * as rx from "./rx";
export * as services from "./services"; export * as services from "./services";

View File

@ -0,0 +1,83 @@
import { mock } from "jest-mock-extended";
import { ForwarderContext } from "../engine";
import { AddyIo, AddyIoSettings } from "./addy-io";
describe("Addy.io forwarder", () => {
const context = mock<ForwarderContext<AddyIoSettings>>();
afterEach(() => {
jest.resetAllMocks();
});
describe("authenticate", () => {
it("returns a bearer header with the token", () => {
context.authenticationToken.mockReturnValue("token");
const result = AddyIo.authenticate(null, context);
expect(result).toEqual({ Authorization: "Bearer token" });
expect(context.authenticationToken).toHaveBeenCalled();
});
});
describe("settings", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = AddyIo.forwarder.settings.deserializer(value);
expect(result).toBe(value);
});
});
describe("importBuffer", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = AddyIo.forwarder.importBuffer.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("createForwardingEmail", () => {
describe("url", () => {
it("returns the alias path", () => {
context.baseUrl.mockReturnValue("");
const result = AddyIo.forwarder.createForwardingEmail.url(null, context);
expect(result).toEqual("/api/v1/aliases");
});
});
describe("body", () => {
it("returns the alias path", () => {
context.emailDomain.mockReturnValue("domain");
context.generatedBy.mockReturnValue("generated by");
const result = AddyIo.forwarder.createForwardingEmail.body(null, context);
expect(result).toEqual({
domain: "domain",
description: "generated by",
});
});
});
describe("hasJsonPayload", () => {
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
const result = AddyIo.forwarder.createForwardingEmail.hasJsonPayload(
{ status } as Response,
context,
);
expect(result).toBeTruthy();
});
});
describe("processJson", () => {
it("should read the email from the response", () => {
const json = { data: { email: "foo@example.com" } };
const result = AddyIo.forwarder.createForwardingEmail.processJson(json, context);
expect(result).toEqual(["foo@example.com"]);
});
});
});
});

View File

@ -0,0 +1,73 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import {
ApiSettings,
IntegrationRequest,
SelfHostedApiSettings,
} from "@bitwarden/common/tools/integration/rpc";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
import { EmailDomainOptions, SelfHostedApiOptions } from "../types";
// integration types
export type AddyIoSettings = SelfHostedApiSettings & EmailDomainSettings;
export type AddyIoOptions = SelfHostedApiOptions & EmailDomainOptions;
export type AddyIoConfiguration = ForwarderConfiguration<AddyIoSettings>;
// default values
const defaultSettings = Object.freeze({
token: "",
domain: "",
});
// supported RPC calls
const createForwardingEmail = Object.freeze({
url(_request: IntegrationRequest, context: ForwarderContext<AddyIoSettings>) {
return context.baseUrl() + "/api/v1/aliases";
},
body(request: IntegrationRequest, context: ForwarderContext<AddyIoSettings>) {
return {
domain: context.emailDomain(),
description: context.generatedBy(request),
};
},
hasJsonPayload(response: Response) {
return response.status === 200 || response.status === 201;
},
processJson(json: any) {
return [json?.data?.email];
},
} as CreateForwardingEmailRpcDef<AddyIoSettings>);
// forwarder configuration
const forwarder = Object.freeze({
defaultSettings,
settings: new UserKeyDefinition<AddyIoSettings>(GENERATOR_DISK, "addyIoForwarder", {
deserializer: (value) => value,
clearOn: [],
}),
importBuffer: new BufferedKeyDefinition<AddyIoSettings>(GENERATOR_DISK, "addyIoBuffer", {
deserializer: (value) => value,
clearOn: ["logout"],
}),
createForwardingEmail,
} as const);
export const AddyIo = Object.freeze({
// integration
id: "anonaddy" as IntegrationId,
name: "Addy.io",
extends: ["forwarder"],
// hosting
selfHost: "maybe",
baseUrl: "https://app.addy.io",
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
return { Authorization: "Bearer " + context.authenticationToken() };
},
// extensions
forwarder,
} as AddyIoConfiguration);

View File

@ -0,0 +1,78 @@
import { mock } from "jest-mock-extended";
import { ForwarderContext } from "../engine";
import { DuckDuckGo, DuckDuckGoSettings } from "./duck-duck-go";
describe("DuckDuckGo forwarder", () => {
const context = mock<ForwarderContext<DuckDuckGoSettings>>();
afterEach(() => {
jest.resetAllMocks();
});
describe("authenticate", () => {
it("returns a bearer header with the token", () => {
context.authenticationToken.mockReturnValue("token");
const result = DuckDuckGo.authenticate(null, context);
expect(result).toEqual({ Authorization: "Bearer token" });
expect(context.authenticationToken).toHaveBeenCalled();
});
});
describe("settings", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = DuckDuckGo.forwarder.settings.deserializer(value);
expect(result).toBe(value);
});
});
describe("importBuffer", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = DuckDuckGo.forwarder.importBuffer.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("createForwardingEmail", () => {
describe("url", () => {
it("returns the alias path", () => {
context.baseUrl.mockReturnValue("");
const result = DuckDuckGo.forwarder.createForwardingEmail.url(null, context);
expect(result).toEqual("/email/addresses");
});
});
describe("body", () => {
it("returns undefined", () => {
const result = DuckDuckGo.forwarder.createForwardingEmail.body(null, context);
expect(result).not.toBeDefined();
});
});
describe("hasJsonPayload", () => {
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
const result = DuckDuckGo.forwarder.createForwardingEmail.hasJsonPayload(
{ status } as Response,
context,
);
expect(result).toBeTruthy();
});
});
describe("processJson", () => {
it("should read the email from the response", () => {
const json = { address: "foo" };
const result = DuckDuckGo.forwarder.createForwardingEmail.processJson(json, context);
expect(result).toEqual(["foo@duck.com"]);
});
});
});
});

View File

@ -0,0 +1,61 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ForwarderConfiguration, ForwarderContext } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
import { ApiOptions } from "../types";
// integration types
export type DuckDuckGoSettings = ApiSettings;
export type DuckDuckGoOptions = ApiOptions;
export type DuckDuckGoConfiguration = ForwarderConfiguration<DuckDuckGoSettings>;
// default values
const defaultSettings = Object.freeze({
token: "",
});
// supported RPC calls
const createForwardingEmail = Object.freeze({
url(_request: IntegrationRequest, context: ForwarderContext<DuckDuckGoSettings>) {
return context.baseUrl() + "/email/addresses";
},
body(_request: IntegrationRequest, _context: ForwarderContext<DuckDuckGoSettings>) {
return undefined;
},
hasJsonPayload(response: Response) {
return response.status === 200 || response.status === 201;
},
processJson(json: any) {
return [`${json.address}@duck.com`];
},
} as CreateForwardingEmailRpcDef<DuckDuckGoSettings>);
// forwarder configuration
const forwarder = Object.freeze({
defaultSettings,
settings: new UserKeyDefinition<DuckDuckGoSettings>(GENERATOR_DISK, "duckDuckGoForwarder", {
deserializer: (value) => value,
clearOn: [],
}),
importBuffer: new BufferedKeyDefinition<DuckDuckGoSettings>(GENERATOR_DISK, "duckDuckGoBuffer", {
deserializer: (value) => value,
clearOn: ["logout"],
}),
createForwardingEmail,
} as const);
// integration-wide configuration
export const DuckDuckGo = Object.freeze({
id: "duckduckgo" as IntegrationId,
name: "DuckDuckGo",
baseUrl: "https://quack.duckduckgo.com/api",
selfHost: "never",
extends: ["forwarder"],
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
return { Authorization: "Bearer " + context.authenticationToken() };
},
forwarder,
} as DuckDuckGoConfiguration);

View File

@ -0,0 +1,215 @@
import { mock } from "jest-mock-extended";
import { ForwarderContext } from "../engine";
import { Fastmail, FastmailSettings } from "./fastmail";
describe("Fastmail forwarder", () => {
const context = mock<ForwarderContext<FastmailSettings>>();
afterEach(() => {
jest.resetAllMocks();
});
describe("authenticate", () => {
it("returns a bearer header with the token", () => {
context.authenticationToken.mockReturnValue("token");
const result = Fastmail.authenticate(null, context);
expect(result).toEqual({ Authorization: "Bearer token" });
expect(context.authenticationToken).toHaveBeenCalled();
});
});
describe("settings", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = Fastmail.forwarder.settings.deserializer(value);
expect(result).toBe(value);
});
});
describe("importBuffer", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = Fastmail.forwarder.importBuffer.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("getAccountId", () => {
describe("url", () => {
it("returns the alias path", () => {
context.baseUrl.mockReturnValue("");
const result = Fastmail.forwarder.getAccountId.url(null, context);
expect(result).toEqual("/jmap/session");
});
});
describe("hasJsonPayload", () => {
it.each([[200]])("returns true when the status is $%i", (status) => {
const result = Fastmail.forwarder.getAccountId.hasJsonPayload(
{ status } as Response,
context,
);
expect(result).toBeTruthy();
});
});
describe("processJson", () => {
it("looks up an account id", () => {
const json = {
primaryAccounts: {
"https://www.fastmail.com/dev/maskedemail": "some id",
},
};
const result = Fastmail.forwarder.getAccountId.processJson(json, context);
expect(result).toEqual(["some id"]);
});
it("returns a cause when account id is missing", () => {
context.missingAccountIdCause.mockReturnValue("cause");
const json = {
primaryAccounts: {
"https://www.fastmail.com/dev/maskedemail": null as string,
},
};
const result = Fastmail.forwarder.getAccountId.processJson(json, context);
expect(result).toEqual([undefined, "cause"]);
});
it("returns a cause when masked mail account is missing", () => {
context.missingAccountIdCause.mockReturnValue("cause");
const json = { primaryAccounts: {} };
const result = Fastmail.forwarder.getAccountId.processJson(json, context);
expect(result).toEqual([undefined, "cause"]);
});
it("returns a cause when all accounts are missing", () => {
context.missingAccountIdCause.mockReturnValue("cause");
const json = { primaryAccounts: null as any };
const result = Fastmail.forwarder.getAccountId.processJson(json, context);
expect(result).toEqual([undefined, "cause"]);
});
});
});
describe("createForwardingEmail", () => {
describe("url", () => {
it("returns the alias path", () => {
context.baseUrl.mockReturnValue("");
const result = Fastmail.forwarder.createForwardingEmail.url(null, context);
expect(result).toEqual("/jmap/api/");
});
});
describe("body", () => {
it("creates a request body", () => {
context.website.mockReturnValue("website");
const request = { accountId: "accountId", website: "" };
const result = Fastmail.forwarder.createForwardingEmail.body(request, context);
const methodCall = result.methodCalls[0][1];
expect(methodCall.accountId).toEqual("accountId");
expect(methodCall.create["new-masked-email"].forDomain).toEqual("website");
});
});
describe("hasJsonPayload", () => {
it.each([[200]])("returns true when the status is $%i", (status) => {
const result = Fastmail.forwarder.createForwardingEmail.hasJsonPayload(
{ status } as Response,
context,
);
expect(result).toBeTruthy();
});
});
describe("processJson", () => {
it("returns the generated email address", () => {
const body = {
methodResponses: [
[
"MaskedEmail/set",
{
created: {
"new-masked-email": {
email: "jdoe@example.com",
},
},
},
],
],
};
const result = Fastmail.forwarder.createForwardingEmail.processJson(body, context);
expect(result).toEqual(["jdoe@example.com"]);
});
it("returns a forwarder error if masked email creation fails", () => {
const notCreatedBody = {
methodResponses: [
[
"MaskedEmail/set",
{
notCreated: { "new-masked-email": { description: "It turned inside out!" } },
},
],
],
};
const notCreatedResult = Fastmail.forwarder.createForwardingEmail.processJson(
notCreatedBody,
context,
);
expect(notCreatedResult).toEqual([undefined, "It turned inside out!"]);
const generalErrorBody = {
methodResponses: [["error", { description: "And then it exploded!" }]],
};
const generalErrorResult = Fastmail.forwarder.createForwardingEmail.processJson(
generalErrorBody,
context,
);
expect(generalErrorResult).toEqual([undefined, "And then it exploded!"]);
});
it.each([
null,
[],
[[]],
[["MaskedEmail/not-a-real-op"]],
[["MaskedEmail/set", null]],
[["MaskedEmail/set", { created: null }]],
[["MaskedEmail/set", { created: { "new-masked-email": null } }]],
[["MaskedEmail/set", { notCreated: null }]],
[["MaskedEmail/set", { notCreated: { "new-masked-email": null } }]],
])("returns undefined if the jmap request is malformed (=%p)", (response: any) => {
const generalErrorBody = {
methodResponses: response,
};
const result = Fastmail.forwarder.createForwardingEmail.processJson(
generalErrorBody,
context,
);
expect(result).toBeUndefined();
});
});
});
});

View File

@ -0,0 +1,127 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import {
ForwarderConfiguration,
ForwarderContext,
EmailDomainSettings,
AccountRequest,
EmailPrefixSettings,
} from "../engine";
import { CreateForwardingEmailRpcDef, GetAccountIdRpcDef } from "../engine/forwarder-configuration";
import { ApiOptions, EmailPrefixOptions } from "../types";
// integration types
export type FastmailSettings = ApiSettings & EmailPrefixSettings & EmailDomainSettings;
export type FastmailOptions = ApiOptions & EmailPrefixOptions & AccountRequest;
export type FastmailRequest = IntegrationRequest & AccountRequest;
export type FastmailConfiguration = ForwarderConfiguration<FastmailSettings, FastmailRequest>;
// default values
const defaultSettings = Object.freeze({
domain: "",
prefix: "",
token: "",
});
// supported RPC calls
const getAccountId = Object.freeze({
url(_request: IntegrationRequest, context: ForwarderContext<FastmailSettings>) {
// cannot use "/.well-known/jmap" because integration RPCs
// never follow redirects
return context.baseUrl() + "/jmap/session";
},
hasJsonPayload(response: Response) {
return response.status === 200;
},
processJson(json: any, context: ForwarderContext<FastmailSettings>) {
const result = json.primaryAccounts?.["https://www.fastmail.com/dev/maskedemail"] ?? undefined;
return [result, result ? undefined : context.missingAccountIdCause()];
},
} as GetAccountIdRpcDef<FastmailSettings>);
const createForwardingEmail = Object.freeze({
url(_request: IntegrationRequest, context: ForwarderContext<FastmailSettings>) {
return context.baseUrl() + "/jmap/api/";
},
body(request: FastmailRequest, context: ForwarderContext<FastmailSettings>) {
const body = {
using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"],
methodCalls: [
[
"MaskedEmail/set",
{
accountId: request.accountId,
create: {
"new-masked-email": {
state: "enabled",
description: "",
forDomain: context.website(request),
emailPrefix: "",
},
},
},
"0",
],
],
};
return body;
},
hasJsonPayload(response: Response) {
return response.status === 200;
},
processJson(json: any): [string?, string?] {
if (
json.methodResponses != null &&
json.methodResponses.length > 0 &&
json.methodResponses[0].length > 0
) {
if (json.methodResponses[0][0] === "MaskedEmail/set") {
if (json.methodResponses[0][1]?.created?.["new-masked-email"] != null) {
const email: string = json.methodResponses[0][1]?.created?.["new-masked-email"]?.email;
return [email];
}
if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) {
const errorDescription: string =
json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description;
return [undefined, errorDescription];
}
} else if (json.methodResponses[0][0] === "error") {
const errorDescription: string = json.methodResponses[0][1]?.description;
return [undefined, errorDescription];
}
}
},
} as CreateForwardingEmailRpcDef<FastmailSettings, FastmailRequest>);
// forwarder configuration
const forwarder = Object.freeze({
defaultSettings,
settings: new UserKeyDefinition<FastmailSettings>(GENERATOR_DISK, "fastmailForwarder", {
deserializer: (value) => value,
clearOn: [],
}),
importBuffer: new BufferedKeyDefinition<FastmailSettings>(GENERATOR_DISK, "fastmailBuffer", {
deserializer: (value) => value,
clearOn: ["logout"],
}),
createForwardingEmail,
getAccountId,
} as const);
// integration-wide configuration
export const Fastmail = Object.freeze({
id: "fastmail" as IntegrationId,
name: "Fastmail",
baseUrl: "https://api.fastmail.com",
selfHost: "maybe",
extends: ["forwarder"],
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
return { Authorization: "Bearer " + context.authenticationToken() };
},
forwarder,
} as FastmailConfiguration);

View File

@ -0,0 +1,85 @@
import { mock } from "jest-mock-extended";
import { ForwarderContext } from "../engine";
import { FirefoxRelay, FirefoxRelaySettings } from "./firefox-relay";
describe("Firefox Relay forwarder", () => {
const context = mock<ForwarderContext<FirefoxRelaySettings>>();
afterEach(() => {
jest.resetAllMocks();
});
describe("authenticate", () => {
it("returns a token header", () => {
context.authenticationToken.mockReturnValue("token");
const result = FirefoxRelay.authenticate(null, context);
expect(result).toEqual({ Authorization: "Token token" });
expect(context.authenticationToken).toHaveBeenCalled();
});
});
describe("settings", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FirefoxRelay.forwarder.settings.deserializer(value);
expect(result).toBe(value);
});
});
describe("importBuffer", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FirefoxRelay.forwarder.importBuffer.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("createForwardingEmail", () => {
describe("url", () => {
it("returns the alias path", () => {
context.baseUrl.mockReturnValue("");
const result = FirefoxRelay.forwarder.createForwardingEmail.url(null, context);
expect(result).toEqual("/v1/relayaddresses/");
});
});
describe("body", () => {
it("returns the alias path", () => {
context.website.mockReturnValue("website");
context.generatedBy.mockReturnValue("generated by");
const result = FirefoxRelay.forwarder.createForwardingEmail.body(null, context);
expect(result).toEqual({
enabled: true,
generated_for: "website",
description: "generated by",
});
});
});
describe("hasJsonPayload", () => {
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
const result = FirefoxRelay.forwarder.createForwardingEmail.hasJsonPayload(
{ status } as Response,
context,
);
expect(result).toBeTruthy();
});
});
describe("processJson", () => {
it("should read the email from the response", () => {
const json = { full_address: "foo@example.com" };
const result = FirefoxRelay.forwarder.createForwardingEmail.processJson(json, context);
expect(result).toEqual(["foo@example.com"]);
});
});
});
});

View File

@ -0,0 +1,69 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ForwarderConfiguration, ForwarderContext } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
import { ApiOptions } from "../types";
// integration types
export type FirefoxRelaySettings = ApiSettings;
export type FirefoxRelayOptions = ApiOptions;
export type FirefoxRelayConfiguration = ForwarderConfiguration<FirefoxRelaySettings>;
// default values
const defaultSettings = Object.freeze({
token: "",
} as FirefoxRelaySettings);
// supported RPC calls
const createForwardingEmail = Object.freeze({
url(_request: IntegrationRequest, context: ForwarderContext<FirefoxRelaySettings>) {
return context.baseUrl() + "/v1/relayaddresses/";
},
body(request: IntegrationRequest, context: ForwarderContext<FirefoxRelaySettings>) {
return {
enabled: true,
generated_for: context.website(request),
description: context.generatedBy(request),
};
},
hasJsonPayload(response: Response) {
return response.status === 200 || response.status === 201;
},
processJson(json: any) {
return [json.full_address];
},
} as CreateForwardingEmailRpcDef<FirefoxRelaySettings>);
// forwarder configuration
const forwarder = Object.freeze({
defaultSettings,
settings: new UserKeyDefinition<FirefoxRelaySettings>(GENERATOR_DISK, "firefoxRelayForwarder", {
deserializer: (value) => value,
clearOn: [],
}),
importBuffer: new BufferedKeyDefinition<FirefoxRelaySettings>(
GENERATOR_DISK,
"firefoxRelayBuffer",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
),
createForwardingEmail,
} as const);
// integration-wide configuration
export const FirefoxRelay = Object.freeze({
id: "firefoxrelay" as IntegrationId,
name: "Firefox Relay",
baseUrl: "https://relay.firefox.com/api",
selfHost: "never",
extends: ["forwarder"],
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
return { Authorization: "Token " + context.authenticationToken() };
},
forwarder,
} as FirefoxRelayConfiguration);

View File

@ -0,0 +1,96 @@
import { mock } from "jest-mock-extended";
import { ForwarderContext } from "../engine";
import { ForwardEmail, ForwardEmailSettings } from "./forward-email";
describe("Addy.io forwarder", () => {
const context = mock<ForwarderContext<ForwardEmailSettings>>();
afterEach(() => {
jest.resetAllMocks();
});
describe("authenticate", () => {
it("returns a bearer header with the token", () => {
context.authenticationToken.mockReturnValue("token");
const result = ForwardEmail.authenticate(null, context);
expect(result).toEqual({ Authorization: "Basic token" });
expect(context.authenticationToken).toHaveBeenCalledWith({ base64: true, suffix: ":" });
});
});
describe("settings", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = ForwardEmail.forwarder.settings.deserializer(value);
expect(result).toBe(value);
});
});
describe("importBuffer", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = ForwardEmail.forwarder.importBuffer.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("createForwardingEmail", () => {
describe("url", () => {
it("returns the alias path", () => {
context.baseUrl.mockReturnValue("");
context.emailDomain.mockReturnValue("email domain");
const result = ForwardEmail.forwarder.createForwardingEmail.url(null, context);
expect(result).toEqual("/v1/domains/email domain/aliases");
});
});
describe("body", () => {
it("returns the alias path", () => {
context.website.mockReturnValue("website");
context.generatedBy.mockReturnValue("generated by");
const result = ForwardEmail.forwarder.createForwardingEmail.body(null, context);
expect(result).toEqual({
labels: "website",
description: "generated by",
});
});
});
describe("hasJsonPayload", () => {
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
const result = ForwardEmail.forwarder.createForwardingEmail.hasJsonPayload(
{ status } as Response,
context,
);
expect(result).toBeTruthy();
});
});
describe("processJson", () => {
it("should read the email from the response", () => {
const json = { name: "foo", domain: { name: "example.com" } };
const result = ForwardEmail.forwarder.createForwardingEmail.processJson(json, context);
expect(result).toEqual(["foo@example.com"]);
});
it("should use the domain from the request when it is not specified", () => {
context.emailDomain.mockReturnValue("example.com");
const json = { name: "foo" };
const result = ForwardEmail.forwarder.createForwardingEmail.processJson(json, context);
expect(result).toEqual(["foo@example.com"]);
});
});
});
});

View File

@ -0,0 +1,76 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
import { ApiOptions, EmailDomainOptions } from "../types";
// integration types
export type ForwardEmailSettings = ApiSettings & EmailDomainSettings;
export type ForwardEmailOptions = ApiOptions & EmailDomainOptions;
export type ForwardEmailConfiguration = ForwarderConfiguration<ForwardEmailSettings>;
// default values
const defaultSettings = Object.freeze({
token: "",
domain: "",
});
// supported RPC calls
const createForwardingEmail = Object.freeze({
url(_request: IntegrationRequest, context: ForwarderContext<ForwardEmailSettings>) {
const domain = context.emailDomain();
return context.baseUrl() + `/v1/domains/${domain}/aliases`;
},
body(request: IntegrationRequest, context: ForwarderContext<ForwardEmailSettings>) {
return {
labels: context.website(request),
description: context.generatedBy(request),
};
},
hasJsonPayload(response: Response) {
return response.status === 200 || response.status === 201;
},
processJson(json: any, context: ForwarderContext<ForwardEmailSettings>) {
const { name, domain } = json;
const domainPart = domain?.name ?? context.emailDomain();
return [`${name}@${domainPart}`];
},
} as CreateForwardingEmailRpcDef<ForwardEmailSettings>);
// forwarder configuration
const forwarder = Object.freeze({
defaultSettings,
settings: new UserKeyDefinition<ForwardEmailSettings>(GENERATOR_DISK, "forwardEmailForwarder", {
deserializer: (value) => value,
clearOn: [],
}),
importBuffer: new BufferedKeyDefinition<ForwardEmailSettings>(
GENERATOR_DISK,
"forwardEmailBuffer",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
),
createForwardingEmail,
} as const);
export const ForwardEmail = Object.freeze({
// integration metadata
id: "forwardemail" as IntegrationId,
name: "Forward Email",
extends: ["forwarder"],
// service provider
selfHost: "never",
baseUrl: "https://api.forwardemail.net",
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
return { Authorization: "Basic " + context.authenticationToken({ base64: true, suffix: ":" }) };
},
// specialized configurations
forwarder,
} as ForwardEmailConfiguration);

View File

@ -0,0 +1,6 @@
export * from "./addy-io";
export * from "./duck-duck-go";
export * from "./fastmail";
export * from "./firefox-relay";
export * from "./forward-email";
export * from "./simple-login";

View File

@ -0,0 +1,92 @@
import { mock } from "jest-mock-extended";
import { ForwarderContext } from "../engine";
import { SimpleLogin, SimpleLoginSettings } from "./simple-login";
describe("Addy.io forwarder", () => {
const context = mock<ForwarderContext<SimpleLoginSettings>>();
afterEach(() => {
jest.resetAllMocks();
});
describe("authenticate", () => {
it("returns a bearer header with the token", () => {
context.authenticationToken.mockReturnValue("token");
const result = SimpleLogin.authenticate(null, context);
expect(result).toEqual({ Authentication: "token" });
expect(context.authenticationToken).toHaveBeenCalled();
});
});
describe("settings", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = SimpleLogin.forwarder.settings.deserializer(value);
expect(result).toBe(value);
});
});
describe("importBuffer", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = SimpleLogin.forwarder.importBuffer.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("createForwardingEmail", () => {
describe("url", () => {
it("returns the alias path", () => {
context.website.mockReturnValue("");
context.baseUrl.mockReturnValue("");
const result = SimpleLogin.forwarder.createForwardingEmail.url(null, context);
expect(result).toEqual("/api/alias/random/new");
});
it("includes the website in the alias path", () => {
context.baseUrl.mockReturnValue("");
context.website.mockReturnValue("website");
const result = SimpleLogin.forwarder.createForwardingEmail.url(null, context);
expect(result).toEqual("/api/alias/random/new?hostname=website");
});
});
describe("body", () => {
it("returns the alias path", () => {
context.generatedBy.mockReturnValue("generated by");
const result = SimpleLogin.forwarder.createForwardingEmail.body(null, context);
expect(result).toEqual({
note: "generated by",
});
});
});
describe("hasJsonPayload", () => {
it.each([[200], [201]])("returns true when the status is $%i", (status) => {
const result = SimpleLogin.forwarder.createForwardingEmail.hasJsonPayload(
{ status } as Response,
context,
);
expect(result).toBeTruthy();
});
});
describe("processJson", () => {
it("should read the email from the response", () => {
const json = { alias: "foo@example.com" };
const result = SimpleLogin.forwarder.createForwardingEmail.processJson(json, context);
expect(result).toEqual(["foo@example.com"]);
});
});
});
});

View File

@ -0,0 +1,74 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
import {
ApiSettings,
IntegrationRequest,
SelfHostedApiSettings,
} from "@bitwarden/common/tools/integration/rpc";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { ForwarderConfiguration, ForwarderContext } from "../engine";
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
import { SelfHostedApiOptions } from "../types";
// integration types
export type SimpleLoginSettings = SelfHostedApiSettings;
export type SimpleLoginOptions = SelfHostedApiOptions;
export type SimpleLoginConfiguration = ForwarderConfiguration<SimpleLoginSettings>;
// default values
const defaultSettings = Object.freeze({
token: "",
domain: "",
});
// supported RPC calls
const createForwardingEmail = Object.freeze({
url(request: IntegrationRequest, context: ForwarderContext<SimpleLoginSettings>) {
const endpoint = context.baseUrl() + "/api/alias/random/new";
const hostname = context.website(request);
const url = hostname !== "" ? `${endpoint}?hostname=${hostname}` : endpoint;
return url;
},
body(request: IntegrationRequest, context: ForwarderContext<SimpleLoginSettings>) {
return { note: context.generatedBy(request) };
},
hasJsonPayload(response: Response) {
return response.status === 200 || response.status === 201;
},
processJson(json: any) {
return [json?.alias];
},
} as CreateForwardingEmailRpcDef<SimpleLoginSettings>);
// forwarder configuration
const forwarder = Object.freeze({
defaultSettings,
settings: new UserKeyDefinition<SimpleLoginSettings>(GENERATOR_DISK, "simpleLoginForwarder", {
deserializer: (value) => value,
clearOn: [],
}),
importBuffer: new BufferedKeyDefinition<SimpleLoginSettings>(
GENERATOR_DISK,
"simpleLoginBuffer",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
),
createForwardingEmail,
} as const);
// integration-wide configuration
export const SimpleLogin = Object.freeze({
id: "simplelogin" as IntegrationId,
name: "SimpleLogin",
selfHost: "maybe",
extends: ["forwarder"],
baseUrl: "https://app.simplelogin.io",
authenticate(_request: IntegrationRequest, context: IntegrationContext<ApiSettings>) {
return { Authentication: context.authenticationToken() };
},
forwarder,
} as SimpleLoginConfiguration);

View File

@ -6,7 +6,7 @@ import { DefaultCatchallOptions } from "../data";
import { EmailCalculator, EmailRandomizer } from "../engine"; import { EmailCalculator, EmailRandomizer } from "../engine";
import { newDefaultEvaluator } from "../rx"; import { newDefaultEvaluator } from "../rx";
import { NoPolicy, CatchallGenerationOptions } from "../types"; import { NoPolicy, CatchallGenerationOptions } from "../types";
import { clone$PerUserId, sharedStateByUserId } from "../util"; import { observe$PerUserId, sharedStateByUserId } from "../util";
import { CATCHALL_SETTINGS } from "./storage"; import { CATCHALL_SETTINGS } from "./storage";
@ -26,7 +26,7 @@ export class CatchallGeneratorStrategy
// configuration // configuration
durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider); durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider);
defaults$ = clone$PerUserId(this.defaultOptions); defaults$ = observe$PerUserId(() => this.defaultOptions);
toEvaluator = newDefaultEvaluator<CatchallGenerationOptions>(); toEvaluator = newDefaultEvaluator<CatchallGenerationOptions>();
readonly policy = PolicyType.PasswordGenerator; readonly policy = PolicyType.PasswordGenerator;

View File

@ -6,7 +6,7 @@ import { DefaultEffUsernameOptions, UsernameDigits } from "../data";
import { UsernameRandomizer } from "../engine"; import { UsernameRandomizer } from "../engine";
import { newDefaultEvaluator } from "../rx"; import { newDefaultEvaluator } from "../rx";
import { EffUsernameGenerationOptions, NoPolicy } from "../types"; import { EffUsernameGenerationOptions, NoPolicy } from "../types";
import { clone$PerUserId, sharedStateByUserId } from "../util"; import { observe$PerUserId, sharedStateByUserId } from "../util";
import { EFF_USERNAME_SETTINGS } from "./storage"; import { EFF_USERNAME_SETTINGS } from "./storage";
@ -25,7 +25,7 @@ export class EffUsernameGeneratorStrategy
// configuration // configuration
durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider); durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider);
defaults$ = clone$PerUserId(this.defaultOptions); defaults$ = observe$PerUserId(() => this.defaultOptions);
toEvaluator = newDefaultEvaluator<EffUsernameGenerationOptions>(); toEvaluator = newDefaultEvaluator<EffUsernameGenerationOptions>();
readonly policy = PolicyType.PasswordGenerator; readonly policy = PolicyType.PasswordGenerator;

View File

@ -7,41 +7,17 @@ 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 { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { StateProvider } from "@bitwarden/common/platform/state"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RestClient } from "@bitwarden/common/tools/integration/rpc";
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state"; import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key"; import { UserKey } from "@bitwarden/common/types/key";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec"; import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec";
import { DefaultDuckDuckGoOptions } from "../data"; import { AddyIo, Fastmail, FirefoxRelay } from "../integration";
import { DefaultPolicyEvaluator } from "../policies"; import { DefaultPolicyEvaluator } from "../policies";
import { ApiOptions } from "../types";
import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy"; import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "./storage";
class TestForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
constructor(
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider, { website: null, token: "" });
}
get key() {
// arbitrary.
return DUCK_DUCK_GO_FORWARDER;
}
get rolloverKey() {
return DUCK_DUCK_GO_BUFFER;
}
defaults$ = (userId: UserId) => {
return of(DefaultDuckDuckGoOptions);
};
}
const SomeUser = "some user" as UserId; const SomeUser = "some user" as UserId;
const AnotherUser = "another user" as UserId; const AnotherUser = "another user" as UserId;
@ -56,6 +32,8 @@ describe("ForwarderGeneratorStrategy", () => {
const encryptService = mock<EncryptService>(); const encryptService = mock<EncryptService>();
const keyService = mock<CryptoService>(); const keyService = mock<CryptoService>();
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const restClient = mock<RestClient>();
const i18nService = mock<I18nService>();
beforeEach(() => { beforeEach(() => {
const keyAvailable = of({} as UserKey); const keyAvailable = of({} as UserKey);
@ -68,7 +46,14 @@ describe("ForwarderGeneratorStrategy", () => {
describe("durableState", () => { describe("durableState", () => {
it("constructs a secret state", () => { it("constructs a secret state", () => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider); const strategy = new ForwarderGeneratorStrategy(
AddyIo,
restClient,
i18nService,
encryptService,
keyService,
stateProvider,
);
const result = strategy.durableState(SomeUser); const result = strategy.durableState(SomeUser);
@ -76,7 +61,14 @@ describe("ForwarderGeneratorStrategy", () => {
}); });
it("returns the same secret state for a single user", () => { it("returns the same secret state for a single user", () => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider); const strategy = new ForwarderGeneratorStrategy(
AddyIo,
restClient,
i18nService,
encryptService,
keyService,
stateProvider,
);
const firstResult = strategy.durableState(SomeUser); const firstResult = strategy.durableState(SomeUser);
const secondResult = strategy.durableState(SomeUser); const secondResult = strategy.durableState(SomeUser);
@ -85,7 +77,14 @@ describe("ForwarderGeneratorStrategy", () => {
}); });
it("returns a different secret state for a different user", () => { it("returns a different secret state for a different user", () => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider); const strategy = new ForwarderGeneratorStrategy(
AddyIo,
restClient,
i18nService,
encryptService,
keyService,
stateProvider,
);
const firstResult = strategy.durableState(SomeUser); const firstResult = strategy.durableState(SomeUser);
const secondResult = strategy.durableState(AnotherUser); const secondResult = strategy.durableState(AnotherUser);
@ -98,7 +97,14 @@ describe("ForwarderGeneratorStrategy", () => {
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
"should map any input (= %p) to the default policy evaluator", "should map any input (= %p) to the default policy evaluator",
async (policies) => { async (policies) => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider); const strategy = new ForwarderGeneratorStrategy(
AddyIo,
restClient,
i18nService,
encryptService,
keyService,
stateProvider,
);
const evaluator$ = of(policies).pipe(strategy.toEvaluator()); const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$); const evaluator = await firstValueFrom(evaluator$);
@ -107,4 +113,39 @@ describe("ForwarderGeneratorStrategy", () => {
}, },
); );
}); });
describe("generate", () => {
it("issues a remote procedure request to create the forwarding address", async () => {
restClient.fetchJson.mockResolvedValue("jdoe@example.com");
const strategy = new ForwarderGeneratorStrategy(
FirefoxRelay,
restClient,
i18nService,
encryptService,
keyService,
stateProvider,
);
const result = await strategy.generate({ website: null });
expect(result).toEqual("jdoe@example.com");
});
it("issues a remote procedure request to look up the account id before creating the forwarding address", async () => {
restClient.fetchJson.mockResolvedValue("some account id");
restClient.fetchJson.mockResolvedValue("jdoe@example.com");
const strategy = new ForwarderGeneratorStrategy(
Fastmail,
restClient,
i18nService,
encryptService,
keyService,
stateProvider,
);
const result = await strategy.generate({ website: null, prefix: "", domain: "example.com" });
expect(result).toEqual("jdoe@example.com");
});
});
}); });

View File

@ -1,32 +1,39 @@
import { map } from "rxjs"; import { map } from "rxjs";
import { Jsonify } from "type-fest";
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 { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { import {
SingleUserState, ApiSettings,
StateProvider, IntegrationRequest,
UserKeyDefinition, RestClient,
} from "@bitwarden/common/platform/state"; } from "@bitwarden/common/tools/integration/rpc";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state"; import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer"; import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer";
import { SecretClassifier } from "@bitwarden/common/tools/state/secret-classifier";
import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition"; import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition";
import { SecretState } from "@bitwarden/common/tools/state/secret-state"; import { SecretState } from "@bitwarden/common/tools/state/secret-state";
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor"; import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { GeneratorStrategy } from "../abstractions"; import { GeneratorStrategy } from "../abstractions";
import { ForwarderConfiguration, AccountRequest, ForwarderContext } from "../engine";
import { CreateForwardingAddressRpc } from "../engine/rpc/create-forwarding-address";
import { GetAccountIdRpc } from "../engine/rpc/get-account-id";
import { newDefaultEvaluator } from "../rx"; import { newDefaultEvaluator } from "../rx";
import { ApiOptions, NoPolicy } from "../types"; import { NoPolicy } from "../types";
import { clone$PerUserId, sharedByUserId } from "../util"; import { observe$PerUserId, sharedByUserId } from "../util";
import { OptionsClassifier } from "./options-classifier";
const OPTIONS_FRAME_SIZE = 512; const OPTIONS_FRAME_SIZE = 512;
/** An email forwarding service configurable through an API. */ /** An email forwarding service configurable through an API. */
export abstract class ForwarderGeneratorStrategy< export class ForwarderGeneratorStrategy<
Options extends ApiOptions, Settings extends ApiSettings,
Options extends Settings & IntegrationRequest = Settings & IntegrationRequest,
> extends GeneratorStrategy<Options, NoPolicy> { > extends GeneratorStrategy<Options, NoPolicy> {
/** Initializes the generator strategy /** Initializes the generator strategy
* @param encryptService protects sensitive forwarder options * @param encryptService protects sensitive forwarder options
@ -34,26 +41,45 @@ export abstract class ForwarderGeneratorStrategy<
* @param stateProvider creates the durable state for options storage * @param stateProvider creates the durable state for options storage
*/ */
constructor( constructor(
private readonly configuration: ForwarderConfiguration<Settings>,
private client: RestClient,
private i18nService: I18nService,
private readonly encryptService: EncryptService, private readonly encryptService: EncryptService,
private readonly keyService: CryptoService, private readonly keyService: CryptoService,
private stateProvider: StateProvider, private stateProvider: StateProvider,
private readonly defaultOptions: Options,
) { ) {
super(); super();
} }
/** configures forwarder secret storage */
protected abstract readonly key: UserKeyDefinition<Options>;
/** configures forwarder import buffer */
protected abstract readonly rolloverKey: BufferedKeyDefinition<Options, Options>;
// configuration // configuration
readonly policy = PolicyType.PasswordGenerator; readonly policy = PolicyType.PasswordGenerator;
defaults$ = clone$PerUserId(this.defaultOptions); defaults$ = observe$PerUserId<Options>(
() => this.configuration.forwarder.defaultSettings as Options,
);
toEvaluator = newDefaultEvaluator<Options>(); toEvaluator = newDefaultEvaluator<Options>();
durableState = sharedByUserId((userId) => this.getUserSecrets(userId)); durableState = sharedByUserId((userId) => this.getUserSecrets(userId));
private get key() {
return this.configuration.forwarder.settings;
}
private get rolloverKey() {
return this.configuration.forwarder.importBuffer;
}
generate = async (options: Options) => {
const requestOptions: IntegrationRequest & AccountRequest = { website: options.website };
const getAccount = await this.getAccountId(this.configuration, options);
if (getAccount) {
requestOptions.accountId = await this.client.fetchJson(getAccount, requestOptions);
}
const create = this.createForwardingAddress(this.configuration, options);
const result = await this.client.fetchJson(create, requestOptions);
return result;
};
// per-user encrypted state // per-user encrypted state
private getUserSecrets(userId: UserId): SingleUserState<Options> { private getUserSecrets(userId: UserId): SingleUserState<Options> {
// construct the encryptor // construct the encryptor
@ -61,23 +87,27 @@ export abstract class ForwarderGeneratorStrategy<
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
// always exclude request properties // always exclude request properties
const classifier = SecretClassifier.allSecret<Options>().exclude("website"); const classifier = new OptionsClassifier<Settings, Options>();
// Derive the secret key definition // Derive the secret key definition
const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, { const key = SecretKeyDefinition.value<Options, Record<string, never>, Settings>(
deserializer: (d) => this.key.deserializer(d), this.key.stateDefinition,
cleanupDelayMs: this.key.cleanupDelayMs, this.key.key,
clearOn: this.key.clearOn, classifier,
}); {
deserializer: (d: Jsonify<Options>) => this.key.deserializer(d as any) as any,
cleanupDelayMs: this.key.cleanupDelayMs,
clearOn: this.key.clearOn,
},
);
// the type parameter is explicit because type inference fails for `Omit<Options, "website">` // the type parameter is explicit because type inference fails for `Omit<Options, "website">`
const secretState = SecretState.from< const secretState = SecretState.from<Options, void, Options, Record<string, never>, Settings>(
Options, userId,
void, key,
Options, this.stateProvider,
Record<keyof Options, never>, encryptor,
Omit<Options, "website"> );
>(userId, key, this.stateProvider, encryptor);
// rollover should occur once the user key is available for decryption // rollover should occur once the user key is available for decryption
const canDecrypt$ = this.keyService const canDecrypt$ = this.keyService
@ -90,6 +120,39 @@ export abstract class ForwarderGeneratorStrategy<
canDecrypt$, canDecrypt$,
); );
return rolloverState; // cast through unknown required because there's no way to prove to
// the compiler that `OptionsClassifier` runs within the buffer wrapping
// the secret state.
return rolloverState as unknown as SingleUserState<Options>;
}
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,233 +0,0 @@
import { firstValueFrom } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { Forwarders, DefaultAddyIoOptions } from "../../data";
import { ADDY_IO_FORWARDER } from "../storage";
import { AddyIoForwarder } from "./addy-io";
import { mockApiService, mockI18nService } from "./mocks.jest";
const SomeUser = "some user" as UserId;
describe("Addy.io Forwarder", () => {
it("key returns the Addy IO forwarder key", () => {
const forwarder = new AddyIoForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(ADDY_IO_FORWARDER);
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new AddyIoForwarder(null, null, null, null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultAddyIoOptions);
});
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token,
domain: "example.com",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.AddyIo.name);
});
it.each([null, ""])(
"throws an error if the domain is missing (domain = %p)",
async (domain) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain,
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwarderNoDomain");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith("forwarderNoDomain", Forwarders.AddyIo.name);
},
);
it.each([null, ""])(
"throws an error if the baseUrl is missing (baseUrl = %p)",
async (baseUrl) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl,
}),
).rejects.toEqual("forwarderNoUrl");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.AddyIo.name);
},
);
it.each([
["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"],
["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"],
["forwarderGeneratedBy", "not provided", null, ""],
["forwarderGeneratedBy", "not provided", "", ""],
])(
"describes the website with %p when the website is %s (= %p)",
async (translationKey, _ignored, website, expectedWebsite) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await forwarder.generate({
website,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
});
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite);
},
);
it.each([
["jane.doe@example.com", 201],
["john.doe@example.com", 201],
["jane.doe@example.com", 200],
["john.doe@example.com", 200],
])(
"returns the generated email address (= %p) if the request is successful (status = %p)",
async (email, status) => {
const apiService = mockApiService(status, { data: { email } });
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
});
expect(result).toEqual(email);
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
},
);
it("throws an invalid token error if the request fails with a 401", async () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwaderInvalidToken",
Forwarders.AddyIo.name,
);
});
it("throws an unknown error if the request fails and no status is provided", async () => {
const apiService = mockApiService(500, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderUnknownError",
Forwarders.AddyIo.name,
);
});
it.each([
[100, "Continue"],
[202, "Accepted"],
[300, "Multiple Choices"],
[418, "I'm a teapot"],
[500, "Internal Server Error"],
[600, "Unknown Status"],
])(
"throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided",
async (statusCode, statusText) => {
const apiService = mockApiService(statusCode, {}, statusText);
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwarderError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderError",
Forwarders.AddyIo.name,
statusText,
);
},
);
});
});

View File

@ -1,100 +0,0 @@
import { ApiService } from "@bitwarden/common/abstractions/api.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 { DefaultAddyIoOptions, Forwarders } from "../../data";
import { EmailDomainOptions, SelfHostedApiOptions } from "../../types";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../storage";
/** Generates a forwarding address for addy.io (formerly anon addy) */
export class AddyIoForwarder extends ForwarderGeneratorStrategy<
SelfHostedApiOptions & EmailDomainOptions
> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider, DefaultAddyIoOptions);
}
// configuration
readonly key = ADDY_IO_FORWARDER;
readonly rolloverKey = ADDY_IO_BUFFER;
// request
generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name);
throw error;
}
if (!options.domain || options.domain === "") {
const error = this.i18nService.t("forwarderNoDomain", Forwarders.AddyIo.name);
throw error;
}
if (!options.baseUrl || options.baseUrl === "") {
const error = this.i18nService.t("forwarderNoUrl", Forwarders.AddyIo.name);
throw error;
}
let descriptionId = "forwarderGeneratedByWithWebsite";
if (!options.website || options.website === "") {
descriptionId = "forwarderGeneratedBy";
}
const description = this.i18nService.t(descriptionId, options.website ?? "");
const url = options.baseUrl + "/api/v1/aliases";
const request = new Request(url, {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.token,
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
}),
body: JSON.stringify({
domain: options.domain,
description,
}),
});
const response = await this.apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json?.data?.email;
} else if (response.status === 401) {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name);
throw error;
} else if (response?.statusText) {
const error = this.i18nService.t(
"forwarderError",
Forwarders.AddyIo.name,
response.statusText,
);
throw error;
} else {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.AddyIo.name);
throw error;
}
};
}
export const DefaultOptions = Object.freeze({
website: null,
baseUrl: "https://app.addy.io",
domain: "",
token: "",
});

View File

@ -1,144 +0,0 @@
import { firstValueFrom } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { Forwarders, DefaultDuckDuckGoOptions } from "../../data";
import { DUCK_DUCK_GO_FORWARDER } from "../storage";
import { DuckDuckGoForwarder } from "./duck-duck-go";
import { mockApiService, mockI18nService } from "./mocks.jest";
const SomeUser = "some user" as UserId;
describe("DuckDuckGo Forwarder", () => {
it("key returns the Duck Duck Go forwarder key", () => {
const forwarder = new DuckDuckGoForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(DUCK_DUCK_GO_FORWARDER);
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new DuckDuckGoForwarder(null, null, null, null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultDuckDuckGoOptions);
});
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token,
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith(
"forwaderInvalidToken",
Forwarders.DuckDuckGo.name,
);
});
it.each([
["jane.doe@duck.com", 201, "jane.doe"],
["john.doe@duck.com", 201, "john.doe"],
["jane.doe@duck.com", 200, "jane.doe"],
["john.doe@duck.com", 200, "john.doe"],
])(
"returns the generated email address (= %p) if the request is successful (status = %p)",
async (email, status, address) => {
const apiService = mockApiService(status, { address });
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate({
website: null,
token: "token",
});
expect(result).toEqual(email);
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
},
);
it("throws an invalid token error if the request fails with a 401", async () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwaderInvalidToken",
Forwarders.DuckDuckGo.name,
);
});
it("throws an unknown error if the request is successful but an address isn't present", async () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwarderUnknownError",
Forwarders.DuckDuckGo.name,
);
});
it.each([100, 202, 300, 418, 500, 600])(
"throws an unknown error if the request returns any other status code (= %i)",
async (statusCode) => {
const apiService = mockApiService(statusCode, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwarderUnknownError",
Forwarders.DuckDuckGo.name,
);
},
);
});
});

View File

@ -1,75 +0,0 @@
import { ApiService } from "@bitwarden/common/abstractions/api.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 { Forwarders, DefaultDuckDuckGoOptions } from "../../data";
import { ApiOptions } from "../../types";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../storage";
/** Generates a forwarding address for DuckDuckGo */
export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider, DefaultDuckDuckGoOptions);
}
// configuration
readonly key = DUCK_DUCK_GO_FORWARDER;
readonly rolloverKey = DUCK_DUCK_GO_BUFFER;
// request
generate = async (options: ApiOptions): Promise<string> => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name);
throw error;
}
const url = "https://quack.duckduckgo.com/api/email/addresses";
const request = new Request(url, {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.token,
"Content-Type": "application/json",
}),
});
const response = await this.apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
if (json.address) {
return `${json.address}@duck.com`;
} else {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name);
throw error;
}
} else if (response.status === 401) {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name);
throw error;
} else {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name);
throw error;
}
};
}
export const DefaultOptions = Object.freeze({
website: null,
token: "",
});

View File

@ -1,281 +0,0 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserId } from "@bitwarden/common/types/guid";
import { Forwarders, DefaultFastmailOptions } from "../../data";
import { FASTMAIL_FORWARDER } from "../storage";
import { FastmailForwarder } from "./fastmail";
import { mockI18nService } from "./mocks.jest";
const SomeUser = "some user" as UserId;
type MockResponse = { status: number; body: any };
// fastmail calls nativeFetch first to resolve the accountId,
// then it calls nativeFetch again to create the forwarding address.
// The common mock doesn't work here, because this test needs to return multiple responses
function mockApiService(accountId: MockResponse, forwardingAddress: MockResponse) {
function response(r: MockResponse) {
return {
status: r.status,
json: jest.fn().mockImplementation(() => Promise.resolve(r.body)),
};
}
return {
nativeFetch: jest
.fn()
.mockImplementationOnce((r: Request) => response(accountId))
.mockImplementationOnce((r: Request) => response(forwardingAddress)),
} as unknown as ApiService;
}
const EmptyResponse: MockResponse = Object.freeze({
status: 200,
body: Object.freeze({}),
});
const AccountIdSuccess: MockResponse = Object.freeze({
status: 200,
body: Object.freeze({
primaryAccounts: Object.freeze({
"https://www.fastmail.com/dev/maskedemail": "accountId",
}),
}),
});
// the tests
describe("Fastmail Forwarder", () => {
it("key returns the Fastmail forwarder key", () => {
const forwarder = new FastmailForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(FASTMAIL_FORWARDER);
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new FastmailForwarder(null, null, null, null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultFastmailOptions);
});
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(AccountIdSuccess, EmptyResponse);
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token,
domain: "example.com",
prefix: "prefix",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.Fastmail.name);
});
it.each([401, 403])(
"throws a no account id error if the accountId request responds with a status other than 200",
async (status) => {
const apiService = mockApiService({ status, body: {} }, EmptyResponse);
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
}),
).rejects.toEqual("forwarderNoAccountId");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwarderNoAccountId",
Forwarders.Fastmail.name,
);
},
);
it.each([
["jane.doe@example.com", 200],
["john.doe@example.com", 200],
])(
"returns the generated email address (= %p) if both requests are successful (status = %p)",
async (email, status) => {
const apiService = mockApiService(AccountIdSuccess, {
status,
body: {
methodResponses: [["MaskedEmail/set", { created: { "new-masked-email": { email } } }]],
},
});
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
});
expect(result).toEqual(email);
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
},
);
it.each([
[
"It turned inside out!",
[
"MaskedEmail/set",
{ notCreated: { "new-masked-email": { description: "It turned inside out!" } } },
],
],
["And then it exploded!", ["error", { description: "And then it exploded!" }]],
])(
"throws a forwarder error (= %p) if both requests are successful (status = %p) but masked email creation fails",
async (description, response) => {
const apiService = mockApiService(AccountIdSuccess, {
status: 200,
body: {
methodResponses: [response],
},
});
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
}),
).rejects.toEqual("forwarderError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwarderError",
Forwarders.Fastmail.name,
description,
);
},
);
it.each([401, 403])(
"throws an invalid token error if the jmap request fails with a %i",
async (status) => {
const apiService = mockApiService(AccountIdSuccess, { status, body: {} });
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwaderInvalidToken",
Forwarders.Fastmail.name,
);
},
);
it.each([
null,
[],
[[]],
[["MaskedEmail/not-a-real-op"]],
[["MaskedEmail/set", null]],
[["MaskedEmail/set", { created: null }]],
[["MaskedEmail/set", { created: { "new-masked-email": null } }]],
[["MaskedEmail/set", { notCreated: null }]],
[["MaskedEmail/set", { notCreated: { "new-masked-email": null } }]],
])(
"throws an unknown error if the jmap request is malformed (= %p)",
async (responses: any) => {
const apiService = mockApiService(AccountIdSuccess, {
status: 200,
body: {
methodResponses: responses,
},
});
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwarderUnknownError",
Forwarders.Fastmail.name,
);
},
);
it.each([100, 202, 300, 418, 500, 600])(
"throws an unknown error if the request returns any other status code (= %i)",
async (statusCode) => {
const apiService = mockApiService(AccountIdSuccess, { status: statusCode, body: {} });
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwarderUnknownError",
Forwarders.Fastmail.name,
);
},
);
});
});

View File

@ -1,150 +0,0 @@
import { ApiService } from "@bitwarden/common/abstractions/api.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 { Forwarders, DefaultFastmailOptions } from "../../data";
import { EmailPrefixOptions, ApiOptions } from "../../types";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../storage";
/** Generates a forwarding address for Fastmail */
export class FastmailForwarder extends ForwarderGeneratorStrategy<ApiOptions & EmailPrefixOptions> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider, DefaultFastmailOptions);
}
// configuration
readonly key = FASTMAIL_FORWARDER;
readonly rolloverKey = FASTMAIL_BUFFER;
// request
generate = async (options: ApiOptions & EmailPrefixOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
throw error;
}
const accountId = await this.getAccountId(options);
if (!accountId || accountId === "") {
const error = this.i18nService.t("forwarderNoAccountId", Forwarders.Fastmail.name);
throw error;
}
const body = JSON.stringify({
using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"],
methodCalls: [
[
"MaskedEmail/set",
{
accountId: accountId,
create: {
"new-masked-email": {
state: "enabled",
description: "",
forDomain: options.website ?? "",
emailPrefix: options.prefix,
},
},
},
"0",
],
],
});
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.token,
"Content-Type": "application/json",
}),
body,
};
const url = "https://api.fastmail.com/jmap/api/";
const request = new Request(url, requestInit);
const response = await this.apiService.nativeFetch(request);
if (response.status === 200) {
const json = await response.json();
if (
json.methodResponses != null &&
json.methodResponses.length > 0 &&
json.methodResponses[0].length > 0
) {
if (json.methodResponses[0][0] === "MaskedEmail/set") {
if (json.methodResponses[0][1]?.created?.["new-masked-email"] != null) {
return json.methodResponses[0][1]?.created?.["new-masked-email"]?.email;
}
if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) {
const errorDescription =
json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description;
const error = this.i18nService.t(
"forwarderError",
Forwarders.Fastmail.name,
errorDescription,
);
throw error;
}
} else if (json.methodResponses[0][0] === "error") {
const errorDescription = json.methodResponses[0][1]?.description;
const error = this.i18nService.t(
"forwarderError",
Forwarders.Fastmail.name,
errorDescription,
);
throw error;
}
}
} else if (response.status === 401 || response.status === 403) {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
throw error;
}
const error = this.i18nService.t("forwarderUnknownError", Forwarders.Fastmail.name);
throw error;
};
private async getAccountId(options: ApiOptions): Promise<string> {
const requestInit: RequestInit = {
cache: "no-store",
method: "GET",
headers: new Headers({
Authorization: "Bearer " + options.token,
}),
};
const url = "https://api.fastmail.com/.well-known/jmap";
const request = new Request(url, requestInit);
const response = await this.apiService.nativeFetch(request);
if (response.status === 200) {
const json = await response.json();
if (json.primaryAccounts != null) {
return json.primaryAccounts["https://www.fastmail.com/dev/maskedemail"];
}
}
return null;
}
}
export const DefaultOptions = Object.freeze({
website: null,
domain: "",
prefix: "",
token: "",
});

View File

@ -1,147 +0,0 @@
import { firstValueFrom } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { Forwarders, DefaultFirefoxRelayOptions } from "../../data";
import { FIREFOX_RELAY_FORWARDER } from "../storage";
import { FirefoxRelayForwarder } from "./firefox-relay";
import { mockApiService, mockI18nService } from "./mocks.jest";
const SomeUser = "some user" as UserId;
describe("Firefox Relay Forwarder", () => {
it("key returns the Firefox Relay forwarder key", () => {
const forwarder = new FirefoxRelayForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(FIREFOX_RELAY_FORWARDER);
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new FirefoxRelayForwarder(null, null, null, null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultFirefoxRelayOptions);
});
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token,
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith(
"forwaderInvalidToken",
Forwarders.FirefoxRelay.name,
);
});
it.each([
["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"],
["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"],
["forwarderGeneratedBy", "not provided", null, ""],
["forwarderGeneratedBy", "not provided", "", ""],
])(
"describes the website with %p when the website is %s (= %p)",
async (translationKey, _ignored, website, expectedWebsite) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
await forwarder.generate({
website,
token: "token",
});
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite);
},
);
it.each([
["jane.doe@duck.com", 201],
["john.doe@duck.com", 201],
["jane.doe@duck.com", 200],
["john.doe@duck.com", 200],
])(
"returns the generated email address (= %p) if the request is successful (status = %p)",
async (full_address, status) => {
const apiService = mockApiService(status, { full_address });
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate({
website: null,
token: "token",
});
expect(result).toEqual(full_address);
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
},
);
it("throws an invalid token error if the request fails with a 401", async () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwaderInvalidToken",
Forwarders.FirefoxRelay.name,
);
});
it.each([100, 202, 300, 418, 500, 600])(
"throws an unknown error if the request returns any other status code (= %i)",
async (statusCode) => {
const apiService = mockApiService(statusCode, {});
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderUnknownError",
Forwarders.FirefoxRelay.name,
);
},
);
});
});

View File

@ -1,82 +0,0 @@
import { ApiService } from "@bitwarden/common/abstractions/api.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 { Forwarders, DefaultFirefoxRelayOptions } from "../../data";
import { ApiOptions } from "../../types";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../storage";
/** Generates a forwarding address for Firefox Relay */
export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider, DefaultFirefoxRelayOptions);
}
// configuration
readonly key = FIREFOX_RELAY_FORWARDER;
readonly rolloverKey = FIREFOX_RELAY_BUFFER;
// request
generate = async (options: ApiOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name);
throw error;
}
const url = "https://relay.firefox.com/api/v1/relayaddresses/";
let descriptionId = "forwarderGeneratedByWithWebsite";
if (!options.website || options.website === "") {
descriptionId = "forwarderGeneratedBy";
}
const description = this.i18nService.t(descriptionId, options.website ?? "");
const request = new Request(url, {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Token " + options.token,
"Content-Type": "application/json",
}),
body: JSON.stringify({
enabled: true,
generated_for: options.website,
description,
}),
});
const response = await this.apiService.nativeFetch(request);
if (response.status === 401) {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name);
throw error;
} else if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json.full_address;
} else {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.FirefoxRelay.name);
throw error;
}
};
}
export const DefaultOptions = Object.freeze({
website: null,
token: "",
});

View File

@ -1,277 +0,0 @@
import { firstValueFrom } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { Forwarders, DefaultForwardEmailOptions } from "../../data";
import { FORWARD_EMAIL_FORWARDER } from "../storage";
import { ForwardEmailForwarder } from "./forward-email";
import { mockApiService, mockI18nService } from "./mocks.jest";
const SomeUser = "some user" as UserId;
describe("ForwardEmail Forwarder", () => {
it("key returns the Forward Email forwarder key", () => {
const forwarder = new ForwardEmailForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(FORWARD_EMAIL_FORWARDER);
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new ForwardEmailForwarder(null, null, null, null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultForwardEmailOptions);
});
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token,
domain: "example.com",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith(
"forwaderInvalidToken",
Forwarders.ForwardEmail.name,
);
});
it.each([null, ""])(
"throws an error if the domain is missing (domain = %p)",
async (domain) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain,
}),
).rejects.toEqual("forwarderNoDomain");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith(
"forwarderNoDomain",
Forwarders.ForwardEmail.name,
);
},
);
it.each([
["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"],
["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"],
["forwarderGeneratedBy", "not provided", null, ""],
["forwarderGeneratedBy", "not provided", "", ""],
])(
"describes the website with %p when the website is %s (= %p)",
async (translationKey, _ignored, website, expectedWebsite) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await forwarder.generate({
website,
token: "token",
domain: "example.com",
});
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite);
},
);
it.each([
["jane.doe@example.com", 201, { name: "jane.doe", domain: { name: "example.com" } }],
["jane.doe@example.com", 201, { name: "jane.doe" }],
["john.doe@example.com", 201, { name: "john.doe", domain: { name: "example.com" } }],
["john.doe@example.com", 201, { name: "john.doe" }],
["jane.doe@example.com", 200, { name: "jane.doe", domain: { name: "example.com" } }],
["jane.doe@example.com", 200, { name: "jane.doe" }],
["john.doe@example.com", 200, { name: "john.doe", domain: { name: "example.com" } }],
["john.doe@example.com", 200, { name: "john.doe" }],
])(
"returns the generated email address (= %p) if the request is successful (status = %p)",
async (email, status, response) => {
const apiService = mockApiService(status, response);
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
});
expect(result).toEqual(email);
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
},
);
it("throws an invalid token error if the request fails with a 401", async () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwaderInvalidToken",
Forwarders.ForwardEmail.name,
undefined,
);
});
it("throws an invalid token error with a message if the request fails with a 401 and message", async () => {
const apiService = mockApiService(401, { message: "A message" });
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),
).rejects.toEqual("forwaderInvalidTokenWithMessage");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwaderInvalidTokenWithMessage",
Forwarders.ForwardEmail.name,
"A message",
);
});
it.each([{}, null])(
"throws an unknown error if the request fails and no status (= %p) is provided",
async (json) => {
const apiService = mockApiService(500, json);
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderUnknownError",
Forwarders.ForwardEmail.name,
);
},
);
it.each([
[100, "Continue"],
[202, "Accepted"],
[300, "Multiple Choices"],
[418, "I'm a teapot"],
[500, "Internal Server Error"],
[600, "Unknown Status"],
])(
"throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided",
async (statusCode, message) => {
const apiService = mockApiService(statusCode, { message });
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),
).rejects.toEqual("forwarderError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderError",
Forwarders.ForwardEmail.name,
message,
);
},
);
it.each([
[100, "Continue"],
[202, "Accepted"],
[300, "Multiple Choices"],
[418, "I'm a teapot"],
[500, "Internal Server Error"],
[600, "Unknown Status"],
])(
"throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided",
async (statusCode, error) => {
const apiService = mockApiService(statusCode, { error });
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),
).rejects.toEqual("forwarderError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderError",
Forwarders.ForwardEmail.name,
error,
);
},
);
});
});

View File

@ -1,104 +0,0 @@
import { ApiService } from "@bitwarden/common/abstractions/api.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 { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { Forwarders, DefaultForwardEmailOptions } from "../../data";
import { EmailDomainOptions, ApiOptions } from "../../types";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../storage";
/** Generates a forwarding address for Forward Email */
export class ForwardEmailForwarder extends ForwarderGeneratorStrategy<
ApiOptions & EmailDomainOptions
> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider, DefaultForwardEmailOptions);
}
// configuration
readonly key = FORWARD_EMAIL_FORWARDER;
readonly rolloverKey = FORWARD_EMAIL_BUFFER;
// request
generate = async (options: ApiOptions & EmailDomainOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name);
throw error;
}
if (!options.domain || options.domain === "") {
const error = this.i18nService.t("forwarderNoDomain", Forwarders.ForwardEmail.name);
throw error;
}
const url = `https://api.forwardemail.net/v1/domains/${options.domain}/aliases`;
let descriptionId = "forwarderGeneratedByWithWebsite";
if (!options.website || options.website === "") {
descriptionId = "forwarderGeneratedBy";
}
const description = this.i18nService.t(descriptionId, options.website ?? "");
const request = new Request(url, {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Basic " + Utils.fromUtf8ToB64(options.token + ":"),
"Content-Type": "application/json",
}),
body: JSON.stringify({
labels: options.website,
description,
}),
});
const response = await this.apiService.nativeFetch(request);
const json = await response.json();
if (response.status === 401) {
const messageKey =
"message" in json ? "forwaderInvalidTokenWithMessage" : "forwaderInvalidToken";
const error = this.i18nService.t(messageKey, Forwarders.ForwardEmail.name, json.message);
throw error;
} else if (response.status === 200 || response.status === 201) {
const { name, domain } = await response.json();
const domainPart = domain?.name || options.domain;
return `${name}@${domainPart}`;
} else if (json?.message) {
const error = this.i18nService.t(
"forwarderError",
Forwarders.ForwardEmail.name,
json.message,
);
throw error;
} else if (json?.error) {
const error = this.i18nService.t("forwarderError", Forwarders.ForwardEmail.name, json.error);
throw error;
} else {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.ForwardEmail.name);
throw error;
}
};
}
export const DefaultOptions = Object.freeze({
website: null,
token: "",
domain: "",
});

View File

@ -1,22 +0,0 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
/** a mock {@link ApiService} that returns a fetch-like response with a given status and body */
export function mockApiService(status: number, body: any, statusText?: string) {
return {
nativeFetch: jest.fn().mockImplementation((r: Request) => {
return {
status,
statusText,
json: jest.fn().mockImplementation(() => Promise.resolve(body)),
};
}),
} as unknown as ApiService;
}
/** a mock {@link I18nService} that returns the translation key */
export function mockI18nService() {
return {
t: jest.fn().mockImplementation((key: string) => key),
} as unknown as I18nService;
}

View File

@ -1,209 +0,0 @@
import { firstValueFrom } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { Forwarders, DefaultSimpleLoginOptions } from "../../data";
import { SIMPLE_LOGIN_FORWARDER } from "../storage";
import { mockApiService, mockI18nService } from "./mocks.jest";
import { SimpleLoginForwarder } from "./simple-login";
const SomeUser = "some user" as UserId;
describe("SimpleLogin Forwarder", () => {
it("key returns the Simple Login forwarder key", () => {
const forwarder = new SimpleLoginForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(SIMPLE_LOGIN_FORWARDER);
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new SimpleLoginForwarder(null, null, null, null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultSimpleLoginOptions);
});
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token,
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith(
"forwaderInvalidToken",
Forwarders.SimpleLogin.name,
);
});
it.each([null, ""])(
"throws an error if the baseUrl is missing (baseUrl = %p)",
async (baseUrl) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
baseUrl,
}),
).rejects.toEqual("forwarderNoUrl");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.SimpleLogin.name);
},
);
it.each([
["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"],
["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"],
["forwarderGeneratedBy", "not provided", null, ""],
["forwarderGeneratedBy", "not provided", "", ""],
])(
"describes the website with %p when the website is %s (= %p)",
async (translationKey, _ignored, website, expectedWebsite) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await forwarder.generate({
website,
token: "token",
baseUrl: "https://api.example.com",
});
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite);
},
);
it.each([
["jane.doe@example.com", 201],
["john.doe@example.com", 201],
["jane.doe@example.com", 200],
["john.doe@example.com", 200],
])(
"returns the generated email address (= %p) if the request is successful (status = %p)",
async (alias, status) => {
const apiService = mockApiService(status, { alias });
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate({
website: null,
token: "token",
baseUrl: "https://api.example.com",
});
expect(result).toEqual(alias);
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
},
);
it("throws an invalid token error if the request fails with a 401", async () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwaderInvalidToken",
Forwarders.SimpleLogin.name,
);
});
it.each([{}, null])(
"throws an unknown error if the request fails and no status (=%p) is provided",
async (body) => {
const apiService = mockApiService(500, body);
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderUnknownError",
Forwarders.SimpleLogin.name,
);
},
);
it.each([
[100, "Continue"],
[202, "Accepted"],
[300, "Multiple Choices"],
[418, "I'm a teapot"],
[500, "Internal Server Error"],
[600, "Unknown Status"],
])(
"throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided",
async (statusCode, error) => {
const apiService = mockApiService(statusCode, { error });
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwarderError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderError",
Forwarders.SimpleLogin.name,
error,
);
},
);
});
});

View File

@ -1,88 +0,0 @@
import { ApiService } from "@bitwarden/common/abstractions/api.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 { Forwarders, DefaultSimpleLoginOptions } from "../../data";
import { SelfHostedApiOptions } from "../../types";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../storage";
/** Generates a forwarding address for Simple Login */
export class SimpleLoginForwarder extends ForwarderGeneratorStrategy<SelfHostedApiOptions> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider, DefaultSimpleLoginOptions);
}
// configuration
readonly key = SIMPLE_LOGIN_FORWARDER;
readonly rolloverKey = SIMPLE_LOGIN_BUFFER;
// request
generate = async (options: SelfHostedApiOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name);
throw error;
}
if (!options.baseUrl || options.baseUrl === "") {
const error = this.i18nService.t("forwarderNoUrl", Forwarders.SimpleLogin.name);
throw error;
}
let url = options.baseUrl + "/api/alias/random/new";
let noteId = "forwarderGeneratedBy";
if (options.website && options.website !== "") {
url += "?hostname=" + options.website;
noteId = "forwarderGeneratedByWithWebsite";
}
const note = this.i18nService.t(noteId, options.website ?? "");
const request = new Request(url, {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authentication: options.token,
"Content-Type": "application/json",
}),
body: JSON.stringify({ note }),
});
const response = await this.apiService.nativeFetch(request);
if (response.status === 401) {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name);
throw error;
}
const json = await response.json();
if (response.status === 200 || response.status === 201) {
return json.alias;
} else if (json?.error) {
const error = this.i18nService.t("forwarderError", Forwarders.SimpleLogin.name, json.error);
throw error;
} else {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.SimpleLogin.name);
throw error;
}
};
}
export const DefaultOptions = Object.freeze({
website: null,
baseUrl: "https://app.simplelogin.io",
token: "",
});

View File

@ -1,11 +1,6 @@
export { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy"; export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy";
export { PasswordGeneratorStrategy } from "./password-generator-strategy"; export { PasswordGeneratorStrategy } from "./password-generator-strategy";
export { CatchallGeneratorStrategy } from "./catchall-generator-strategy"; export { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy"; export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy";
export { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy"; export { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy";
export { AddyIoForwarder } from "./forwarders/addy-io";
export { DuckDuckGoForwarder } from "./forwarders/duck-duck-go";
export { FastmailForwarder } from "./forwarders/fastmail";
export { FirefoxRelayForwarder } from "./forwarders/firefox-relay";
export { ForwardEmailForwarder } from "./forwarders/forward-email";
export { SimpleLoginForwarder } from "./forwarders/simple-login";

View File

@ -0,0 +1,60 @@
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { OptionsClassifier } from "./options-classifier";
type SomeSettings = { foo: string };
type SomeOptions = IntegrationRequest & SomeSettings;
describe("OptionsClassifier", () => {
describe("classify", () => {
it("classifies properties from its input to the secret", () => {
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
const result = classifier.classify({ foo: "bar", website: null });
expect(result.secret).toMatchObject({ foo: "bar" });
});
it("omits the website property from the secret", () => {
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
const result = classifier.classify({ foo: "bar", website: "www.example.com" });
expect(result.secret).not.toHaveProperty("website");
});
it("has no disclosed data", () => {
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
const result = classifier.classify({ foo: "bar", website: "www.example.com" });
expect(result.disclosed).toEqual({});
});
});
describe("declassify", () => {
it("copies properties from secret to its output", () => {
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
const result = classifier.declassify(null, { foo: "bar" });
expect(result).toMatchObject({ foo: "bar" });
});
it("adds a website property to its output", () => {
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
const result = classifier.declassify(null, { foo: "bar" });
expect(result).toMatchObject({ website: null });
});
it("ignores disclosed data", () => {
const classifier = new OptionsClassifier<SomeSettings, SomeOptions>();
const result = classifier.declassify({ foo: "biz" }, { foo: "bar" });
expect(result).toEqual({ foo: "bar", website: null });
});
});
});

View File

@ -0,0 +1,44 @@
import { Jsonify } from "type-fest";
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { Classifier } from "@bitwarden/common/tools/state/classifier";
/** Classifies an object by excluding IntegrationRequest parameters.
*/
export class OptionsClassifier<
Settings,
Options extends IntegrationRequest & Settings = IntegrationRequest & Settings,
> implements Classifier<Options, Record<string, never>, Settings>
{
/** Partitions `secret` into its disclosed properties and secret properties.
* @param value The object to partition
* @returns an object that classifies secrets.
* The `disclosed` member is new and contains disclosed properties.
* The `secret` member is a copy of the secret parameter, including its
* prototype, with all disclosed and excluded properties deleted.
*/
classify(value: Options) {
const secret = JSON.parse(JSON.stringify(value));
delete secret.website;
const disclosed: Record<string, never> = {};
return { disclosed, secret };
}
/** Merges the properties of `secret` and `disclosed`. When `secret` and
* `disclosed` contain the same property, the `secret` property overrides
* the `disclosed` property.
* @param disclosed an object whose disclosed properties are merged into
* the output. Unknown properties are ignored.
* @param secret an objects whose properties are merged into the output.
* Excluded properties are ignored. Unknown properties are retained.
* @returns a new object containing the merged data.
*
* @remarks Declassified data is always jsonified--the purpose of classifying it is
* to Jsonify it,
* which causes type conversions.
*/
declassify(_disclosed: Jsonify<Record<keyof Settings, never>>, secret: Jsonify<Settings>) {
const result = { ...(secret as any), website: null };
return result as Jsonify<Options>;
}
}

View File

@ -6,7 +6,7 @@ import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions, Polici
import { PasswordRandomizer } from "../engine"; import { PasswordRandomizer } from "../engine";
import { mapPolicyToEvaluator } from "../rx"; import { mapPolicyToEvaluator } from "../rx";
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
import { clone$PerUserId, sharedStateByUserId } from "../util"; import { observe$PerUserId, sharedStateByUserId } from "../util";
import { PASSPHRASE_SETTINGS } from "./storage"; import { PASSPHRASE_SETTINGS } from "./storage";
@ -25,7 +25,7 @@ export class PassphraseGeneratorStrategy
// configuration // configuration
durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider); durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider);
defaults$ = clone$PerUserId(DefaultPassphraseGenerationOptions); defaults$ = observe$PerUserId(() => DefaultPassphraseGenerationOptions);
readonly policy = PolicyType.PasswordGenerator; readonly policy = PolicyType.PasswordGenerator;
toEvaluator() { toEvaluator() {
return mapPolicyToEvaluator(Policies.Passphrase); return mapPolicyToEvaluator(Policies.Passphrase);

View File

@ -6,7 +6,7 @@ import { Policies, DefaultPasswordGenerationOptions } from "../data";
import { PasswordRandomizer } from "../engine"; import { PasswordRandomizer } from "../engine";
import { mapPolicyToEvaluator } from "../rx"; import { mapPolicyToEvaluator } from "../rx";
import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types"; import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types";
import { clone$PerUserId, sharedStateByUserId, sum } from "../util"; import { observe$PerUserId, sharedStateByUserId, sum } from "../util";
import { PASSWORD_SETTINGS } from "./storage"; import { PASSWORD_SETTINGS } from "./storage";
@ -24,7 +24,7 @@ export class PasswordGeneratorStrategy
// configuration // configuration
durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider); durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider);
defaults$ = clone$PerUserId(DefaultPasswordGenerationOptions); defaults$ = observe$PerUserId(() => DefaultPasswordGenerationOptions);
readonly policy = PolicyType.PasswordGenerator; readonly policy = PolicyType.PasswordGenerator;
toEvaluator() { toEvaluator() {
return mapPolicyToEvaluator(Policies.Password); return mapPolicyToEvaluator(Policies.Password);

View File

@ -4,18 +4,6 @@ import {
SUBADDRESS_SETTINGS, SUBADDRESS_SETTINGS,
PASSPHRASE_SETTINGS, PASSPHRASE_SETTINGS,
PASSWORD_SETTINGS, PASSWORD_SETTINGS,
SIMPLE_LOGIN_FORWARDER,
FORWARD_EMAIL_FORWARDER,
FIREFOX_RELAY_FORWARDER,
FASTMAIL_FORWARDER,
DUCK_DUCK_GO_FORWARDER,
ADDY_IO_FORWARDER,
ADDY_IO_BUFFER,
DUCK_DUCK_GO_BUFFER,
FASTMAIL_BUFFER,
FIREFOX_RELAY_BUFFER,
FORWARD_EMAIL_BUFFER,
SIMPLE_LOGIN_BUFFER,
} from "./storage"; } from "./storage";
describe("Key definitions", () => { describe("Key definitions", () => {
@ -58,112 +46,4 @@ describe("Key definitions", () => {
expect(result).toBe(value); expect(result).toBe(value);
}); });
}); });
describe("ADDY_IO_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = ADDY_IO_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("DUCK_DUCK_GO_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = DUCK_DUCK_GO_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("FASTMAIL_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FASTMAIL_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("FIREFOX_RELAY_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FIREFOX_RELAY_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("FORWARD_EMAIL_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FORWARD_EMAIL_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("SIMPLE_LOGIN_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = SIMPLE_LOGIN_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("ADDY_IO_BUFFER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = ADDY_IO_BUFFER.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("DUCK_DUCK_GO_BUFFER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = DUCK_DUCK_GO_BUFFER.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("FASTMAIL_BUFFER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FASTMAIL_BUFFER.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("FIREFOX_RELAY_BUFFER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FIREFOX_RELAY_BUFFER.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("FORWARD_EMAIL_BUFFER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FORWARD_EMAIL_BUFFER.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("SIMPLE_LOGIN_BUFFER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = SIMPLE_LOGIN_BUFFER.options.deserializer(value);
expect(result).toBe(value);
});
});
}); });

View File

@ -1,15 +1,10 @@
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { import {
PassphraseGenerationOptions, PassphraseGenerationOptions,
PasswordGenerationOptions, PasswordGenerationOptions,
CatchallGenerationOptions, CatchallGenerationOptions,
EffUsernameGenerationOptions, EffUsernameGenerationOptions,
ApiOptions,
EmailDomainOptions,
EmailPrefixOptions,
SelfHostedApiOptions,
SubaddressGenerationOptions, SubaddressGenerationOptions,
} from "../types"; } from "../types";
@ -62,123 +57,3 @@ export const SUBADDRESS_SETTINGS = new UserKeyDefinition<SubaddressGenerationOpt
clearOn: [], clearOn: [],
}, },
); );
/** backing store configuration for {@link Forwarders.AddyIo} */
export const ADDY_IO_FORWARDER = new UserKeyDefinition<SelfHostedApiOptions & EmailDomainOptions>(
GENERATOR_DISK,
"addyIoForwarder",
{
deserializer: (value) => value,
clearOn: [],
},
);
/** backing store configuration for {@link Forwarders.DuckDuckGo} */
export const DUCK_DUCK_GO_FORWARDER = new UserKeyDefinition<ApiOptions>(
GENERATOR_DISK,
"duckDuckGoForwarder",
{
deserializer: (value) => value,
clearOn: [],
},
);
/** backing store configuration for {@link Forwarders.FastMail} */
export const FASTMAIL_FORWARDER = new UserKeyDefinition<ApiOptions & EmailPrefixOptions>(
GENERATOR_DISK,
"fastmailForwarder",
{
deserializer: (value) => value,
clearOn: [],
},
);
/** backing store configuration for {@link Forwarders.FireFoxRelay} */
export const FIREFOX_RELAY_FORWARDER = new UserKeyDefinition<ApiOptions>(
GENERATOR_DISK,
"firefoxRelayForwarder",
{
deserializer: (value) => value,
clearOn: [],
},
);
/** backing store configuration for {@link Forwarders.ForwardEmail} */
export const FORWARD_EMAIL_FORWARDER = new UserKeyDefinition<ApiOptions & EmailDomainOptions>(
GENERATOR_DISK,
"forwardEmailForwarder",
{
deserializer: (value) => value,
clearOn: [],
},
);
/** backing store configuration for {@link forwarders.SimpleLogin} */
export const SIMPLE_LOGIN_FORWARDER = new UserKeyDefinition<SelfHostedApiOptions>(
GENERATOR_DISK,
"simpleLoginForwarder",
{
deserializer: (value) => value,
clearOn: [],
},
);
/** backing store configuration for {@link Forwarders.AddyIo} */
export const ADDY_IO_BUFFER = new BufferedKeyDefinition<SelfHostedApiOptions & EmailDomainOptions>(
GENERATOR_DISK,
"addyIoBuffer",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
);
/** backing store configuration for {@link Forwarders.DuckDuckGo} */
export const DUCK_DUCK_GO_BUFFER = new BufferedKeyDefinition<ApiOptions>(
GENERATOR_DISK,
"duckDuckGoBuffer",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
);
/** backing store configuration for {@link Forwarders.FastMail} */
export const FASTMAIL_BUFFER = new BufferedKeyDefinition<ApiOptions & EmailPrefixOptions>(
GENERATOR_DISK,
"fastmailBuffer",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
);
/** backing store configuration for {@link Forwarders.FireFoxRelay} */
export const FIREFOX_RELAY_BUFFER = new BufferedKeyDefinition<ApiOptions>(
GENERATOR_DISK,
"firefoxRelayBuffer",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
);
/** backing store configuration for {@link Forwarders.ForwardEmail} */
export const FORWARD_EMAIL_BUFFER = new BufferedKeyDefinition<ApiOptions & EmailDomainOptions>(
GENERATOR_DISK,
"forwardEmailBuffer",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
);
/** backing store configuration for {@link forwarders.SimpleLogin} */
export const SIMPLE_LOGIN_BUFFER = new BufferedKeyDefinition<SelfHostedApiOptions>(
GENERATOR_DISK,
"simpleLoginBuffer",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
);

View File

@ -6,7 +6,7 @@ import { DefaultSubaddressOptions } from "../data";
import { EmailCalculator, EmailRandomizer } from "../engine"; import { EmailCalculator, EmailRandomizer } from "../engine";
import { newDefaultEvaluator } from "../rx"; import { newDefaultEvaluator } from "../rx";
import { SubaddressGenerationOptions, NoPolicy } from "../types"; import { SubaddressGenerationOptions, NoPolicy } from "../types";
import { clone$PerUserId, sharedStateByUserId } from "../util"; import { observe$PerUserId, sharedStateByUserId } from "../util";
import { SUBADDRESS_SETTINGS } from "./storage"; import { SUBADDRESS_SETTINGS } from "./storage";
@ -30,7 +30,7 @@ export class SubaddressGeneratorStrategy
// configuration // configuration
durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider); durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider);
defaults$ = clone$PerUserId(this.defaultOptions); defaults$ = observe$PerUserId(() => this.defaultOptions);
toEvaluator = newDefaultEvaluator<SubaddressGenerationOptions>(); toEvaluator = newDefaultEvaluator<SubaddressGenerationOptions>();
readonly policy = PolicyType.PasswordGenerator; readonly policy = PolicyType.PasswordGenerator;

View File

@ -1,4 +1,5 @@
import { RequestOptions } from "./forwarder-options"; import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { UsernameGenerationMode } from "./generator-options"; import { UsernameGenerationMode } from "./generator-options";
/** Settings supported when generating an email subaddress */ /** Settings supported when generating an email subaddress */
@ -11,4 +12,4 @@ export type CatchallGenerationOptions = {
* is `jd`, then the generated email address will be `jd@mydomain.io` * is `jd`, then the generated email address will be `jd@mydomain.io`
*/ */
catchallDomain?: string; catchallDomain?: string;
} & RequestOptions; } & IntegrationRequest;

View File

@ -1,4 +1,4 @@
import { RequestOptions } from "./forwarder-options"; import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
/** Settings supported when generating a username using the EFF word list */ /** Settings supported when generating a username using the EFF word list */
export type EffUsernameGenerationOptions = { export type EffUsernameGenerationOptions = {
@ -7,4 +7,4 @@ export type EffUsernameGenerationOptions = {
/** when true, a random number is appended to the username */ /** when true, a random number is appended to the username */
wordIncludeNumber?: boolean; wordIncludeNumber?: boolean;
} & RequestOptions; } & IntegrationRequest;

View File

@ -1,14 +1,17 @@
import { IntegrationId } from "@bitwarden/common/tools/integration";
import {
ApiSettings,
IntegrationRequest,
SelfHostedApiSettings,
} from "@bitwarden/common/tools/integration/rpc";
import { EmailDomainSettings, EmailPrefixSettings } from "../engine";
/** Identifiers for email forwarding services. /** Identifiers for email forwarding services.
* @remarks These are used to select forwarder-specific options. * @remarks These are used to select forwarder-specific options.
* The must be kept in sync with the forwarder implementations. * The must be kept in sync with the forwarder implementations.
*/ */
export type ForwarderId = export type ForwarderId = IntegrationId;
| "anonaddy"
| "duckduckgo"
| "fastmail"
| "firefoxrelay"
| "forwardemail"
| "simplelogin";
/** Metadata format for email forwarding services. */ /** Metadata format for email forwarding services. */
export type ForwarderMetadata = { export type ForwarderMetadata = {
@ -23,50 +26,13 @@ export type ForwarderMetadata = {
}; };
/** Options common to all forwarder APIs */ /** Options common to all forwarder APIs */
export type ApiOptions = { export type ApiOptions = ApiSettings & IntegrationRequest;
/** bearer token that authenticates bitwarden to the forwarder.
* This is required to issue an API request.
*/
token?: string;
} & RequestOptions;
/** Options that provide contextual information about the application state
* when a forwarder is invoked.
* @remarks these fields should always be omitted when saving options.
*/
export type RequestOptions = {
/** @param website The domain of the website the generated email is used
* within. This should be set to `null` when the request is not specific
* to any website.
*/
website: string | null;
};
/** Api configuration for forwarders that support self-hosted installations. */ /** Api configuration for forwarders that support self-hosted installations. */
export type SelfHostedApiOptions = ApiOptions & { export type SelfHostedApiOptions = SelfHostedApiSettings & IntegrationRequest;
/** The base URL of the forwarder's API.
* When this is empty, the forwarder's default production API is used.
*/
baseUrl: string;
};
/** Api configuration for forwarders that support custom domains. */ /** Api configuration for forwarders that support custom domains. */
export type EmailDomainOptions = { export type EmailDomainOptions = EmailDomainSettings;
/** The domain part of the generated email address.
* @remarks The domain should be authorized by the forwarder before
* submitting a request through bitwarden.
* @example If the domain is `domain.io` and the generated username
* is `jd`, then the generated email address will be `jd@mydomain.io`
*/
domain: string;
};
/** Api configuration for forwarders that support custom email parts. */ /** Api configuration for forwarders that support custom email parts. */
export type EmailPrefixOptions = EmailDomainOptions & { export type EmailPrefixOptions = EmailDomainSettings & EmailPrefixSettings;
/** A prefix joined to the generated email address' username.
* @example If the prefix is `foo`, the generated username is `bar`,
* and the domain is `domain.io`, then the generated email address is `
* then the generated username is `foobar@domain.io`.
*/
prefix: string;
};

View File

@ -1,4 +1,5 @@
import { RequestOptions } from "./forwarder-options"; import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { UsernameGenerationMode } from "./generator-options"; import { UsernameGenerationMode } from "./generator-options";
/** Settings supported when generating an email subaddress */ /** Settings supported when generating an email subaddress */
@ -8,4 +9,4 @@ export type SubaddressGenerationOptions = {
/** the email address the subaddress is applied to. */ /** the email address the subaddress is applied to. */
subaddressEmail?: string; subaddressEmail?: string;
} & RequestOptions; } & IntegrationRequest;

View File

@ -1,4 +1,4 @@
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, Observable } from "rxjs";
import { import {
SingleUserState, SingleUserState,
@ -8,14 +8,17 @@ import {
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
/** construct a method that outputs a copy of `defaultValue` as an observable. */ /** construct a method that outputs a copy of `defaultValue` as an observable. */
export function clone$PerUserId<Value>(defaultValue: Value) { export function observe$PerUserId<Value>(
create: () => Partial<Value>,
): (key: UserId) => Observable<Value> {
const _subjects = new Map<UserId, BehaviorSubject<Value>>(); const _subjects = new Map<UserId, BehaviorSubject<Value>>();
return (key: UserId) => { return (key: UserId) => {
let value = _subjects.get(key); let value = _subjects.get(key);
if (value === undefined) { if (value === undefined) {
value = new BehaviorSubject({ ...defaultValue }); const initialValue = create();
value = new BehaviorSubject({ ...initialValue } as Value);
_subjects.set(key, value); _subjects.set(key, value);
} }

View File

@ -5,7 +5,8 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { engine, services, strategies } from "@bitwarden/generator-core"; import { RestClient } from "@bitwarden/common/tools/integration/rpc";
import { engine, services, strategies, Integrations } from "@bitwarden/generator-core";
import { DefaultGeneratorNavigationService } from "@bitwarden/generator-navigation"; import { DefaultGeneratorNavigationService } from "@bitwarden/generator-navigation";
import { LegacyUsernameGenerationService } from "./legacy-username-generation.service"; import { LegacyUsernameGenerationService } from "./legacy-username-generation.service";
@ -17,12 +18,7 @@ const {
CatchallGeneratorStrategy, CatchallGeneratorStrategy,
SubaddressGeneratorStrategy, SubaddressGeneratorStrategy,
EffUsernameGeneratorStrategy, EffUsernameGeneratorStrategy,
AddyIoForwarder, ForwarderGeneratorStrategy,
DuckDuckGoForwarder,
FastmailForwarder,
FirefoxRelayForwarder,
ForwardEmailForwarder,
SimpleLoginForwarder,
} = strategies; } = strategies;
export function legacyUsernameGenerationServiceFactory( export function legacyUsernameGenerationServiceFactory(
@ -35,6 +31,7 @@ export function legacyUsernameGenerationServiceFactory(
stateProvider: StateProvider, stateProvider: StateProvider,
): UsernameGenerationServiceAbstraction { ): UsernameGenerationServiceAbstraction {
const randomizer = new CryptoServiceRandomizer(cryptoService); const randomizer = new CryptoServiceRandomizer(cryptoService);
const restClient = new RestClient(apiService, i18nService);
const usernameRandomizer = new UsernameRandomizer(randomizer); const usernameRandomizer = new UsernameRandomizer(randomizer);
const emailRandomizer = new EmailRandomizer(randomizer); const emailRandomizer = new EmailRandomizer(randomizer);
const emailCalculator = new EmailCalculator(); const emailCalculator = new EmailCalculator();
@ -55,23 +52,45 @@ export function legacyUsernameGenerationServiceFactory(
); );
const addyIo = new DefaultGeneratorService( const addyIo = new DefaultGeneratorService(
new AddyIoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), new ForwarderGeneratorStrategy(
Integrations.AddyIo,
restClient,
i18nService,
encryptService,
cryptoService,
stateProvider,
),
policyService, policyService,
); );
const duckDuckGo = new DefaultGeneratorService( const duckDuckGo = new DefaultGeneratorService(
new DuckDuckGoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), new ForwarderGeneratorStrategy(
Integrations.DuckDuckGo,
restClient,
i18nService,
encryptService,
cryptoService,
stateProvider,
),
policyService, policyService,
); );
const fastmail = new DefaultGeneratorService( const fastmail = new DefaultGeneratorService(
new FastmailForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), new ForwarderGeneratorStrategy(
Integrations.Fastmail,
restClient,
i18nService,
encryptService,
cryptoService,
stateProvider,
),
policyService, policyService,
); );
const firefoxRelay = new DefaultGeneratorService( const firefoxRelay = new DefaultGeneratorService(
new FirefoxRelayForwarder( new ForwarderGeneratorStrategy(
apiService, Integrations.FirefoxRelay,
restClient,
i18nService, i18nService,
encryptService, encryptService,
cryptoService, cryptoService,
@ -81,8 +100,9 @@ export function legacyUsernameGenerationServiceFactory(
); );
const forwardEmail = new DefaultGeneratorService( const forwardEmail = new DefaultGeneratorService(
new ForwardEmailForwarder( new ForwarderGeneratorStrategy(
apiService, Integrations.ForwardEmail,
restClient,
i18nService, i18nService,
encryptService, encryptService,
cryptoService, cryptoService,
@ -92,7 +112,14 @@ export function legacyUsernameGenerationServiceFactory(
); );
const simpleLogin = new DefaultGeneratorService( const simpleLogin = new DefaultGeneratorService(
new SimpleLoginForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), new ForwarderGeneratorStrategy(
Integrations.SimpleLogin,
restClient,
i18nService,
encryptService,
cryptoService,
stateProvider,
),
policyService, policyService,
); );

View File

@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { of } from "rxjs"; import { of } from "rxjs";
import { IntegrationId } from "@bitwarden/common/tools/integration";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { import {
GeneratorService, GeneratorService,
@ -185,7 +186,7 @@ describe("LegacyPasswordGenerationService", () => {
const navigation = createNavigationGenerator({ const navigation = createNavigationGenerator({
type: "passphrase", type: "passphrase",
username: "word", username: "word",
forwarder: "simplelogin", forwarder: "simplelogin" as IntegrationId,
}); });
const accountService = mockAccountServiceWith(SomeUser); const accountService = mockAccountServiceWith(SomeUser);
const generator = new LegacyPasswordGenerationService( const generator = new LegacyPasswordGenerationService(
@ -496,7 +497,7 @@ describe("LegacyPasswordGenerationService", () => {
const navigation = createNavigationGenerator({ const navigation = createNavigationGenerator({
type: "password", type: "password",
username: "forwarded", username: "forwarded",
forwarder: "firefoxrelay", forwarder: "firefoxrelay" as IntegrationId,
}); });
const accountService = mockAccountServiceWith(SomeUser); const accountService = mockAccountServiceWith(SomeUser);
const generator = new LegacyPasswordGenerationService( const generator = new LegacyPasswordGenerationService(

View File

@ -23,6 +23,7 @@ import {
DefaultSubaddressOptions, DefaultSubaddressOptions,
SubaddressGenerationOptions, SubaddressGenerationOptions,
policies, policies,
Integrations,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { import {
GeneratorNavigationPolicy, GeneratorNavigationPolicy,
@ -724,7 +725,7 @@ describe("LegacyUsernameGenerationService", () => {
}); });
options.type = "forwarded"; options.type = "forwarded";
options.forwardedService = "anonaddy"; options.forwardedService = Integrations.AddyIo.id;
await generator.saveOptions(options); await generator.saveOptions(options);
expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, { expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, {
@ -735,7 +736,7 @@ describe("LegacyUsernameGenerationService", () => {
}); });
options.type = "forwarded"; options.type = "forwarded";
options.forwardedService = "duckduckgo"; options.forwardedService = Integrations.DuckDuckGo.id;
await generator.saveOptions(options); await generator.saveOptions(options);
expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, { expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, {
@ -744,7 +745,7 @@ describe("LegacyUsernameGenerationService", () => {
}); });
options.type = "forwarded"; options.type = "forwarded";
options.forwardedService = "fastmail"; options.forwardedService = Integrations.Fastmail.id;
await generator.saveOptions(options); await generator.saveOptions(options);
expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, { expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
@ -753,7 +754,7 @@ describe("LegacyUsernameGenerationService", () => {
}); });
options.type = "forwarded"; options.type = "forwarded";
options.forwardedService = "firefoxrelay"; options.forwardedService = Integrations.FirefoxRelay.id;
await generator.saveOptions(options); await generator.saveOptions(options);
expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, { expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, {
@ -762,7 +763,7 @@ describe("LegacyUsernameGenerationService", () => {
}); });
options.type = "forwarded"; options.type = "forwarded";
options.forwardedService = "forwardemail"; options.forwardedService = Integrations.ForwardEmail.id;
await generator.saveOptions(options); await generator.saveOptions(options);
expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, { expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
@ -772,7 +773,7 @@ describe("LegacyUsernameGenerationService", () => {
}); });
options.type = "forwarded"; options.type = "forwarded";
options.forwardedService = "simplelogin"; options.forwardedService = Integrations.SimpleLogin.id;
await generator.saveOptions(options); await generator.saveOptions(options);
expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, { expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, {

View File

@ -1,12 +1,12 @@
import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs"; import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { import {
ApiOptions, ApiOptions,
EmailDomainOptions, EmailDomainOptions,
EmailPrefixOptions, EmailPrefixOptions,
RequestOptions,
SelfHostedApiOptions, SelfHostedApiOptions,
NoPolicy, NoPolicy,
GeneratorService, GeneratorService,
@ -30,12 +30,12 @@ type MappedOptions = {
subaddress: SubaddressGenerationOptions; subaddress: SubaddressGenerationOptions;
}; };
forwarders: { forwarders: {
addyIo: SelfHostedApiOptions & EmailDomainOptions & RequestOptions; addyIo: SelfHostedApiOptions & EmailDomainOptions & IntegrationRequest;
duckDuckGo: ApiOptions & RequestOptions; duckDuckGo: ApiOptions & IntegrationRequest;
fastmail: ApiOptions & EmailPrefixOptions & RequestOptions; fastmail: ApiOptions & EmailPrefixOptions & IntegrationRequest;
firefoxRelay: ApiOptions & RequestOptions; firefoxRelay: ApiOptions & IntegrationRequest;
forwardEmail: ApiOptions & EmailDomainOptions & RequestOptions; forwardEmail: ApiOptions & EmailDomainOptions & IntegrationRequest;
simpleLogin: SelfHostedApiOptions & RequestOptions; simpleLogin: SelfHostedApiOptions & IntegrationRequest;
}; };
}; };

View File

@ -1,6 +1,6 @@
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
import { import {
ForwarderId, ForwarderId,
RequestOptions,
CatchallGenerationOptions, CatchallGenerationOptions,
EffUsernameGenerationOptions, EffUsernameGenerationOptions,
SubaddressGenerationOptions, SubaddressGenerationOptions,
@ -10,7 +10,7 @@ import {
export type UsernameGeneratorOptions = EffUsernameGenerationOptions & export type UsernameGeneratorOptions = EffUsernameGenerationOptions &
SubaddressGenerationOptions & SubaddressGenerationOptions &
CatchallGenerationOptions & CatchallGenerationOptions &
RequestOptions & { IntegrationRequest & {
type?: UsernameGeneratorType; type?: UsernameGeneratorType;
forwardedService?: ForwarderId | ""; forwardedService?: ForwarderId | "";
forwardedAnonAddyApiToken?: string; forwardedAnonAddyApiToken?: string;